feat(achievement-hunter): Phase 0 — game-facts, Steam service caller, device probe - #241
Draft
srsholmes wants to merge 1 commit into
Draft
feat(achievement-hunter): Phase 0 — game-facts, Steam service caller, device probe#241srsholmes wants to merge 1 commit into
srsholmes wants to merge 1 commit into
Conversation
… device probe Groundwork for the Achievement Hunter dashboard (#237 §2.1). No user-visible feature yet, and deliberately so: the next step is a hardware probe whose output decides two design questions, and building Phase 1 before that would mean guessing at numbers the device can just tell us. ## packages/game-facts — the shared sweep engine A "fact" is one datum about one game that needs I/O: achievement completion, HowLongToBeat time, ProtonDB tier, is-a-friend-playing. Every one of those needs the same plumbing, so it lives in one package rather than being written twice. Two consumers, which clears the repo's stated reuse bar before either one duplicates anything. Achievement Hunter needs one resolver; the collections plugin has six fact keys waiting for exactly this interface, and specified it to the signature. Six modules, each with a sibling test: - `types.ts` — the four-state `FactValue`, `FactResolver<K>`, `SweepProgress` - `staleness.ts` — value-dependent TTL. A single number is the obvious design and it is wrong: 40-60% of a Steam library has no achievements at all, and giving those rows 30 days rather than 24 hours is the difference between a cold sweep every day and a handful of requests - `plan.ts` — batch planning. Preserves caller order (recency-first, so the dashboard's hero row lands in batch 1) and needs no cursor to resume: committed rows are no longer stale, so a re-plan skips them - `sweeper.ts` — one request in flight, inter-batch gap, persisted exponential backoff, pause/resume/cancel, honest progress - `store.ts` — one aggregate document, because "which of my 2000 rows are stale" is a whole-corpus question and `external-cache` has no enumerate API - `limiter.ts` — FIFO semaphore, so the copies in protondb-badges and hltb have somewhere to migrate `FactValue` carries four states, not three: `missing` (the source answered, this game has nothing) is distinct from `loading` (nobody asked yet) and `unavailable` (the source is down). Collapsing the first into the others is a bug the collections plugin already paid for — it made every unanswered game *match* under a default pass policy, silently and with no diagnostic. Fatal and transient failures are distinguished. A rate-limit result stops the sweep immediately, because retrying into a rate limit is how an account gets flagged; a timeout is tolerated and the batch's appIds simply stay stale for the next sweep. ## packages/steam-cdp/src/services.ts — authenticated service calls Steam's own page can call Valve's protobuf services as the signed-in user over the CM connection Steam already holds. No API key, no HTTP, no third party. That is what makes a library-wide answer possible at all, including for games that are not installed. Module ids are resolved by grepping the shipped bundle for the versioned method literal, never hardcoded — webpack ids change on every Steam build. The result caches under a key derived from the content-hashed `chunk~*.js` filenames, so it re-resolves exactly once per Steam UI update and works with Steam closed. `nSteamVersion` would have done the same job but needs a `SteamClient` call that is on the known-to-hang list. Every call is raced against a timeout twice: in-page, so the awaited promise always settles, and again in JS in case the page itself is wedged. That is not defensive padding. `CDPClient` serialises evaluates per target because Steam's CEF IPC crashes `steamwebhelper` if two clients drive one target, so a hung in-page promise head-of-line blocks every other plugin's access to Steam for as long as it hangs. The export key is deliberately not grepped — it is minified per build, so searching for it would reintroduce the fragility that resolving by method name exists to avoid. The generated expression duck-types it instead. ## The probe `plugins/achievement-hunter/scripts/probe-achievements.ts` is maintainer-only and read-only. It answers nine questions, two of which change the design: - the server-side appid cap, which sets the default batch size and therefore the whole cost model - whether per-game detail already carries global rarity, which would delete a planned tier outright It redacts Steam ids to their last four digits, since its output is meant to be committed, and `--no-batch-probe` skips the only section that talks to Valve repeatedly. ## Verification `typecheck`, `lint`, `check:specs`, `check:dead-code` clean. `test:backend` 423 fail on this branch and 423 on main — identical failure names, compared with `comm -13` on sorted `(fail)` lines rather than counts. That 423 is the standing macOS baseline for Linux-targeted code plus the `mock.module` leakage in `docs/test-mock-contamination.md`. Test count 2553 to 2692: +139 passing, zero new failures. `test:ui` 115 fail, unchanged. `store.test.ts` injects an `ExternalCache` double rather than importing the real module. Two other specs mock that module with a partial lacking `set`, and it leaks across files despite `--isolate` — green in isolation, twelve failures in the full suite. Injecting is the fix, not a workaround: the cache is a collaborator and has its own disk-backed tests. `scripts/check-plugin-specs.sh` scopes both new package trees in from the first commit rather than ratcheting them on later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srsholmes
marked this pull request as draft
August 1, 2026 23:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for the Achievement Hunter dashboard from #237 §2.1 — "which games am I 3 achievements away from 100%?", across the whole library including games that aren't installed. Big Picture has no equivalent view.
No user-visible feature yet, deliberately. The next step is a hardware probe whose output decides two design questions. Building Phase 1 first would mean guessing at numbers the device can just tell us.
👉 What I need from the handheld
Read-only. Steam ids are redacted to their last four digits so the output is safe to commit.
--no-batch-probeskips the only section that talks to Valve more than once.Two answers change the design:
Player.GetAchievementsProgress. The whole cost model rests on ~50 per batch being accepted. If the real cap is 25 the sweep doubles; if it's 400 it shrinks fivefold. The probe binary-searches 25/50/100/200/400 and stops at the first error, truncation or non-linear slowdown.GetMyAchievementsForAppalready carries global rarity. If it does, a whole planned tier (GetTopAchievementsForGames) disappears and "rarest unlocked" becomes free.It also pins down which steamid expression actually resolves (nothing in the repo resolves one today), whether
RegisterForAchievementChangesfires per unlock or per session, and a PRESENT/MISSING table for theappStorerecency fields the dashboard sorts by — currently inferred from reading TabMaster's source, not verified.The rate-limit question, since it shaped everything
The instinct to worry is right, applied to the wrong cost model.
Player.GetAchievementsProgresstakes an array of appids over the CM connection Steam already holds — no API key, no HTTP endpoint, no third party.protondb-badgestoday, same librarySo a throttled full sweep costs under a twentieth of what an already-shipped plugin does to the same library, against Valve's own authenticated RPC. What is not defensible is
GetMyAchievementsForAppat library scale — one app per call on the globally serialised CDP evaluate chain. That one stays lazy.Day 2 being cheap comes from a value-dependent TTL: 40–60% of a real library has no achievements at all, and those rows get 30 days rather than 24 hours.
packages/game-factsA "fact" is one datum about one game that needs I/O. Every one needs the same plumbing, so it lives in one package instead of being written twice.
Two consumers, which clears the repo's reuse bar before either duplicates anything. Achievement Hunter needs one resolver;
plugins/collections(#239) has six fact keys waiting on exactly this interface and specified it to the signature.Six modules, sibling test each:
types.tsFactValue,FactResolver<K>,SweepProgressstaleness.tsplan.tssweeper.tsstore.tsexternal-cachehas no enumerate APIlimiter.tsFactValuehas four states.missing(source answered, this game has nothing) is distinct fromloading(nobody asked yet) andunavailable(source is down). Collapsing the first into the others is a bugplugins/collectionsalready paid for: it made every unanswered game match under a default pass policy, silently and with no diagnostic. This replaces an earlier-1sentinel in my own design —{state:"ok", value:-1}claims a real measurement of minus one percent and forces every consumer to learn the sentinel.Failures are split fatal vs transient. A rate-limit result stops the sweep immediately, because retrying into a rate limit is how an account gets flagged. A timeout is tolerated and that batch's appIds simply stay stale for next time.
Resumability has no cursor: each batch commits, so committed rows are no longer stale and a re-plan skips them. A sweep that dies at batch 17 of 40 resumes at 17 because of what's in the cache, not because anything wrote down "17".
packages/steam-cdp/src/services.tsModule ids are resolved by grepping the shipped bundle for the versioned method literal, never hardcoded — webpack ids change on every Steam build, method names don't. The result caches under a key derived from the content-hashed
chunk~*.jsfilenames, so it re-resolves exactly once per Steam UI update and works with Steam closed.nSteamVersionwould do the same job but needs aSteamClientcall that's on the known-to-hang list.The export key is deliberately not grepped — that one is minified per build, so searching for it would reintroduce the fragility that resolving by method name exists to avoid. The generated expression duck-types it out of the resolved module.
Every call is raced against a timeout twice: in-page so the awaited promise always settles, and again in JS in case the page itself is wedged. Not defensive padding —
CDPClientserialises evaluates per target because Steam's CEF IPC crashessteamwebhelperif two clients drive one target concurrently, so a hung in-page promise head-of-line blocks every other plugin's Steam access for as long as it hangs.64-bit ids travel as decimal strings in both directions; a 17-digit steamid loses its last digit the moment it touches a JS number.
Testing
typecheck,lint,check:specs,check:dead-codeclean. 141 pass across the two new packages.test:backend423 fail on this branch and 423 onmain— identical failure names, compared withcomm -13on sorted(fail)lines rather than counts. That 423 is the standing macOS baseline for Linux-targeted code plus themock.moduleleakage indocs/test-mock-contamination.md. Test count 2553 → 2692: +139 passing, zero new failures.test:ui115 fail, unchanged.Everything is tested through an injected clock, an injected
evaluate, or an injected cache, so none of it needs Steam. The sweeper's one-in-flight invariant is asserted by afetchBatchthat records overlap rather than by trusting the loop's shape.store.test.tsinjects anExternalCachedouble rather than importing the real module.packages/sgdb-art/src/index.test.ts:14andplugins/store-bridge/lib/stores/epic/index.test.ts:60both mock that module with a partial lackingset, and it leaks across files despite--isolate— green in isolation, twelve failures in the full suite. Injecting is the fix rather than a workaround: the cache is a collaborator and has its own disk-backed specs.check-plugin-specs.shscopes both new package trees in from the first commit rather than ratcheting them on afterwards.Notes for review
onLoad— no CM calls, no CDP calls, asserted by a test. The sweep starts on page open, which is the literal reading of "don't load it all up front" at the process level while still allowing a library-wide answer at the feature level.plugins/collectionsis not touched. It's unmerged on feat(collections): a Steam collections manager (replaces #236) #239, so the fact contract crosses by structural typing with neither plugin importing the other.FactValueis declared in both places on purpose; when feat(collections): a Steam collections manager (replaces #236) #239 lands, its local copy becomes a re-export. The package header says so.mainindependently and touch different files.Closes nothing yet; #237 stays open until the dashboard ships.
🤖 Generated with Claude Code