SEAM RULING (§8): bless the direct wire — NO lifecycle hook. F already holds currentAdapter (interior_mode.js:67) and passes it to dig.open() PER DIG (:111), so the live read lands, F updates the pack, the next riffle picks it up; not-landed/slow/dead => the adapter is simply the tier-1 pack => the dig never blocks (charter risk #1). No C lifecycle change. BUT THE QUESTION UNDERNEATH IT IS C'S, AND THE OBVIOUS WIRING IS WRONG. pickRealOffers indexes pack.items[(rr()*len)|0], so FILTERING THE ARRAY RESHUFFLES THE CRATE. Measured, 5 crates: one record selling changed 21 OTHER records (6/2/2/6/5 collateral). Pick-then-omit (draw from the stable full list, drop gone after) = 0 collateral: a crate with 3 sold shows 13, not a different 16. Selling one record must remove one record, not re-stock the shop. THE TRAP: both forms hide the sold record — so F's planned assertion ("a real gone item never renders") GOES GREEN ON THE WRONG WIRING. It cannot see the 21. -> F: assert on COLLATERAL (the survivors are unchanged across a gone delta), not absence. Vacuous-gate law, third application, and the cheapest to miss because it passes. LANDED (dig.js): pack.gone (Set|array) omitted at PICK time. F's invariant published: update item FIELDS in place (live price) freely; NEVER add/remove/reorder items[] — that identity+order IS the seeded pick's stability (R25's positional-id lesson, one layer up). DETERMINISM BOUNDARY sits exactly here: server down => byte-identical tier 1 (gate-able); server up => the crate legitimately changes (tier 2 is non-deterministic BY DESIGN), so no gate may assert dig determinism with the server up, and pack.gone never enters a seeded stream or golden. SCOPED: the bin fan is decorative (a different seeded pick from the dig's offers) — a sold cover may linger there until rebuild. That's when I'd publish a post-build hook; the epoch's claim doesn't need it, because the crate you RIFFLE is true. VERIFIED: pure-function measurement for the collateral claim, then end-to-end through the real dig's public surface (pull -> Esc -> scroll -> pull): sold record gone, survivors 4/4, collateral 0, items[] untouched at 120, live in-place price shows on the next pull. (My first harness returned the same title 4x — I never un-pulled — so its number was an artifact; re-ran it properly rather than report a metric I'd just watched lie.) DOCS FREEZE (§6): LANE_C_PUB is v5.0-FROZEN, whole document, with the case law at the top: three amendments here exist because a rule of mine met a case written AFTER it, and both times the lane building to my rule took the red. Header now states it: when a line meets a case it didn't foresee, the line is the stale thing — amend it, don't bend the case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
465 lines
34 KiB
Markdown
465 lines
34 KiB
Markdown
# 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-SCOPED** —
|
||
> `sku_<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.
|
||
|
||
*The district ships **three** venue archetypes behind `?gigs=1` — `pub`, `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.type` ∈ `pub` | `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)
|
||
```js
|
||
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**: `role` ∈ `guitar` / `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)
|
||
```js
|
||
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`:
|
||
```js
|
||
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`):**
|
||
```json
|
||
{
|
||
"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 **~2–3× 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`/`hardware` → `dept` 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`**.
|
||
- **`type` ∈ `record` | `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:
|
||
```json
|
||
{ "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`,
|
||
`provenance` — **`stockpack.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)
|
||
```js
|
||
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.
|