Skip to content

feat(achievement-hunter): Phase 0 — game-facts, Steam service caller, device probe - #241

Draft
srsholmes wants to merge 1 commit into
mainfrom
feat/achievement-hunter
Draft

feat(achievement-hunter): Phase 0 — game-facts, Steam service caller, device probe#241
srsholmes wants to merge 1 commit into
mainfrom
feat/achievement-hunter

Conversation

@srsholmes

Copy link
Copy Markdown
Owner

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

bun plugins/achievement-hunter/scripts/probe-achievements.ts > docs/steam-achievements-probe.md

Read-only. Steam ids are redacted to their last four digits so the output is safe to commit. --no-batch-probe skips the only section that talks to Valve more than once.

Two answers change the design:

  1. The server-side appid cap on 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.
  2. Whether GetMyAchievementsForApp already 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 RegisterForAchievementChanges fires per unlock or per session, and a PRESENT/MISSING table for the appStore recency 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.GetAchievementsProgress takes an array of appids over the CM connection Steam already holds — no API key, no HTTP endpoint, no third party.

Library This, batched protondb-badges today, same library
500 games cold 10 requests 500 HTTP GETs
2000 games cold 40 requests 2000 HTTP GETs
2000 games, day 2 ~6–12 requests 2000 again unless cached

So 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 GetMyAchievementsForApp at 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-facts

A "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.ts four-state FactValue, FactResolver<K>, SweepProgress
staleness.ts value-dependent TTL — the single biggest cost reducer
plan.ts batch planning; preserves caller order, resumes with no cursor
sweeper.ts one in flight, inter-batch gap, persisted backoff, pause/resume/cancel
store.ts one aggregate doc — "which 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 has four states. missing (source answered, this game has nothing) is distinct from loading (nobody asked yet) and unavailable (source is down). Collapsing the first into the others is a bug plugins/collections already paid for: it made every unanswered game match under a default pass policy, silently and with no diagnostic. This replaces an earlier -1 sentinel 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.ts

Module 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~*.js filenames, so it re-resolves exactly once per Steam UI update and works with Steam closed. nSteamVersion would do the same job but needs a SteamClient call 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 — CDPClient serialises evaluates per target because Steam's CEF IPC crashes steamwebhelper if 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-code clean. 141 pass across the two new packages.

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 → 2692: +139 passing, zero new failures. test:ui 115 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 a fetchBatch that records overlap rather than by trusting the loop's shape.

store.test.ts injects an ExternalCache double rather than importing the real module. packages/sgdb-art/src/index.test.ts:14 and plugins/store-bridge/lib/stores/epic/index.test.ts:60 both 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 rather than a workaround: the cache is a collaborator and has its own disk-backed specs.

check-plugin-specs.sh scopes both new package trees in from the first commit rather than ratcheting them on afterwards.


Notes for review

Closes nothing yet; #237 stays open until the dashboard ships.

🤖 Generated with Claude Code

… 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
srsholmes marked this pull request as draft August 1, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant