Skip to content

feat(pinballmap) Pinball Map client with on-disk cache + per-source politeness#83

Merged
jkeeley2073 merged 1 commit into
mainfrom
Dev-PinballMapClient
May 6, 2026
Merged

feat(pinballmap) Pinball Map client with on-disk cache + per-source politeness#83
jkeeley2073 merged 1 commit into
mainfrom
Dev-PinballMapClient

Conversation

@jkeeley2073
Copy link
Copy Markdown
Contributor

Summary

Phase 3 Wave 1 PR 3. Adds a typed HTTP client for the public Pinball Map API (pinballmap.com/api/v1/region/{region}/locations.json), mirroring the OpdbClient pattern: extends PoliteScraperBase, routes every request through IPolitenessGate, on-disk cache with atomic .tmp + File.Move(overwrite), telemetry under pinwiz.pinballmap.*. The DTO shape was probed against the live API on 2026-05-04 (per DL-0002, unit tests must not pin a self-defined contract); a live-contract test gated behind PINBALL_WIZARD_LIVE_CONTRACT_TESTS=1 exercises the production code path against the real endpoint.

The integration is the citation surface for the Phase 3 Wizard answer flow — each location's location_machine_xrefs[].machine.opdb_id is the bridge from a Pinball Map location to our canonical machine catalog. Politely identifying User-Agent PinballWizard/0.1 is allow-listed by pinballmap.com/robots.txt (which disallows AI crawlers but not our explicit non-AI UA); the seed manifest sets a 5 s per-request delay vs the site's published Crawl-delay: 3 for headroom.

Test Plan

  • dotnet build PinballWizard.slnx → 0 warnings, 0 errors
  • dotnet test PinballWizard.slnx → 579 passed, 0 failed (566 baseline + 13 new)
  • PINBALL_WIZARD_LIVE_CONTRACT_TESTS=1 dotnet test --filter LiveContract → 1 passed against live pinballmap.com
  • git log -1 --format='%an <%ae>'Jim Keeley <94459922+jkeeley2073@users.noreply.github.com>

Out of Scope

  • IFPA / PinballPrices clients — explicitly deferred per build-spec § Phase 3 § Non-goals
  • AI / Foundry surface — Wave 2 PR 4+
  • A second-pass sync that writes Pinball Map data into a Cosmos repository — the read-side client is sufficient for the Wizard citation flow; persistence lands when a downstream consumer needs it
  • Region enumeration — current scope is "fetch a single region by name on demand"; multi-region orchestration deferred until a feature requires it

Checklist

  • CI is green (build + test + coverage + CodeQL + sanitization)
  • PR title follows the Conventional Commits format above
  • If this is a new architectural decision, an ADR has been added under docs/adr/ — N/A, mirrors existing OPDB pattern
  • If user-visible behavior changes, README.md and/or docs/ are updated in the same PR — N/A, no user-visible CLI surface changes (client used by future consumer)
  • If a memory in ~/.claude/projects/c--projects-PinballWizard/memory/ is now stale, it has been updated or removed in the same PR — N/A
  • No TODO / FIXME / commented-out code committed
  • No new entries in <NoWarn> without a comment explaining why and the removal criterion

Pre-push self-audit (additive PRs)

Step 0 — /local-review (qualitative)

  • Ran /local-review and addressed every 🔴 finding before push
  • Local review outcome: 0 🔴 / 1 ⚠️ (intentional improvement vs sibling: scoped catch (Exception cleanupEx) + Debug log replaces catch { /* ignore */ } from OpdbClient per audit rule "no bare catch{}") / 10 categories (design, drift, error handling, security, provenance, polite-by-construction, telemetry, tests, documentation, live-contract) ✅

Step 1 — Mechanical checklist

  • Every new *Options property has at least one real getter call in src/. Verified by grep:
    • BaseUrlPinballMapClient.BuildRegionUrl + ServiceCollectionExtensions
    • BaseUrlKeyProgram.cs gating
    • HttpTimeoutSecondsServiceCollectionExtensions
    • CacheDirectoryPinballMapClient.TryGetCachePath
    • CacheTtlSecondsPinballMapClient.GetRegionLocationsBytesAsync
  • Sibling-diffed against OpdbClient; one intentional improvement noted above
  • No bare catch { } — minimum scope is catch (Exception) everywhere; cancellation propagates
  • New ISourceScraper? N/A — this is a Phase 3 read-side integration like OPDB, not a --source alias scraper. SourceAliasContractTests unaffected
  • Tests assert behavior, not just structure (PerCallFailureIsolation actually fails one region and verifies the next succeeds; PerRegionCacheKeysDoNotCollide actually exercises two regions; cache stale/hit/miss tests verify network-call counts)
  • Build is zero-warning (Directory.Build.props enforces warnings-as-errors)
  • git log -1 --format='%an <%ae>' shows personal noreply, not work email

…oliteness

Phase 3 Wave 1 PR 3. Adds a typed HTTP client for the public Pinball Map
API (pinballmap.com/api/v1/region/{region}/locations.json), mirroring the
OpdbClient pattern (politeness gate + on-disk cache + atomic .tmp write).

Why now: the citation showcase surface for the Phase 3 Wizard answer flow
needs a reliable, polite source of pinball-location-by-region data with
OPDB-id linkage to our canonical machine catalog. Each location's
location_machine_xrefs[].machine.opdb_id is the bridge — a Wizard answer
that says "this machine lives at these locations" can cite a concrete
public source. The same client also lays the data foundation for future
Phase 5+ valuation features that join location density against pricing.

Polite-by-construction: extends PoliteScraperBase, routes every request
through IPolitenessGate, identifies as PinballWizard/0.1 per
feedback_polite_scraping.md. Seed manifest entry sets requestDelayMs to
5000 (vs the site's published Crawl-delay: 3) for headroom; overrides
land in the IngestionSource Cosmos doc. The Pinball Map robots.txt
disallows AI crawlers but allows our identifying UA.

DTO shape was probed against the live API on 2026-05-04 (per DL-0002 —
the OPDB integration once shipped against an assumed contract that the
real API never honored; unit tests must not pin a self-defined shape).
A live-contract test exists, gated behind PINBALL_WIZARD_LIVE_CONTRACT_TESTS=1
so CI does not hit the real API on every build.

Tests: 13 added (5-test polite-scraper template + on-disk cache hit /
miss / stale / atomic-write / per-region-key-isolation / persist-failure
tolerance + live-contract). Production-manifest pin updated 9 to 10
entries.
@jkeeley2073 jkeeley2073 added the claude-code Generated with Claude Code label May 4, 2026
manufacturer = "Stern",
year = 2017,
opdb_id = opdbId,
ipdb_id = (int?)null,
Comment thread src/PinballWizard.Infrastructure/Integrations/PinballMap/PinballMapClient.cs Dismissed
.Trim()
.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-'));
return Path.Combine(dir, $"locations-{safe}.json");

public PinballMapClientTests()
{
_cacheDir = Path.Combine(Path.GetTempPath(), $"pinballmap-tests-{Guid.NewGuid():N}");
var locs = await client.GetLocationsByRegionAsync("chicago", CancellationToken.None);

Assert.Equal(2, locs.Count);
var cachePath = Path.Combine(_cacheDir, "locations-chicago.json");
{
// Cache file exists but is older than TTL → refetch.
Directory.CreateDirectory(_cacheDir);
var cachePath = Path.Combine(_cacheDir, "locations-chicago.json");
await client.GetLocationsByRegionAsync("chicago", CancellationToken.None);

Assert.Single(handler.Requests);
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-chicago.json")));
// fetch must still succeed; the persist failure is logged and
// swallowed.
var conflictingFilePath = Path.GetTempFileName();
var unwritablePath = Path.Combine(conflictingFilePath, "pinballmap-tests");

Assert.Equal("Chicago A", chicago[0].Name);
Assert.Equal("Portland A", portland[0].Name);
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-chicago.json")));
Assert.Equal("Chicago A", chicago[0].Name);
Assert.Equal("Portland A", portland[0].Name);
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-chicago.json")));
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-portland.json")));
Comment on lines +205 to +211
catch (Exception cleanupEx)
{
Logger.LogDebug(
cleanupEx,
"PinballMap: best-effort cleanup of temp cache file {TmpPath} failed; ignoring.",
tmpPath);
}
.Trim()
.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-'));
return Path.Combine(dir, $"locations-{safe}.json");

public PinballMapClientTests()
{
_cacheDir = Path.Combine(Path.GetTempPath(), $"pinballmap-tests-{Guid.NewGuid():N}");
var locs = await client.GetLocationsByRegionAsync("chicago", CancellationToken.None);

Assert.Equal(2, locs.Count);
var cachePath = Path.Combine(_cacheDir, "locations-chicago.json");
// is never invoked AND the returned locations match the cache
// contents (not whatever the handler would have returned).
Directory.CreateDirectory(_cacheDir);
var cachePath = Path.Combine(_cacheDir, "locations-chicago.json");
{
// Cache file exists but is older than TTL → refetch.
Directory.CreateDirectory(_cacheDir);
var cachePath = Path.Combine(_cacheDir, "locations-chicago.json");
await client.GetLocationsByRegionAsync("chicago", CancellationToken.None);

Assert.Single(handler.Requests);
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-chicago.json")));
// fetch must still succeed; the persist failure is logged and
// swallowed.
var conflictingFilePath = Path.GetTempFileName();
var unwritablePath = Path.Combine(conflictingFilePath, "pinballmap-tests");

Assert.Equal("Chicago A", chicago[0].Name);
Assert.Equal("Portland A", portland[0].Name);
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-chicago.json")));
Assert.Equal("Chicago A", chicago[0].Name);
Assert.Equal("Portland A", portland[0].Name);
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-chicago.json")));
Assert.True(File.Exists(Path.Combine(_cacheDir, "locations-portland.json")));
@jkeeley2073 jkeeley2073 merged commit 5a81b6a into main May 6, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-code Generated with Claude Code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants