PROCITY/docs/LANES/LANE_C_PUB.md
m3ultra eeba920d68 Lane C R39 §39.3: the op shop gets a bin — the verb table, and the margin was never six
THE TABLE (LANE_C_PUB §10, the deliverable): what a player can actually do in each of
the 12 shop types, counted in 48 rooms per type. `record` digs (4.6 bins/room) and is the
only room where anything can be sold. `stall` digs — in 27 of 48 rooms; the other 21 (44%)
have NO crate, a measured correction to the round's binding fact #5. The other ten types
offer looking. `?stock=real` is opt-in, so the default boot has zero `buyMesh` anywhere;
garments/snacks/magazines/treasures are unbuyable in any mode; `interactable:true` on
cases/fridges/returns/arcade is read by nothing.

THE MECHANISM: `rummageBin` — a castored galvanised dump tub, rim 0.76 m, heaped with
stickered bric-a-brac and a hand-lettered "EVERYTHING $2" card. 72 rooms: placed 72/72,
reachable 72/72 (0.77–1.15 m), 0 carves, pathOK 72/72, deterministic 72/72, and the rest
of the room byte-identical to pre-R39 72/72 (the spec is appended last). Armed end-to-end:
aim → dig → 16 offers → BUY → bin depletes. Default: 0 bins, dig never opens.

THE CONTENTS ARE NOT SHIPPED. `o.dig` defaults false, so the tub is furniture and the
economy is untouched; `opts.fittingOpts.rummageBin.dig` (or `?opshopdig=1`) arms it. Held
for John with the numbers: 44 record + 36 stall vs 147 op shops across 23 towns.

THE BUDGET: `C-progress.md:841`'s GLB-on 344 is R9-era and stale — today's tree measured
319 before I touched it, so the margin was 31, not six. The bin costs +5 GLB-off / +10
GLB-on at opshop/hall; every other type byte-identical. Margin now 21. Also: `slot.pool`
stops the scatter fill minting a material per item, so 12 goods cost less than 9 did.

FILED, NOT FIXED: Lane E's bookshelf GLB ships transmission:1, which triggers three.js's
transmission pre-pass and issues every draw in the room twice — opshop/hall 191→104,
book/hall 101→51. Roughly half the interior budget. E's asset, so E/F rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:59:42 +10:00

53 KiB
Raw Blame History

LANE C — interiors contract (venues · real-town mapping · real stock) → Lanes A/D/E/F/G

v5.0-FROZEN · 2026-07-17 · the whole document. Every section below is the shipped, measured state of Lane C's contracts across five epochs. Changes from here are version-bumped amendments, recorded in the marker list — the same discipline §0§5 have carried since v3.0. Case law worth keeping: three of this doc's amendments exist because a rule of mine met a case written after it (§7's naming vs the loader, §7.2a's sku_ before mint existed) — the lane building to the rule took the red both times. When a line here meets a case it didn't foresee, the line is the stale thing. Amend it; don't bend the case.

v3.0-FROZEN · 2026-07-16 · durable contract for Lane D + Lane F. §0§5 describe the shipped venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit). v3.1 amendment: §3 became the stage.backline[] contract (ledger #5 — the amp unification). v4.0-beta amendment (2026-07-16): §6 added — the real-town shop-class → archetype mapping (ledger #2, the density widening; C→E handshake). The frozen venue contract (§0§5) is unchanged. v4.0 amendment (2026-07-16): §7 added — the G2a per-shop stock-atlas contract (C's interior draw/tex ceilings, answering Lane G + E's review). §0§6 unchanged. v5.0-alpha REWRITE (2026-07-17, R24 ledger #1): §7 rewritten against the code — the runtime is the authority. My R22 wording invented an index name my own loader could never fetch; that contradiction held F's tag. §7 now states what stockpack.js actually implements, extended per-shop, and the type+base cache keying is landed. G's R23 atlas layout is ratified (G re-emits provenance field names only, §7.3). §0§6 unchanged. v5.0-alpha amendment (2026-07-17, R25 ledger #3): §7.2a added — slot ids must be sku-derived, unique across packs and stable across re-emits (F's id-collision finding, made structural). Index-only; no atlas bytes, no goldens. §0§6 unchanged. v5.0-beta amendment (2026-07-17, R26 ledger #1/#4/#6): §7.2b sourcing (real|mint — NOT tier, which is already the ladder rung), §7.2c condition for the price card, §7.2d the atlas manifest (existence declared, never probed — kills the 404 attribution exception). All index/manifest-only: no atlas bytes, no town caches, no goldens. §0§6 unchanged. v5.0-beta amendment (2026-07-17, R26 wave 4): §7.2a's id form is now SOURCING-SCOPEDsku_<POS id> for real, mint_<listing id> for mint. Prefixes are namespace fences, not decoration: my R25 line was written before mint existed and read as one space where there are two. Doc-only. v7.0-alpha amendment (2026-07-18, R30 ledger #2): §9 added — the sell-counter contract (the no-pump law made structural: offer(p) = min(p1, max(1, floor(p·0.5))) — strictly below the buy side for every p ≥ 1, by construction). §0§8 unchanged. v9.0-alpha amendment (2026-08-03, R39 §39.3): §10 added — WHAT YOU CAN ACTUALLY DO IN A SHOP: the honest per-type verb table, the five structural facts under it, a measured correction to the round's binding fact #5 (44% of market-stall rooms contain no crate), the op shop's own rummage bin (shipped; contents gated, held for John), and the finding that one asset's transmission: 1 is costing ~4650% of the interior draw budget. §0§9 unchanged.

The district ships three venue archetypes behind ?gigs=1pub, band_room, rsl. Lane A converts a chosen shop to a venue kind in place (shop.type = kind); C keys the interior recipe off shop.type. All three return the SAME shape (below), so Lane D drops rigs in and Lane F reads state with no per-kind branching. Non-venue rooms are byte-identical (venue code runs only for recipe.venue, on its own seed sub-streams). Verified fresh (seeds 20261990/1990/7/42/101 + 90-build soak): 0 throws · 0 carves · 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst build 8.3 ms. Budgets in §3.


0. The pose ry convention (pinned) — READ THIS FIRST

Canonical convention (every C pose uses it): a pose's ry is the yaw of a thing whose front / outward normal is local Z at ry=0, i.e. world-facing (sin ry, cos ry). counter.stand, browsePoints, watchPoints and bandPoses ALL follow it, and the box placeholder (placeholder.js) is built to it (toes at Z). keepers.js greet math assumes it. Drop a rig in at a pose's ry and it faces the right way — no per-consumer flip.

Why no flip (the R13 fix, for the record): the GLB fleet meshes ship facing local +Z at ry=0 — the opposite of the Z convention (verified: the anterior toe-bone vector toe foot reads (≈0, +0.99)). Before R13 this was compensated per-consumer, inconsistently: band.js flipped by π (band/crowd correct, placeholders backward) while keepers.js did not (placeholders correct, keepers + browsers 180° backward whenever the GLB fleet was loaded — they'd been shipping back-to-front). D fixed it at the source in rigs.js buildFigure: the cloned inner is rotated π about Y so every GLB rig faces Z, matching the placeholder and every C pose. All consumer flips were then deleted. Net: keepers, browsers, band and crowd all face correctly, GLB and placeholder modes are consistent, and C's pure-data poses never moved (no golden touched). If you add a new rig consumer, plant it at the pose ry with no flip.


1. What buildInterior returns for a venue (shop.typepub | band_room | rsl)

Everything below is pure data on the return (room-local coords, the ry convention in §0). Lane D drops rigs straight in; Lane F reads state — no new lifecycle vs. the R9 keeper/browser seam.

room.stage — the band stage, a 4-piece (null for non-venues)

room.stage = {
  x, z,            // deck centre (against the back wall)
  w, d,            // deck size (m) — varies by kind (table below)
  deckY,           // deck top surface height (m) — the front line stands here
  riserY,          // drum riser top height (m) = deckY + 0.16 — the drummer sits here
  frontZ,          // z of the deck lip (the crowd stands in front of this, +Z)
  bandPoses: [ { x, z, ry, role, y, seated? } ×4 ],
}
  • bandPoses[0..2] = the front line: roleguitar / vocal / bass, across the deck lip, y = deckY.
  • bandPoses[3] = the drummer: role: 'drums', seated: true, up-stage centre on the drum riser, y = riserY (= deckY + 0.16, the fittings.js stage riser box).
  • Lane D: lift each member to its own pose.y (front line = deckY, drummer = riserY). role hints the instrument — E's GLBs via opts.instrumentFor(role) (electric_guitar/bass_guitar/mic_stand/drum_kit), primitives otherwise (asset law). seated:true → seated bob. ry = π already faces the audience (+Z) — no flip (§0).
  • The band is a 4-piece for v3.0 (front-line trio + drummer). Reverting to a trio would be a version bump (drop bandPoses[3]), not a runtime toggle.

room.watchPoints — the audience, cap by kind (empty [] for non-venues)

room.watchPoints = [ { x, z, ry, dance, slotIndex } ]   // seeded floor poses, all facing the stage centre
  • Crowd cap by kind: pub 8 · band_room 8 · rsl 12 (the RSL is the crowd-cap stress case). The room fills what geometry allows up to the cap; the count IS the crowd cap (F asserts crowd ≤ watchPoints.length).
  • dance seeded ~⅓ true ("a bit of both"). D: dance:false → stand-and-watch idle; dance:true → bob/sway with a seeded phase offset (use slotIndex/coords). Every point walkable, reachable, clear of the keeper stand + browse points + stage/PA. Deterministic per shop.seed.

room.counter / keeper — unchanged

The bar is a normal counter; the barkeep is just a keeper at room.counter.stand (R9 seam). Faces the customer correctly under §0.

room.audio.gigKey — the live bed

room.audio = { musicKey, toneKey, gigKey? }. The manifest key is the gigKey, canonical form gig-<genreKey> — no mapping table. F passes gig state via opts.gig:

buildInterior(rslShop, THREE, { gig: { on: true } })   // → room.audio.gigKey === 'gig-covers'
  • Genre resolves opts.gig.genreKey → the venue's own shop.genreKey (A sets it) → a kind default (pub→pubrock, band_room→grunge, rsl→covers). Present only when a gig is on.
  • Prefer gigKey over musicKey while the gig plays. E's beds are gig-pubrock / gig-grunge / gig-covers; a missing key → silence (audio law), band still visible.
  • C hand-builds 'gig-'+genreKey (byte-identical to citygen's gigKeyFor, selfcheck-asserted) rather than importing it — deliberately, to keep the interiors lib standalone (it never imports citygen).

The venue table (C-owned tuning)

kind archetype bias ceiling deck w × d, deckY crowd cap genre → gigKey dressing
pub wide / hall 3.6 m 0.58·W × 2.0, 0.32 8 pubrock → gig-pubrock pub tables, sticky carpet, bar
band_room cosy / pokey / wide 3.4 m (low) 0.62·W × 1.6, 0.20 (barely a riser) 8 grunge → gig-grunge near-empty standing room, tin-shed dingy
rsl hall / wide 4.0 m (tall) 0.50·W × 2.4, 0.40 12 covers → gig-covers flock walls, club carpet, bistro tables, members' bar

2. Quiet-night variant

No opts.gig → a normal interior: stage + watchPoints are still returned as data, but there's no gigKey (room-tone + maybe the seeded radio musicKey), and D spawns no crowd. A seeded dark night reads true — the venue is just a quiet pub/club.

3. stage.backline[] — the amp slots (v3.1, ledger #5)

room.stage.backline = [ { x, z, ry, y } ×2 ] — seeded up-stage amp/spare-cab poses, pure data (C plants no amp meshes as of v3.1). This is the unified backline: Lane D plants E's guitar_amp GLB here (its own primitive fallback under ?noassets) — one amp system, GLB when present. C's old primitive ampStacks are dropped from all three venue recipes.

  • slot[0] = stage-right (the primary amp) at (stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22) — identical to D's R15-verified plant, so wiring D's amp to backline[0] moves nothing on screen. slot[1] = stage-left spare cab (mirror). ry = π faces the audience (+Z, the §0 convention); y = deckY.
  • Guarantees (verified fresh, seed 20261990, pub/band_room/rsl): every slot is up-stage of the front line, flanks the drum riser, clear of all 4 bandPoses (nearest-bass clearance 0.87 m pub / 0.70 m band_room / 0.92 m rsl) and all watchPoints (≥1.95 m), and on-deck (within the deck footprint). Deterministic per shop.seed. Lane D: verify no interpenetration once wired (C's R15 clearance discipline).
  • Draw impact: dropping C's primitives shaved ~4 draws off each empty venue shell (drawSweep glb-off: pub 122 · band_room 120 · rsl 131 ≤350); D's GLB/primitive at backline[0] adds it back on the gig night.
  • v3.2 (parked): the drummer sit clip, instrument LOD, and C's audioEmitter idea.

4. Budgets (measured R14, seed 20261990, fresh context)

  • Empty shells (drawSweep glb-off): band_room 124 · pub 126 · rsl 135 — town-wide worst 161 (opshop/hall). ≤ 350. ✓ (identical to R13 — C geometry unchanged).
  • Gig-night interior draws, D's GigCrew with the real instrument GLBs + amp (localdepot): pub 60 · band_room 52 · rsl 71. Asset-free (placeholder crowd, no GLBs) worst RSL 237 (16 rigs). ≤ 350. ✓
  • Tris: fleet peds ~2.7k each; E's instrument GLBs 14k each (decimated to the ≤15k target), 5 on a stage = 70k — the instruments dominate. RSL gig-night ≈ 182k tris once the async GLBs settle. The interior gate is draws (passes); tris are the street budget (200k), so this is not an interior gate. Note for E/F: F's gig-night smoke reports ~34k because it measures before the async instrument GLBs load; the settled scene is ~180k. A camera-distance LOD on instruments is a v3.1 candidate.
  • Determinism / release-law: C's interior code is unchanged since R13; D's continuity + RY-fix ride disjoint rng namespaces (ctx.stream(salt) vs rng(citySeed,kind,id)) and cannot perturb C's placement or any golden. 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0.

5. The archetypes (C-owned)

band_room = a tin shed: small (cosy/pokey), low ceiling, barely-a-riser deck, near-empty standing floor, bar in the corner — the PA louder than the room. rsl = carpet-and-flock club: the biggest floor in town (hall/wide), a proper raised stage at one end, bistro tables round the edge, members' bar, the biggest crowd cap. Both reuse the pub venue machinery (theme.js venueSpec + layout.js placeStage/placeWatchPoints); only the dressing + venueSpec differ. Stage/PA keep GLB slots for Lane E (primitive fallback holds); the backline amp is D's, planted at stage.backline[0] (see §3).


6. Real-town shop-class → interior archetype mapping (v4.0-beta, ledger #2) → Lane E

Directive (John, kickoff — SUBTLE: texture, not takeover). The secondhand shops stay the heroes of the town (they are the game). The widening adds general retail as texture — the everyday main-street staples a real Australian town has — mapped onto existing archetypes (no new archetypes this round). The player should feel the town's more alive, not that the game changed.

How it's applied (E owns the emission). plan_osm keeps only registry types (SHOP_TYPES — unknowns flatten to opshop). So E maps each fetched OSM shop=* / amenity=* value to a registry type below and bakes it into the cache's shop.type. Every target is an existing type, so C's recipes, A's registry, and the budgets are all unchanged. Unknown/unlisted classes degrade safely to opshop (R18-proven: 18 real OSM types all built valid).

Heroes — secondhand (unchanged; NEVER subsampled):

OSM class → type
charity, second_hand, antiques, vintage opshop
books, bookshop book
music record
video, games video
pawnbroker pawn
toys toy

Widened — everyday main-street texture (general retail):

OSM class(es) → type wears it as
bakery, cafe†, confectionery, pastry, chocolate, deli/delicatessen, butcher, cheese, coffee, tea milkbar the food counter
convenience, kiosk, general, dairy milkbar the corner store
newsagent, stationery milkbar papers + magazines
chemist, pharmacy, cosmetics, perfumery milkbar dispensary counter
clothes, fashion, boutique, shoes, bag, jewelry/jewellery dept the dressed retail floor
department_store, variety_store, supermarket dept big general retail
hardware, doityourself, trade, electronics, mobile_phone, furniture, houseware/homeware dept shelved goods
greengrocer, florist, farm, garden_centre stall produce/plants on trestles

cafe/fast_food are amenity=*, not shop=* — fetch them from the amenity tag; everything else is shop=*. Fallback: any fetched class not listed → opshop.

Copy-paste map for E (build_towns.py):

{
  "charity":"opshop","second_hand":"opshop","antiques":"opshop","vintage":"opshop",
  "books":"book","bookshop":"book","music":"record","video":"video","games":"video",
  "pawnbroker":"pawn","toys":"toy",
  "bakery":"milkbar","cafe":"milkbar","confectionery":"milkbar","pastry":"milkbar","chocolate":"milkbar",
  "deli":"milkbar","delicatessen":"milkbar","butcher":"milkbar","cheese":"milkbar","coffee":"milkbar","tea":"milkbar",
  "convenience":"milkbar","kiosk":"milkbar","general":"milkbar","dairy":"milkbar",
  "newsagent":"milkbar","stationery":"milkbar",
  "chemist":"milkbar","pharmacy":"milkbar","cosmetics":"milkbar","perfumery":"milkbar",
  "clothes":"dept","fashion":"dept","boutique":"dept","shoes":"dept","bag":"dept","jewelry":"dept","jewellery":"dept",
  "department_store":"dept","variety_store":"dept","supermarket":"dept",
  "hardware":"dept","doityourself":"dept","trade":"dept","electronics":"dept","mobile_phone":"dept",
  "furniture":"dept","houseware":"dept","homeware":"dept",
  "greengrocer":"stall","florist":"stall","farm":"stall","garden_centre":"stall"
}

Default (not in the map) → opshop.

Subtlety guardrails (E):

  • Fetch only these tasteful staples — not every OSM shop=*. Skip vehicle/tyres/fuel, funeral, storage, industrial, and pure services the player never browses (hairdresser, beauty, travel_agency, bank, laundry, estate_agent).
  • Never subsample the secondhand heroes.
  • If a town's general-retail count would visually drown the secondhand strip, seed-subsample the texture classes (the milkbar/dept/stall targets) toward ~23× the secondhand count, and count the drops in norm.dropped like every other drop.

Why these targets: milkbar (counter + fridge + magazine rack + glass case) is the canonical Australian counter shop — it wears cafe/bakery/deli/newsagent/chemist naturally; dept (cube shelves + clothes racks + glass cases) wears clothes + hardware + big retail; stall (trestles + crates) wears produce + florist. Three general-retail looks + the six secondhand heroes + the three venues = variety with no new archetypes. Pre-verified (seed 20261990, real-town lot 8.5×14): milkbar/dept/stall/opshop all build valid (0 carves, ≤143 draws); dept reads as a clean dressed-retail floor (cube shelves + clothes racks + counter, the escalator does not dominate the ~7.9×11.7 room), so clothes/hardwaredept is confirmed. C re-checks the full result in the five-town door-soak (ledger #6) once E's caches land. (FYI: C's theme.js canonicalType/ALIAS already recognizes several of these — deli/corner_store→milkbar etc. — for any direct buildInterior(rawClass); the authoritative mapping for the caches is this table.)


7. Real-stock packs — THE RUNTIME IS THE AUTHORITY (v5.0-alpha, R24 ledger #1) → Lanes E/F/G

This section was the root cause of R23's held tag and has been rewritten against the code. My R22 wording invented an index name (stock_shop_<godverseShopId>_index.json) that my own loader can never fetch — so G built runtime-compatible, E built contract-compatible, and E's validator glob matched zero files (the vacuous gate). Nobody was wrong; the contract was. Rule going forward: web/js/interiors/stockpack.js is the contract. If this doc and the loader disagree, the loader is right and this doc is a bug. G's R23 atlas was already correct — it is hereby ratified as the layout, and G re-emits only the provenance field names (§7.3).

7.1 File layout (loader-exact)

The loader is preloadStockPack(type, { base }) → it fetches <base>stock_<type>_index.json.

  • typerecord | book | toy — and it is not cosmetic: type picks the stock slot (SLOT_FOR: record→sleeve, book→spine, toy→box). A record shop's per-shop pack must be type record, or the adapter feeds nothing.
  • Town-wide packs (unchanged): assets/models/stock_<type>_index.json.
  • Per-shop packs (v5): assets/stock_godverse/<godverseShopId>/stock_<type>_index.json — the base is the per-shop directory; the filename is the same. (This is exactly G's R23 layout.)
  • RETIRED: stock_shop_<id>_index.json — never loadable; do not emit, do not glob for it. → Lane E: validate_atlas.py's glob must become stock_godverse/**/stock_*_index.json (the current stock_shop_*_index.json is why it passed on 0 files).

7.2 Fields the RUNTIME reads (absence ⇒ pack null ⇒ parody)

  • atlases: string[] — resolved against the same base; depot:/https: entries pass through.
  • items: [...], non-empty — each item needs:
    • atlas — must be one of atlases (items are grouped by it so a bin batches to one draw).
    • uv: [u0,v0,u1,v1], origin TOP-LEFT — the loader flips V itself (uvRect). Do not pre-flip.
  • Read by the dig/buy consumers (not by the loader): id, title, artist, price, price_band.

7.2a Slot ids — unique across packs, stable across re-emits (v5.0-alpha, R25 ledger #3)

Rule: items[].id MUST derive from the source's own stable key — never from position — and MUST be unique across ALL packs. The shipped generic packs keep rec_NNNN as their namespace — a real-stock pack must not emit into it.

AMENDED v5.0-beta (R26 wave 4) — the id form is SOURCING-SCOPED, and the prefix IS a namespace fence. R25's line said "sku_-prefixed for real-stock packs". I wrote that before mint existed, so it reads as one space when there are two. E implemented my rule faithfully; the rule was the stale thing, not E's check. The form now follows §7.2b's sourcing:

sourcing id form the space it names
"real" sku_<POS id> (e.g. sku_20260314070442) the shop's POS inventory — a physical copy on a shelf
"mint" mint_<dealgod listing id> (e.g. mint_1382327) a dealgod listing — a plausible stand-in, not that shop's stock

These are disjoint namespaces and no gate, lookup, or economy path may treat them as one. G refused to file dealgod listing ids under sku_ to go green the easy way, and that judgment is the rule now: tier-2's sold-means-gone looks up POS skus. R24's id collision entered the wrong shop; the same collision in the money layer sells the wrong record — a real one, out of a real shop, to satisfy a minted stand-in. A prefix that lies costs money, not a gate. (Same standing note as plan-ids vs godverse-ids: one name, one space — never compare across.)

Why — two reasons, both measured:

  1. Positional ids collide by construction. Both packs number from zero, so all 120 godverse ids are also generic-pack ids — 120/120 overlap (measured R25). An id-equality gate therefore cannot discriminate which pack a rendered item came from: that is what made the brief's own #7a spec vacuous. F caught it and replaced it with title-sets + texture-URL provenance (now the canonical honest-gate pattern for stock). Sku ids make id-equality discriminating again — permanently, and while there are two atlases to migrate instead of fifty.
  2. Position is not identity — and this one outlives the gate. A positional id renumbers on every re-bake: sell one record out of crate 550 and rec_0050 silently becomes a different record. Anything holding an id across snapshots — a wallet purchase, a saved dig, a gate assertion, and above all tier-2's sold-means-gone — would then point at the wrong item. The POS sku is stable precisely because it is the shop's own identity for that record: the same principle that makes godverseShopId the POS shop id. (release_id identifies the pressing, not the copy — the sku is the copy.)

Scope: index-only. id is not read by the loader (§7.3) — the dig/buy consumers and the gates read it. No atlas bytes, no town cache, no golden moves. → Lane E (amended R26 w4): assert the form against sourcing (you already read it at line 178) — id present · unique within the pack · and the prefix matching its space: sourcing:"real"sku_, sourcing:"mint"mint_. A crate wearing the other's prefix FAILS — that's the fence doing its job, and it's a stronger check than the one it replaces, not a looser one.

7.2b sourcing — real vs mint, and why it is NOT tier (v5.0-beta, R26 ledger #1)

The beta needs every keyed crate marked as actual POS inventory or seeded plausibility, so no gate, doc or human mistakes one for the other. The brief called that field tier. It can't be tier: that name is already taken, twice over.

  • G's atlases already emit tier: 1 = the charter's ladder rung (0 parody · 1 static-real · 2 live GODVERSE). A mint atlas is also rung 1 — same mechanism (a statically baked per-shop atlas); only the data's origin differs. And kind is already the stock type (record).
  • So real-vs-mint is an orthogonal axis, not a rung. Overloading tier would put two meanings in one name — the same species as the plan-id/godverse-id collision now standing-noted, and as the positional ids in §7.2a. One name, one meaning.

Rule: top-level sourcing: "real" | "mint", REQUIRED on every real-stock atlas index. tier stays the numeric ladder rung, unchanged.

  • "real" — baked from that shop's own POS snapshot (Monster Robot Party). The index carries its snapshot provenance (§7.3).
  • "mint" — a deterministic seeded sample (random.Random(shop_id)) standing in for a keyed shop with no POS. Plausible, never claimed as that shop's stock. A mint index MUST NOT carry POS-snapshot fields (the separation IS the honesty — E asserts both directions).
  • Gates read sourcing (vacuous-gate law: an index with no sourcing FAILS). A mint crate can never report as real. The dig card's treatment is C's call — see §7.2c.

7.2c Price-card fields — condition (v5.0-beta, R26 ledger #4)

The dig card is a record-shop price sticker, and a sticker's fields are artist / title / condition / price. condition is not in the index today — G's snapshot carries condition + sleeve_cond per item and drops them at emit, so pickRealOffers currently maps cond: '' and the card renders dangling separators around a hole. That's the "database row" the ledger wants gone. Rule: condition (and optionally sleeve_cond) are OPTIONAL index item fields; emit them when the source has them. Index-only; the loader never reads them (§7.3). C's card renders whatever is present and omits what isn't — a missing field must never leave a stray separator. Mint items: emit condition only if the sampled listing genuinely carries one; a plausible-but-invented grade is exactly the kind of fiction sourcing exists to prevent.

7.2d The atlas manifest — existence is declared, never probed (v5.0-beta, R26 ledger #6)

Fail-soft-by-404 collides with the zero-console-errors law: the runtime probes per-shop atlases blind and eats the 404 noise (8 in F's R24 gate run), tolerated by attribution — a workaround, not a design. Rule: web/assets/stock_godverse/index.json is the authority for which shops carry atlases. No entry ⇒ no fetch. Shape:

{ "version": 1,
  "shops": [ { "godverseShopId": 3962749, "types": ["record"], "sourcing": "real" },
             { "godverseShopId": 31,      "types": ["record"], "sourcing": "mint" } ] }
  • godverseShopId is a number in the GODVERSE namespace — never compare it to a plan id (standing note).
  • → F: consult the manifest before fetching; no entry ⇒ no probe ⇒ zero 404s, no attribution exception. A shop in the manifest whose fetch still fails keeps the existing fail-soft → parody.
  • → G: emit it alongside the atlases (mint entries included).
  • → E: manifest↔files consistency both ways — an atlas on disk absent from the manifest FAILS, and a manifest entry with no atlas FAILS.
  • Manifest absent entirely (a tree with no godverse atlases): that is a legitimate zero-shop state — the wire probes nothing and stays silent. Not an error.

7.3 Fields the runtime IGNORES — and the provenance ruling (the drift dies here)

version, atlas_px, cell/cell_w/cell_h, count, kind, tier, shop, crate, provenancestockpack.js reads none of them. So the authority for their shape is not C's loader but E's validate_atlas.py, and the house convention set by 23 shipped town caches:

  • Provenance lives at TOP LEVEL: license, attribution, generator, snapshot — US spelling license, exactly what E's validator reads and what every town cache already uses. British licence is retired. → Lane G: rename + lift those four to top level.
  • Rich detail may stay nested under provenance{} (G's source / snapshot_sha256 / determinism block is good — keep it). The four gate fields are the contract; the block is colour.
  • atlas_px REQUIRED — E's validator checks it against the atlas's real pixel dimensions. That is a genuine falsifiable check; keep it.
  • cell / cell_w / cell_h are informational. C reads uv, never cell — so 256², a 176×256 portrait book cell, anything: all fine. Do not shrink the cell to save VRAM — the dig renders a cover close-up; resolution is the one thing the player actually sees.

7.4 Cache identity = (type, base) — landed R24

stockpack.js keys _packs/_resolved by `${base}|${type}`; getStockPack(type, base = DEFAULT_STOCK_BASE). Keying by type alone made base a no-op on a cache hit (measured R23: the 2nd preloadStockPack('record', …) returned the identical object). That was two bugs:

  1. two stocked shops collide — the 2nd serves the 1st's crate; and
  2. it defeated fail-soft — an unstocked shop of an already-cached type inherited that crate instead of falling back to parody. (A correctness bug, not a mix-up.) Both fixed and re-measured (§7.7). Pre-v5 callers are unaffected: no base ⇒ the town-wide pack.

7.5 The seam (→ Lane F, ledger #6a)

buildInterior(shop, THREE, { stock: 'real', stockBase: `assets/stock_godverse/${shop.godverseShopId}/` })

opts.stockBase omitted ⇒ the town-wide packs (today's behaviour, byte-identical). Preload the shop's pack on entry with the same base. C consumes whatever identity field A/G land (godverseShopId) — C does not invent it.

7.6 Ceilings (C's, unchanged from the R23 correction)

  • Draws: one atlas = one draw; ≤2 atlases per shop (G's 120-record crate lands on the cap); a 60+ item shop may use 2048²; spill beyond 2 → the town-wide pack. Room law ≤350 stands.
  • VRAM: ≤2 atlases @≤2048²; LRU = 1 shop's atlas set resident ⇒ ≤32 MB peak. (R23 correction: my "≤8 MB" assumed a ~16-item "typical shop". A real record shop is 120 records.)
  • The LRU + dispose is C's, and it lands at v5.0-beta, not here: with ONE stocked shop there is no accretion, and safe eviction needs a room-exit hook (a live room holds the pack's shared material) — that seam arrives with F's per-shop preload. Stated so it can't slide silently.

7.7 R24 verification — falsifiable, per the new vacuous-gate law

Fresh context, against G's merged stock_godverse/3962749/ (120 real records):

  • Every sleeve samples G's atlas file — texture sources are exactly stock_godverse/3962749/stock_record_atlas_00.webp / _01.webp; pack items ≡ index items by id. (Not "covers render": a parody canvas also has a .map. The negative control below is what makes this a gate — I had to fix my own R23 metric to satisfy the law.)
  • Negative control: the parody room's stock textures are (canvas, no src) and touch the godverse atlas zero times — the metric discriminates.
  • Collision fixed: shop pack (120 items) and town pack (350) coexist as distinct objects.
  • Fail-soft with a POPULATED cache: missing base ⇒ null ⇒ parody (the R23 bug is gone).
  • Ceilings: 98 draws with real covers via opts.stockBase; pathOK; 0 carves; 0 console errors.

8. Tier-2 live enrichment — the seam (v5.0, R27 ledger #2) → Lane F

Ruling: bless the direct wire — no new lifecycle hook. But the gone application form is C's, and it's landed in dig.js. The question wasn't hook-vs-no-hook; it was how gone applies.

1. Lifecycle — no hook needed. F already holds currentAdapter (interior_mode.js:67) and passes it to dig.open() per dig (:111) — a fresh read every time a crate is opened. When the live read lands, F updates the pack; the next dig picks it up. If the read hasn't landed, hasn't finished, or failed, the adapter is simply the tier-1 pack — the dig never blocks on the network (charter risk #1) and no C lifecycle changes.

2. The form — pick-then-omit, NEVER filter-the-array. pack.gone = a Set/array of ids the live read reports sold. C picks from the full, stable items[] and omits gone ids after the draw.

  • Why (measured, R27, 5 crates): filtering items[] first reshuffles the crate — every pick indexes by position, so one sale changed 21 OTHER records. Pick-then-omit: 0 collateral. A crate with 3 sold shows 13, not a different 16.
  • Both forms hide the sold record — so “the gone item never renders” cannot tell them apart.F: the sandbox proof (#4) must assert on collateral (the survivors are unchanged across a gone delta), not just absence. The obvious assertion is vacuous here. (Vacuous-gate law, third application — and the cheapest one to get wrong, because it goes green.)
  • Proven in the real dig: sold record gone · survivors 4/4 kept · collateral 0 · items[] untouched (120).

3. F's invariant — items[] identity and order ARE the pick's stability.

  • Do: update item fields in place (live price) — free, no reshuffle; the card reads it.price at pull time, so a live price shows on the next pull.
  • Never: add, remove, or reorder items[]. That is what pack.gone exists for.

4. The determinism boundary sits exactly here. Server down ⇒ no gone, no live fields ⇒ the dig is tier 1, deterministic and gate-able. Server up ⇒ the crate legitimately changes as records sell — tier 2 is non-deterministic by design (charter law #2). So no gate may assert dig determinism with the server up, and no live value may enter a seeded stream or a golden: pack.gone is consumed at pick time and never persisted.

5. The bin fan is decorative, not inventory. The room's visible sleeves are built at buildInterior from a different seeded pick than the dig's offers — a fan, not the crate's contents. A sold record's cover may linger in the fan until the room rebuilds. If we ever want the fan to honour gone, that is when I'd publish a post-build hook; it is not needed for the epoch's claim, because the crate you riffle is true.


9. The sell counter (v7.0-alpha, R30 ledger #2) → Lanes F/B

The mirror of the buy card. Walk to the keeper's counter holding items the shop trades in → a sell card (the same manila price-sticker treatment as the dig's pull panel): item, OFFER, SELL. Module: web/js/interiors/sell.js — DOM-only (zero draws, zero GPU resources), same consumer pattern as dig.js (C ships the interaction; F owns the in-game input/mode hook).

9.1 THE OFFER — the no-pump law, structural (the multiplier and why)

sellOffer(pricePaid) = min(pricePaid  1, max(1, floor(pricePaid × 0.5)))     // and never < 0
  • The multiplier is 0.5 — the classic secondhand-dealer margin (a keeper pays about half of what he'll sticker it at; he has to eat too). It keeps selling meaningful (half your money back beats a full crate of duds) without being an income source.
  • The min(pricePaid 1, …) clamp is the law made structural, not tuned: the offer is strictly below what you paid for every pricePaid ≥ 1 (at p = 1 the offer is $0 and the SELL button disables — the keeper won't buy what he can't sticker; at p = 2 the naive max(1, floor(p/2)) = 1 would meet the clamp anyway — the clamp exists so no future retune of the multiplier can ever push an offer to break even). Buy-then-immediately-sell therefore loses ≥ $1 per round trip, always — F's no-pump gate (R30 ledger #5, monotonic cash loss) holds by construction, and a change to this formula that breaks that property is a contract violation, not a balance pass.
  • Alpha basis = pricePaid. The alpha carries no guide-band data (the BIBLE bands are beta), and in the alpha every buy happens at the shop's asking price — so pricePaid IS the buy-side price the player faced. Beta swaps only the basis, not the shape: when E's guide bands land, the offer becomes min(bandLow 1, max(1, floor(bandLow × 0.5))) — below the band's low edge by construction, so a gem bought under band still nets its finder margin only against the band, never against the keeper. The formula's clamp survives the swap untouched.

9.2 What the keeper buys (alpha: type = type, strict)

  • sellableIn(shopType, item)item.type === shopType. A record shop buys records, a book shop books — any shop of the type (per-keeper taste, haggling, pawn-buys-everything: all beta, not built).
  • An item with no type is NOT sellable — fail-closed (vacuous-gate law: a matcher that passes an untyped item would "match" everywhere). → Lane F ASK: stamp type (the registry stock type: record/book/toy/…) on every collection item at buy time — the R30 published shape ({townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}) doesn't carry it, and without it nothing is sellable anywhere. Carrying title/artist too is strongly asked (B's collection UI needs them; my card falls back to the sku/slotId label when absent — ugly but honest).

9.3 The seam — money like buying, removal via the game API only

  • C consumes window.PROCITY.game.collection READ-ONLY. The card never splices, reorders, or mutates it (same species of law as §8.3's items[]).
  • onSell(item, offer) is the single side-effect callback — the exact mirror of the dig's onBuy(offer) (the proven wallet seam). The consumer (F) does both halves: credit the wallet, remove the item from the collection through the game API. onSell returning false = keeper veto: card unchanged, nothing moves.
  • wallet.sell(item, offer) is provided (C-owned wallet.js, mirrors buy()): credits offer, drops the matching v0-inventory entry, notifies. F binds it exactly like it binds wallet.buy.
  • Till ring: the sale rings 'till' from the counter emitter (the R28 one-shot pattern, same distance-gain math as the dig's) — silent-and-happy with no engine / ?mute / ?noassets.

9.4 The trigger + the card

  • Where: within SELL_DIST = 2.0 m of room.counter.pose (bench centre, room-local — the frame the interior camera walks in). Helper exported: nearCounter(room, pos, dist?).
  • Suggested E routing for F (matches my harness): aimed bin → dig (unchanged) · else near counter with ≥ 1 sellable → sell card · else shelf-buy. Pointer unlocks while the card is up (dig precedent).
  • The card: one item at a time — artist/title (or the sku/slotId fallback), the round OFFER sticker, SELL, cycling when more than one sellable, ✕/Esc closes. open() returns false (no card, no DOM) when nothing is sellable.

9.5 Fail-soft (the classic-pure law, C's half)

No game layer (?classic=1 / ?game=0window.PROCITY.game absent) → the consumer never opens the sell UI, and open() itself returns false on a missing/empty item list — zero errors, zero DOM, zero localStorage. createSell() allocates DOM only; dispose() removes every node + listener.

9.6 API (loader-exact, per the §7 rule: the code is the contract)

import { createSell, sellOffer, sellableIn, nearCounter, SELL_MULT, SELL_DIST } from './js/interiors/sell.js';
const sell = createSell();
sell.open({ shopType, shopName, items, getCash, onSell, onClose, emitters })  // → bool (false = nothing sellable)
sell.close();  sell.dispose();  sell.active                                    // mirrors dig.js

10. WHAT YOU CAN ACTUALLY DO IN A SHOP (v9-alpha, R39 §39.3) → everyone

This section is a measurement, not a design. Fable's brief asked for the honest per-type inventory of player verbs, built by reading the recipes and the fitting kinds and then counting them in built rooms — not from memory. It is uncomfortable. It is also, as far as I can tell, the first time anyone has written down what this game currently offers, shop type by shop type.

10.1 THE TABLE — the DEFAULT BOOT (web/index.html, no flags), 12 types

Counts are from built rooms: 6 archetypes × 8 seeds = 48 rooms per type, headless.

type dig a bin? pull-and-buy off a shelf? sell at the counter? anything else
record YES — 28 bins/room, mean 4.6, 0/48 rooms empty no — records are the dig, by design YES — the only shop in the game where anything can be sold
opshop no (R39: it now HAS a bin; contents unruled — §10.4) no — there is no opshop stock pack, and none is possible today no — nothing in the game is ever type:'opshop' look at clothes you cannot buy
book no only under ?stock=real — ~37 buy meshes / ~412 items per room only for items already type:'book', which also needs ?stock=real
toy no only under ?stock=real — ~34 meshes / ~62 items per room ditto, type:'toy' a glass case (interactable, no consumer)
video no novhsAisle has 'spine' slots, but the pack is resolved by SHOP TYPE and there is no video pack no returns slot + arcade cabinet: both interactable:true, both dead
pawn no no no glass cases + pegboard — dressing only
milkbar no no no fridge + glass case: interactable:true, dead
dept no no no glass cases, escalator — dressing only
stall YES, but21 of 48 rooms have ZERO crates; mean 0.92, max 3 no no — a stall dig records type:'record'; the stall is type:'stall', so the matcher fails
pub no no no on a gig night: pay the cover, watch the band
band_room no no no ditto
rsl no no no ditto

Every type additionally: walk in, walk out, look, a keeper standing at the counter, up to 3 browser rigs, a seeded radio/room-tone, and (with a wantlist) a ~45% keeper rumour on the banner.

10.2 The five structural facts behind that table (verified in the code, R39)

  1. ?stock=real is opt-inindex.html:222 params.get('stock') === 'real', and interior_mode.js:360 gates the adapter on it. No adapter ⇒ stock.js never calls buildBuyableShelfon the boot every player gets, there is not one buyMesh anywhere in the game. The R9 buy-anywhere path is real, tested and shipped, and it is dark by default.
  2. The pack is chosen by SHOP TYPE, and SLOT_FOR has three entries (stockpack.js:85: record→sleeve · book→spine · toy→box). buildInterior asks getStockPack(recipe.key). So the only shelves that can ever be buyable are book spines in a book shop and toy boxes in a toy shop — the video store's identical spine shelves cannot be, because no video pack exists.
  3. Four stock kinds can never be bought in any mode: garment, snack, magazine, treasure. hang() and treasures() never call buildBuyableShelf, and no pack type maps to them. The op shop's actual stock — the clothes — is unbuyable by construction.
  4. sellableIn is item.type === shopType (sell.js:37, fail-closed) and only two call sites ever stamp a type: interior_mode.js:206 (a dig pull — always 'record', wherever it happened) and :251 (a shelf buy — currentShop.type). Therefore on a default boot the sell card can open in exactly one kind of room: a record shop.
  5. interactable: true is not a verb. interior_mode's E routes exactly three things: an aimed kind:'bin', the counter (via nearCounter), and a buyMesh. case, fridge, returns and the arcade cabinet all set interactable: true and nothing reads them — 5 glass cases per pawn room, 8 per dept room, 4 fridges per milk bar, all inert. That flag is currently a comment.

10.3 The correction to the binding fact (R39, measured — the honest direction)

The round's binding fact #5 says "only record shops and market stalls have a diggable crate." True of the recipes. Not true of the rooms: the stall's crate is zone:'centre', and the stall's 36 trestle tables take the aisle first, so the centre candidates are blocked. 21 of 48 stall rooms (44%) contain no crate at all; mean 0.92 per room against the record shop's 4.6. So RED TICKET DAY's roster, which counts a stall as a crate-shop by type, overcounts: a roster can name a stall you cannot dig in. One line fixes it (fallbackZones / anywhere on the stall's crate spec — the machinery landed this round), but it adds crates to the world, so it is held with the contents question and not shipped.

10.4 THE RUMMAGE BIN — the op shop's own floor bin (SHIPPED, contents gated)

fittings.js rummageBin + the opshop recipe. A shallow galvanised dump tub (0.95 × 0.70 × 0.28) with flared sides on a castored steel frame, rim at 0.76 m — you rummage standing, not crouched — heaped with stickered mixed goods and a hand-lettered card taped to the front panel ("EVERYTHING $2"). Deliberately not a record crate recoloured: crate is 0.55 × 0.45 and deep because it is sized to stand LPs in, and nothing stands up in this.

// the shipped default — furniture. kind:'prop', no bin, no dig prompt, no `places` entry.
buildInterior(shop, THREE)
// armed — kind:'bin', the dig fires. ONE opt. (interior_test.html: ?opshopdig=1)
buildInterior(shop, THREE, { fittingOpts: { rummageBin: { dig: true } } })

New, generic, opt-in seams (all pure data, no stream reads, absent ⇒ pre-R39 byte-identical):

  • recipe.fittings[i].o — opts handed to the fitting builder.
  • buildInterior opts.fittingOpts[kind] — per-build override of the above. F's gate handle.
  • recipe.fittings[i].fallbackZones / .anywhere — placement fallbacks for a fitting a recipe promises will be there. Without them the bin landed in only 11 of 24 archetype×seed rooms, because it is appended last and its zone is the fullest it will ever be. With them: 72/72.

Guarantees (72 rooms = 6 archetypes × 6 seeds × GLB on/off, headless):

  • placed 72/72 · 0 carves · pathOK 72/72 · determinism 72/72
  • reachable 72/72 — the nearest walkable cell that is flood-reachable from the spawn sits 0.771.15 m from the bin, against the dig's 2.5 m proximity / 3.2 m aimed reach
  • the rest of the room is byte-identical to pre-R39 in 72/72. The spec is appended LAST, so the shared lay-count / lay-place streams hand every existing op-shop fitting the same draws it always had; only the tub is added.
  • end-to-end, armed: aim resolves → dig opens → 16 offers → pull card → BUY debits the wallet → the bin depletes to 15. Negative control, default: tub present, 0 bins, aim finds nothing, the dig never opens. Zero console errors on both arms.

Draw cost — measured on the GLB path, which is the default boot (index.html:349).

drawSweep (the ≤350 law's own instrument) pre-R39 R39 Δ
GLB-off worst 156 (opshop/hall) 161 (opshop/hall) +5
GLB-on worst 319 (opshop/hall) 329 (opshop/hall) +10
every other type, both modes 0 — byte-identical, 11/11

Margin against the ≤350 law: 21 draws. Per-room the tub costs +3…+9 GLB-off and +6…+18 GLB-on (the doubling is not the tub — see §10.5). It is the worst-case room it lands in, which is why this is stated against opshop/hall and not an average. Soak: 60 rooms, leak geo 0 / tex 0, determinism identical, avg 3.4 ms / worst 16.5 ms.

A free win found while costing it (stock.js, shipped). The scatter fill minted a tinted material per item, so a pile of 9 goods in a room with no other scatter cost 9 draws — one bucket each. New opt-in slot.pool keeps every face on the room's shared pooled textures at white, exactly as the spine and shelf-box rows already do, so the pile merges into buckets the room already has. The tub went from 9 goods / 12 draws to 12 goods / 39 draws. Existing scatter callers (trestleTable, pegboard) draw the same values in the same order and are byte-identical — proven by all 11 other types' drawSweep numbers not moving.

10.5 THE INTERIOR BUDGET IS ~2× WHAT IT NEEDS TO BE — one asset material (→ Lanes E/F)

Costing the tub turned up something worth more than the tub. Lane E's bookshelf GLB ships transmission: 1 on mtl_10218_Bookshelves_v1. One transmissive material in a scene makes three.js run its transmission pre-pass — a second full render of the opaque scene into a render target — so every draw in that room is issued and counted twice.

Measured, seed 1990, isolated room, by setting transmission = 0 on that material and re-rendering:

room GLB-on draws with transmission: 0 saved
opshop/hall (the game's worst interior) 191 104 87 (46%)
opshop/wide 142 80 62 (44%)
book/hall 101 51 50 (50%)
dept/hall (no bookshelf) 116 116 0 — the control

bookshelf is in the opshop (12) and book (58) recipes, which are the two worst GLB-on types in the sweep (329 and 243). Roughly half the interior draw budget is being spent on a pre-pass nobody asked for, triggered by a flag on one asset, and a bookshelf rendering as glass is almost certainly not the intent. → E: re-emit without KHR_materials_transmission. → F: the cheap runtime fence is a transmission = 0 scrub in glb.js upgradeFitting; I have NOT shipped it, because it changes how another lane's asset renders and that is not mine to decide in passing. Either fix turns a 21-draw margin into a ~100-draw one.

10.6 THE CONTENTS — HELD FOR JOHN. Do not flip the default without this ruling.

The mechanism is shipped and gated. What is IN the bin is the question, because it is not "op shops now dig", it is "op shops now dig records". Measured against the 23 shipped town caches (1,236 shops: 44 record · 36 stall · 147 opshop):

today with the op-shop bin armed
crate-shops per town 011 225
towns with none 3 (bowral, darwin, daylesford) 0
diggable bins, all 23 towns¹ ~235 ~382 (+63%)
under-band gems/day, all towns² ~113 ~183

¹ record 4.6 bins/room × 44 + stall 0.92 × 36 + opshop 1.0 × 147. ² dig.js:48 — 10% gem roll on a collector/grail band ⇒ 3% per sleeve × 16 sleeves/bin.

A · parody records (what the mechanism inherits). Cost: zero codeproceduralSleeves already serves everything, and the market stall already does exactly this, so this is a change of scale, not of kind. BIBLE bands untouched (record bands exist). RED TICKET DAY gets a roster in all 23 towns. The catch: an op-shop find is stamped type:'record', so it can only be sold in a record shop — and bowral, darwin and daylesford have zero record shops. Those three towns would be able to dig and have nowhere to sell. The first pull from an armed bin, seed 42, was a grail-band record asking $72, in the Salvos bric-a-brac tub. That is either the op-shop fantasy made mechanical or the moment records stop being special; it is a taste call, not a measurement.

B · books and toys through the existing pull-and-buy pack. Not free. (i) The pack is resolved by shop type, so an op shop asks for stock_opshop_index.json, which does not exist — it needs either an E-emitted pack or a recipe-declares-its-pack-types change in C. (ii) The dig scene is record-shaped: dig.js builds a crate of 0.31 m square sleeves and a card that says artist/title/condition. A paperback in it renders as a square LP sleeve. So B costs a second dig presentation. In its favour: the BIBLE already has book and toy bands (bible.js:28-29), so the price fences hold with zero new numbers, and the sell path is clean once F stamps the item's own type instead of the hardcoded 'record'.

C · a new bric-a-brac class. Highest cost — a new BIBLE band row, a parody generator or pack, a card treatment, gates. Also the only option that fixes A's asymmetry: sellableIn is type = type, so bric-a-brac would sell in op shops, which is both correct and diegetic, and the op shop becomes its own loop instead of a second-rate record shop.

My recommendation. Ship the flag (done), and let John play arm A for one session before anyone decides — it is zero code and it answers the only question that matters, which is whether digging an op shop is fun. Then: C is the destination, because it is what stops "records" being the answer to every question in this game and it closes the three-town sell hole. B is not worth doing alone — its cost lands in the wrong place (a record-shaped dig scene showing a book), and its good half (the band table, the type stamp) is inherited by C anyway. If John wants RED TICKET DAY running in all 23 towns this round, A does it today for nothing, and the three-town sell hole is the price.