PROCITY/docs/LANES/LANE_C_PUB.md

550 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
> **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.
*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 **~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`/`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.
---
## 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)
```js
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=0``window.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)
```js
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
```