Compare commits

...

6 Commits

Author SHA1 Message Date
m3ultra
f419c1a5e2 Fable (integrator): R30 wave-1/2 review — amendments ratified, the cross-lane 6 lines adjudicated, wave-3 inputs bound
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:29:39 +10:00
m3ultra
7b7227af73 Lane F R30 close-out: §30.5 results (7 verify legs, qa 6/6, the two harness bugs recorded) + F-progress — ledger #5 gates + sell routing held for the later F session per the brief 2026-07-18 14:27:44 +10:00
m3ultra
207bcffb54 Lane F R30 (v7.0-alpha ledgers #1+#3): THE SAVE CORE + SLEEP=SAVE=TOMORROW — save.js (procity-save/1, delta law by shape, loud reject + stash, beforeunload), game-backed wallet facade on the proven seam, buy seams record the collection (type/sku|slotId/title, C's §9 asks), day-salted stock rotation (stk-* seam in interiors/layout [marked], dig binSeed; day 1 = pre-v7, REAL never rotates), gig weekNight = (day-1)%7 (amended from day%7 with the measurement, §30.3), wake at DAWN. classic/game=0: layer never constructed, ZERO storage touches measured. Verified 7 legs green in fresh contexts; qa.sh --strict 6/6, 0 warn 2026-07-18 14:26:35 +10:00
m3ultra
369a2be23e Lane C R30 (v7.0-alpha ledger #2, implementation): sell.js — the sell counter lands. E at the bench → the mirrored price sticker; offer strictly below pricePaid by construction (8/8 scripted round trips lose money); type=type fail-closed (untyped item sells nowhere); wallet.sell = the credit mirror; game layer absent → no card, zero errors. Verified on :8144 no-store, stubbed game shape (F's core not landed); soak green avg 3.4ms · leak 0/0 · draws card-open ≡ closed 2026-07-18 14:14:14 +10:00
m3ultra
53fcd3ca1b Lane F R30 (v7.0-alpha ledger #1, THE CONTRACT FIRST): LANE_F_NOTES §30 — window.PROCITY.game published ({day,cash,collection,townKey,save,load,sleep,export,import,wallet,recordFind,removeFind}), procity-save/1 schema (the delta law by shape), the day-salt rotation law (day 1 = pre-v7, real never rotates, gig night = day%7), C's §9 asks honoured (type/title/artist + removeFind) 2026-07-18 14:09:09 +10:00
m3ultra
c8503a5a2e Lane C R30 (v7.0-alpha ledger #2, contract first): LANE_C_PUB §9 — the sell counter. Offer = min(p−1, max(1, floor(p·0.5))): the no-pump law structural, not tuned; type=type strict, fail-closed on untyped items; onSell mirrors onBuy (the proven wallet seam); collection read-only, removal via the game API 2026-07-18 14:04:59 +10:00
14 changed files with 900 additions and 15 deletions

View File

@ -3,11 +3,71 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-17 (round 28) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-18 (round 30) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-17 (round 28, THE SPIKE AND THE SWEEP) — `audioEmitter`: the till rings, and the panner lies
## Update 2026-07-18 (round 30, v7.0-alpha THE SAVE AND THE CRATE) — the sell counter: cash comes back, always less
R30 ledger #2 — contract first (LANE_C_PUB §9, its own commit), implementation second. F's save core
(ledger #1) had **not landed** when this was built: no `save.js`, no `window.PROCITY.game` anywhere in
the tree — so everything below is built to the R30-published shape and verified against a **stub** of
exactly that shape (noted loudly below).
### The contract (§9) — the one number and why
**`sellOffer(p) = min(p 1, max(1, floor(p × 0.5)))`, never < 0.**
- **0.5** is the secondhand-dealer margin — the keeper pays half of what he'll sticker it at.
- **`min(p 1, …)` is the no-pump law made STRUCTURAL:** the offer is strictly below `pricePaid` for
every `p ≥ 1`, so buy-then-immediately-sell loses ≥ $1 per round trip **by construction** — no
balance retune can break F's monotonic-loss gate without violating the contract. At `p = 1` the
offer is $0 and SELL disables (the keeper won't buy what he can't sticker).
- **Alpha basis = `pricePaid`** (every alpha buy is at asking price, so it IS the buy-side price the
player faced; there are no guide bands yet). **Beta swaps the basis to `bandLow`, not the shape.**
- Matching is **type = type, strict, fail-closed**: an untyped item sells NOWHERE (a matcher passing
untyped items would "match" everywhere — vacuous-gate law applied to a matcher).
### What shipped (C's files only)
- **`web/js/interiors/sell.js`** — `createSell()` + pure `sellOffer` / `sellableIn` / `nearCounter`.
DOM-only (zero draws, zero GPU), the dig's manila price-sticker mirrored to the left with a green
offer sticker; one item at a time, ` ` cycling, ✕/Esc; till rings from the counter emitter (R28
one-shot pattern). Same consumer pattern as `dig.js`: the single side-effect is `onSell(item,
offer)` — **C never mutates `game.collection`** (read-only law, §9.3).
- **`web/js/interiors/wallet.js`** — `wallet.sell(item, offer)`: the credit mirror of `buy()`.
This file never prices; the offer comes in from `sellOffer`.
- **`web/interior_test.html`** — E-at-counter routing (bin → dig · counter → sell · shelf → buy) gated
on `window.PROCITY.game` presence (the in-game classic-pure law, exactly); `?sellstub=1` stubs the
published game shape for verification until F's core lands.
### Verified (fresh context, no-store server on :8144 — NOT :8130, zero console errors throughout)
- **The sale:** record shop seed 1990, at the bench (`nearCounter` true at 0.9 m, false across the
room) → card `THE FIBROS — "Servo at Midnight" · you paid $24 · offer $12` → SELL → **cash 163 →
175 (+$12 < $24 paid)**, collection 4 → 3, sold item gone, toast + till fired.
- **Type fence:** stub carried 2 records + 1 book + 1 untyped item. Record shop cards **exactly the 2
records**; book shop cards **exactly the book** ($7 on $15); the untyped item cards **nowhere**.
- **The disabled edge:** the $1 record offers $0 → button reads NOT WORTH BUYING, disabled.
- **No-pump round-trip (C's mini-gate, precursor to F's #5):** buy-then-sell through the real wallet +
card path, prices {2,3,5,9,24,60,61,137} → **8/8 strictly negative** (1,2,3,5,12,30,31,69).
- **The gate discriminated first** (vacuous-gate law, proven live): my first loop pushed a 999-priced
item into the collection **without checking `wallet.buy` succeeded** — broke wallet, no debit, then
a +$499 "sale". A pump — from an item never paid for. **Finding for F below.**
- **Fail-soft:** boot with no stub (= no game layer) → E at the counter does nothing, `open()` false,
zero DOM, zero errors. `dispose()` leaves 0 panels/0 toasts in the DOM.
- **Budgets:** draws with the card open ≡ closed (16 ≡ 16 — DOM is zero-draw); `buildInterior` and all
room code untouched this round; 50-room soak re-run green in the same session: **avg 3.4 ms · worst
8.2 ms · leak geo 0 / tex 0 · determinism identical · worst draws 73 ≤ 350**.
### Findings / asks (→ F, for the save core + gates)
1. **The collection item needs `type`** (registry stock type, stamped at buy time). The R30 shape
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
it **nothing is sellable anywhere** (my matcher is fail-closed on purpose). `title`/`artist` are
strongly asked too (B's collection UI needs them; my card falls back to the sku label — honest, ugly).
2. **`pricePaid` must only ever be written by a SUCCESSFUL debit.** The no-pump law's structure
assumes pricePaid was actually paid — my broken first loop showed a collection entry that skipped
the wallet debit converts straight into a money pump. F's no-pump gate should include this negative
control: a collection entry minted without a debit must be impossible (or the gate must fail).
3. **The removal handoff:** in-game, the sold item must leave the collection **via the game API**
`onSell` is where F calls it. My harness splices its own stub (its array, its right); C's modules
never touch game state. When F publishes the API name, §9.3 references it verbatim.
R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.

View File

@ -4,6 +4,46 @@
---
## Round 30 (v7.0-alpha — THE SAVE AND THE CRATE) — F: the contract first, then the save core + tomorrow
**The contract shipped before a line of implementation** (`53fcd3c`): `window.PROCITY.game =
{ day, cash, collection[], townKey, save(), load(), sleep(), export(), import(json), wallet,
recordFind(), removeFind() }` + the `procity-save/1` schema + the rotation law, published in
LANE_F_NOTES §30 — and it worked exactly as the order intended: **B picked up §30.4 within the
session** (venue.js "tonight" already reads `gigs.weekNight`; hud.js collection/SLEEP surface in
flight), and C's already-published §9 asks (`type` stamped at buy, `title`/`artist`, removal via the
game API, `wallet.sell` mirrored) are honoured in the entry shape.
**Then the layer** (`207bcff`): `web/js/world/save.js` (F-owned, new) — versioned localStorage save,
THE DELTA LAW BY SHAPE (payload keys are exactly `[cash, collection, day, savedAt, schema, town]`
no world field can even be expressed); corrupted/foreign → **loud reject, raw stashed, fresh start,
town boots**; save on sleep + beforeunload; export/import for moving machines. The wallet is a
game-backed facade of C's exact v0 interface, so every proven seam (dig onBuy, shelf buy, cover,
C's onSell) is unchanged — cash just became authoritative from the save. Buy seams record finds;
`?classic=1`/`?game=0` never construct the layer — **ZERO Storage calls, instrumented, not trusted**.
**SLEEP=TOMORROW:** `sleep()` → day+1 → gig `weekNight` re-keys the EXISTING week schedule → wake at
DAWN → save. Stock rotation is a runtime day salt on the EXISTING `stk-*` streams (2 marked seam
lines in C's interiors/layout) + the dig's `binSeed`**plan generation never sees `day`** (A's #6
boundary). **Day 1 IS the pre-v7 town** (measured: `?game=0` stock fp == fresh-game day-1 fp), and
**REAL-sourced crates never rotate** (Monster Robot fp identical across sleeps; the mint crate rotates).
**Two amendments filed loudly, with measurement, not slipped in:** gig night = **(day 1) % 7**, not
the brief's literal `day % 7` (day starts at 1 — the literal form flips a fresh boot's gigs off night 0,
breaking the R13 cover/band gates before the player ever sleeps); and sleep saves AFTER incrementing
(disk always carries the morning you woke into). Fable ratifies or overrules at review.
**Numbers:** 7 verify legs green in fresh no-store contexts — $12 dig buy == pricePaid recorded · sell
halves credit/remove correctly · parody fp `3396372961 → 1818111125` across a sleep, byte-identical
same-day AND across reboots · corrupt blob = 1 loud reject + stash + clean boot · `qa.sh --strict`
**6 passed · 0 failed · 0 warn** with the game layer default-ON. Two F-harness bugs caught and fixed
in the run (the post-sleep CLOSED door; a transform-based fingerprint that was vacuous over batched
parody stock — hash the VERTEX data). Full record: `LANE_F_NOTES` §30.
**Held for the later F session (per the brief):** ledger #5 gates + the sell-card E-key routing.
---
## Round 26 (v5.0-beta — EVERY CRATE DIFFERENT) — F: the manifest consumed, the tolerance retired
**Every crate is different, and F measured it rather than taking G's word.** Not by entering one lucky

View File

@ -29,6 +29,9 @@
> **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
@ -462,3 +465,85 @@ never persisted.
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
```

View File

@ -1447,3 +1447,169 @@ subject-presence check now reads the disk. *The vacuous-gate law has a sibling:
own subject being absent.*
**No tag** — wave 0 rides Spike 2's close, which is gated on John's footage. Nothing else is owed on m3.
---
## 30. Round 30 — v7.0-alpha THE SAVE CORE (ledger #1) + SLEEP=SAVE=TOMORROW (ledger #3)
### §30.1 THE CONTRACT — `window.PROCITY.game` (PUBLISHED FIRST, per the brief; B builds on this, C reads it at the counter)
`window.PROCITY.game` is **null** under `?classic=1` and under `?game=0`; on every other boot it is:
```js
window.PROCITY.game = {
day, // getter, int ≥ 1. DAY 1 IS THE PRE-v7 TOWN: stock streams carry no day salt on day 1,
// so a fresh game's shops are byte-identical to a ?game=0 boot. Rotation begins at the
// first sleep. (Falsifiable: day-1 stock == no-game stock; day-2 stock != day-1 stock.)
cash, // getter, int dollars — always equals PROCITY.wallet.cash()
collection, // getter → the LIVE array. READ-ONLY for every consumer (C's §9.3 law); the only
// mutations are game.recordFind (buy seam) and game.removeFind (sell seam).
townKey, // string for THIS boot: `${plansrc}/${town || 'default'}@${seed}`
// e.g. 'synthetic/default@20261990' · 'osm/redhill_godverse@20261990'
save(), // → bool. Serializes schema procity-save/1 → localStorage['procity-save'].
load(), // → bool (ran once at construction). Corrupted/foreign blob → LOUD reject
// (console.error), raw stashed at localStorage['procity-save.rejected'], fresh start.
// THE TOWN NEVER BREAKS: the save holds only player deltas (the delta law), so a bad
// save can cost you your stuff, never the world.
sleep(), // → the new day (int). day+1 → gig weekNight re-keys to (day 1) % 7 (see the §30.3
// amendment) → wake at DAWN (lighting segment 0) → save(). Stock streams re-seed (§30.3).
export(), // → JSON string — the exact procity-save/1 payload (John moves machines).
import(json), // → bool. Validated exactly like load(). true ⇒ state replaced + saved + day-derived
// state applied (weekNight etc). false ⇒ loud reject, CURRENT STATE UNTOUCHED.
wallet, // the wallet-compatible facade — PROCITY.wallet IS this object when the game is on.
// Full Lane C v0 interface: cash() start() canBuy(p) buy(o) sell(o, offer)
// inventory() count() onChange(fn). buy/sell/inventory keep C's exact semantics
// (sell credits the offer + drops the matching v0-inventory entry, per §9.3);
// cash is game-backed so a loaded save's cash is authoritative.
recordFind(shop, info), // F's buy-seam internal (interior_mode calls it) — appends a collection
// entry stamped with townKey + day. Exposed so the gates can drive it.
removeFind(entry), // → bool. Identity (===) removal — THE only way the collection shrinks.
// C's sell counter: onSell ⇒ F calls wallet.sell(item, offer) +
// game.removeFind(entry). Returns false (nothing moves) if entry ∉ collection.
};
```
**Collection entry shape** (C's §9.2 asks honoured: `type` stamped at buy time, `title`/`artist` carried):
```js
{ townKey, // where in the world — the boot key above
shopId, // plan shop id (always present)
godverseShopId, // the real POS id, ONLY when the shop has one (the two id spaces stay separate — R24 law)
type, // the ITEM's stock type: 'record' (any dig, incl. an opshop bin) · 'book' · 'toy' (shelf
// buys). Sellability is type = type, fail-closed (C §9.2) — this field is load-bearing.
sku, // pack item id (`sku_*` / `mint_*` / `rec_*` — the sku IS the identity, R25/26 fences)…
slotId, // …OR, for parody items with no pack id: `<binKey>#<offerIndex>` (dig). Exactly one
// of sku|slotId is set.
title, artist, // display (B's collection card; C's sell card falls back to sku/slotId when absent)
pricePaid, // int dollars actually debited
dayFound } // game.day at purchase
```
**Save schema `procity-save/1`** (localStorage key `procity-save`):
```js
{ schema: 'procity-save/1', day, cash, town: townKey, collection: [entries], savedAt }
```
THE DELTA LAW, enforced by shape: no plan, no shop, no stock, no clock, no world field of any kind ever
enters this object — the world regenerates from seed. Validation on load/import is exact-schema-string +
field checks (day int ≥ 1 · cash finite ≥ 0 · collection entries carry townKey/pricePaid/dayFound and
one of sku|slotId); any miss rejects the WHOLE save loudly. Versioning: a future `procity-save/2`
migrates-or-rejects, never silently coerces.
**Save points:** `sleep()` and `beforeunload`. (Note, measured against the brief's "save, day+1" order:
sleep increments FIRST and saves the post-sleep state, so disk always carries the morning you woke into —
the literal order would leave disk one day stale between sleep and unload. Same falsifiable outcome, one
fewer window.)
### §30.2 The laws, wired from birth
- **`?classic=1`** → `createGame` is NEVER CALLED: no game object, no wallet facade, no listener, ZERO
localStorage touches (save.js has no module-scope storage access — the classic-purity gate in ledger #5
measures this rather than trusting it). `?game=0` → same absence, C's plain `createWallet` runs the
session exactly as pre-v7. Default boot → game on (the v3.1 flip precedent).
- **Game money is game money:** the save carries dollars that exist nowhere else; import validates shape,
not provenance — there is no bridge to anything real, in either direction.
### §30.3 SLEEP = TOMORROW — the rotation law (ledger #3, runtime only)
- **The day salt.** The parody/mint stock pick streams re-seed per (shop, day) by salting the EXISTING
runtime streams — plan generation NEVER sees `day` (A verifies the boundary, ledger #6):
- interior stock: `buildInterior(shop, THREE, { …, stockDay: day })``ctx.stockSalt = ':d'+day`
layout.js appends it to the `stk-*` sub-stream salts (one marked seam line each in interiors.js +
layout.js — `[Lane F R30 seam]`). Fittings/layout/audio streams untouched: the ROOM is the same
room every day; what rotates is which items the streams pick into it.
- the dig: `binSeed(shop.seed, binKey + '@d' + day)` (interior_mode, F-owned).
- `day === 1` or no game layer ⇒ NO salt (byte-identical to pre-v7 — the day-1 convention above).
- **REAL-sourced stock never rotates.** A shop whose per-shop pack the godverse manifest declares
`sourcing:'real'` (Monster Robot Party) gets NO day salt, ever — its crate is a real crate. `mint`
crates and parody stock rotate. (The town-wide v2 packs are real *images* used as seeded set-dressing,
not a real shop's inventory — they rotate with the parody streams. Ruled here so nobody re-derives it.)
- **Gig night = (day 1) % 7 — AMENDED from the brief's literal `day % 7`, with the measurement.**
`day` starts at 1, so the literal form keys a FRESH game's first night to night 1 — flipping the
default boot's gigs off night 0 before the player ever sleeps, breaking the R13 cover/band gates and
B's night-0-keyed frontage, and contradicting the day-1-is-the-pre-v7-town convention this same
contract establishes for stock. (day 1) % 7 keeps the brief's intent exactly — every sleep walks
the existing seeded week — and day 1 IS night 0. `gig_state.js` (F-owned) gains `setWeekNight(n)` +
`get weekNight`; every venue's latch re-keys to its night-n gig (plan.gigs is already the whole week;
the three hard-won latch laws untouched). Cover stamps reset with the re-key (new night ⇒ cover due),
correct by John's R12 ruling. Fable ratifies or overrules at review — filed loudly, not slipped in.
- **Wake at dawn:** `sleep()``lighting.setSegment(0)` (fires `procity:segment`, so every latch +
facade observes the morning — the listens-not-polls law does the work for free).
### §30.4 Cross-lane notes on this contract
- **→ B (collection UI + SLEEP surface, ledger #4):** everything you need is `PROCITY.game`
(`day`/`cash`/`collection`/`sleep()`) + `wallet.onChange` for the cash chip. Cover thumbs: resolve via
the entry's `sku` against the shop's pack where present; parody entries have no thumb (title/artist
card is correct-and-readable, per the brief).
- **→ B (found while wiring weekNight):** `venue.js:38` keys "tonight" to **night 0** — correct pre-game,
but after the first sleep the marquee/frontage would advertise night-0's band while the latch plays
night-(day%7)'s. `PROCITY.gigs.weekNight` (and `game.day`) are published; one line in venue.js when
you touch the HUD. Filed, not silently fixed (your file).
- **→ C (sell counter, §9.3):** your asks are honoured — `type` stamped at buy time, `title`/`artist`
carried, removal via `game.removeFind(entry)`, credit via the facade's `sell(item, offer)` (mirrors
your wallet.js exactly). The E-key routing hook (aimed bin → dig · near counter + sellable → sell card
· else shelf-buy) is F's and lands with the sell wiring pass, not this one.
- **→ A (ledger #6):** the day salt rides `buildInterior` opts / `binSeed` only — grep-provable that
`generatePlan*`/plan_osm never receive `day`.
### §30.5 RESULTS — implemented, measured, committed (`53fcd3c` contract · `207bcff` implementation)
All measured in FRESH browser contexts on a port-isolated no-store server (:8791 — the module-cache lesson),
seed 20261990, plus `redhill_godverse?stock=real` for the crate legs. Seven legs, all green:
| leg | measured |
|---|---|
| the contract | default boot: `game` live — day 1 · cash **$191 == wallet.cash()** · collection 0 · townKey `synthetic/default@20261990` · weekNight 0 · full API incl. `wallet.sell` · 0 errors |
| classic purity | `?classic=1` AND `?game=0`: `game === null`, **ZERO Storage-prototype calls** (instrumented before any page script, not trusted) · wallet $191 (C's v0) · 0 errors |
| buy → collection | dig pull: **$12 debited**, entry `{type:'record', slotId:'20_-317#0', pricePaid:12, dayFound:1, title:'Sunburnt'}` — debit == pricePaid, shape == §30.1 |
| sell seam halves | `wallet.sell` credited $3 (179→182) · `removeFind` true then **false on re-remove** · collection 0 |
| sleep | `sleep()` → day 2 · **woke at DAWN (seg 0)** · weekNight 1 · localStorage payload keys exactly `[cash, collection, day, savedAt, schema, town]` — the delta law by shape, no world field |
| rotation | parody shop vertex-data fp: day 1 `3396372961` → day 2 `1818111125` (**rotates**) · re-entry same day **byte-identical** · after reload **still identical** (cross-boot determinism) · `?game=0` fp **== day-1 fp** (the day-1 convention, measured) |
| persistence | reload: day 2 · cash · collection 1 · weekNight 1 all restored (beforeunload save) · `export()` valid · `import()` of garbage + foreign schema **rejected loudly, state untouched**; real payload adopted + weekNight re-keyed |
| corrupt save | pre-seeded bad blob → **1 loud reject**, raw stashed at `procity-save.rejected`, fresh start ($191, day 1), **town boots, 0 other errors** |
| the crates | **REAL** (Monster Robot Party g:3962749): base `stock_godverse/3962749/`, fp **identical** day 1 → day 2 — real never rotates · **MINT** (Presents of Mind g:767): fp **rotates** — mint does |
`qa.sh --strict` **6 passed · 0 failed · 0 warn** over the landed layer — classic regression, default-boot
gate, buy-v0, cover (paid + free), the R26 crate gate, R27 live gate, glance, no-giants: all green with the
game layer default-ON.
**Two of F's own harness bugs caught by the run (recorded per house habit):**
1. **The post-sleep CLOSED door.** First fp read after `sleep()` failed to enter the shop — because the
town woke at DAWN and the shop was **correctly closed** (the hours law doing its job). The harness now
sets midday before entering. A verify that forgets the world has rules will read the rules as bugs.
2. **The fingerprint was vacuous for parody rooms.** It hashed mesh transforms + first UVs — but
`batchRoom` merges parody stock into merged geometry at IDENTITY transform, so it hashed constants and
reported "no rotation" over stock that rotates. (Mint showed through only because atlas UV rects land
in the first 8 floats.) Fixed: hash the **vertex data** (position + uv arrays), where the day-salted
picks/jitters actually live. The vacuous-measurement species, caught in F's own tool, same round it
re-read the law to everyone else.
**Live cross-lane note (seen mid-session, not F's commit):** B picked up §30.4 within the hour —
`venue.js` "tonight" is already a live getter off `PROCITY.gigs.weekNight`, and `hud.js` has the
collection/SLEEP surface in flight. The contract-first order did exactly what it exists to do.
**Held / next session (per the brief):** ledger #5 gates (save/load determinism scripted session ·
no-pump v0 · classic-purity localStorage gate · rotation determinism) — a LATER F session. The sell-card
E-key routing (dig → sell → shelf priority, §9.4) rides the sell wiring pass alongside it.

View File

@ -74,6 +74,28 @@ it.
Waves: **[F, C] → [B, F-cont] → [F-gate, A]**. D/E/G rest unless footage arrives.
## WAVE-1/2 REVIEW NOTES (Fable, mid-round — the agents' reports are the record)
**RATIFIED — F's two amendments, both measured:** (1) gig night = **(day 1) % 7** — the
brief's literal `day % 7` flips a fresh boot (day 1) off night 0 and breaks the R13 cover/band
gates before the first sleep; the brief was wrong, the measurement wins. (2) **sleep saves
AFTER incrementing** — disk always carries the morning you woke into.
**ADJUDICATED — the cross-lane 6 lines (F edited C's `interiors.js`/`layout.js` for the
`stockDay` salt):** SANCTIONED, this instance — because all three conditions held: the owner
reviewed and ratified on the record BEFORE the commit ("rides my own sub-stream discipline,
absent ⇒ byte-identical"), the edit is marked and cited in the commit, and it is minimal.
**Without all three, a cross-lane edit gets reverted on sight.** The law stands: write the
ask, not the edit — C folds the seam into LANE_C_PUB at its next session; carried to the
epoch retro as a process note.
**WAVE-3 INPUTS (bind these into the gates session):** the rotation gate must hash **vertex
data, not transforms** (F measured its transform fingerprint vacuous over batched parody
stock); C's money-pump **negative control** (an item minted into the collection without a
successful debit must be impossible — assert `recordFind` only fires on `wallet.buy` truthy);
the **E-key sell routing** (§9.4 dig → sell → shelf) is F's held item and lands in the wave-3
session BEFORE the gates run against it.
## Standing
- **John — nothing blocks this round.** The deploy to digalot.fyi/procity still waits on your

View File

@ -62,6 +62,7 @@ import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R1
import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers)
import { VenueQueue } from './js/citizens/queue.js'; // [Lane F R13] Lane D outdoor gig queue (?gigs=1)
import { preloadManifest, preloadStockPack } from './js/interiors/interiors.js'; // [Lane F] Lane E manifest + Lane C/E stock pack
import { createGame } from './js/world/save.js'; // [Lane F R30] v7 THE SAVE CORE (contract: LANE_F_NOTES §30)
main().catch((err) => {
console.error('[procity] init failed:', err);
@ -240,18 +241,46 @@ if (STOCK_REAL) {
}
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops.
// Runtime-only: never writes back into the plan/room build, so goldens + draw-counts are unaffected.
const wallet = createWallet(seed);
// [Lane F R30 — v7 THE SAVE CORE] The game layer, default-ON (the v3.1 flip precedent): ?game=0 opts out,
// ?classic=1 forces its total absence (createGame never called ⇒ ZERO localStorage touches — the covenant
// stays a pure v2 boot). When the game is on, PROCITY.wallet IS game.wallet — Lane C's exact v0 interface
// (buy/sell/canBuy/…) backed by game-owned cash, so a loaded save's cash is authoritative and every
// existing consumer (dig onBuy, shelf buy, the cover charge, C's onSell) rides the proven seam unchanged.
// A fresh game opens with Lane C's seeded start cash, so day 1 is byte-identical money to a ?game=0 boot.
const GAME_ON = flagOn('game');
const TOWNKEY = `${PLANSRC}/${TOWN || 'default'}@${seed}`;
const baseWallet = createWallet(seed);
const game = GAME_ON ? createGame({
townKey: TOWNKEY, startCash: baseWallet.start(),
// fired after sleep()/import() moves the day: re-key the gig week (day % 7 into the existing schedule),
// wake at dawn (segment 0 — fires procity:segment, so every latch observes the morning), tell John.
onDay: (day) => {
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day 1) % 7 — see the seam note below
lighting.setSegment(0);
hud.showToast(`🌅 day ${day}`);
},
}) : null;
const wallet = game ? game.wallet : baseWallet;
// [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
// audio engine tests to decide there is no gig layer at all (byte-identical, no spill).
const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ plan, lighting }) : null;
// [Lane F R30 — SLEEP=TOMORROW] key tonight's gigs off the game day: weekNight = (day 1) % 7, so a
// loaded save wakes mid-week and every sleep walks the EXISTING seeded week schedule. Measured against
// the brief's literal `day % 7`: day starts at 1, so the literal form would boot a FRESH game on night 1
// — flipping the default boot's gigs off night 0, breaking the R13 cover/band gates and B's night-0-keyed
// frontage before the player ever sleeps. Day 1 IS night 0 (the pre-v7 tonight), same convention as the
// day-1 stock streams (§30.3). Skipped entirely when either layer is off.
const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day));
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter()
// time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id),
rosterOf: (id) => citizens.tonightRoster(id), gigState, // [R15] identity continuity: the crowd IS tonight's roster
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null) }); // [R27] tier 2
liveBase: LIVE_BASE, sourcingOf: (id) => (STOCK_SOURCING ? STOCK_SOURCING(id) : null), // [R27] tier 2
game }); // [R30] the collection (buy seam records finds) + the day salt (stock rotation, §30.3)
// [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's
// open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin
// doesn't read as "walked out of the shop".
@ -485,7 +514,8 @@ frame();
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS,
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
wallet, // [Lane F R8] Lane C buy loop v0 (cash/buy/sell/count) — game-backed facade when the game is on
game, // [Lane F R30] THE GAME (LANE_F_NOTES §30 contract) — NULL under ?classic=1 / ?game=0
gigs: gigState, // [Lane F R12] the gig state machine — NULL flags-off (Lane B's audio tests this to
// decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done
venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
@ -494,7 +524,8 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
// reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out.
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram') },
flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram'),
game: GAME_ON }, // [R30] the v7 game layer (default-on; ?game=0 / ?classic=1 → off)
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so

View File

@ -73,6 +73,7 @@ import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPa
import { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js';
import { collapseBuyItem } from './js/interiors/stockpack.js';
import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './js/interiors/sell.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
@ -522,6 +523,54 @@ async function shelfBuySoak() {
return out;
}
// ── sell counter (R30, v7.0-alpha ledger #2) — walk to the counter holding items, press E → sell card ──
// GATE = the game layer's presence (window.PROCITY.game), exactly the in-game law: ?classic=1/?game=0
// build no game object, so no sell card, zero errors. F's save core (R30 ledger #1) hadn't landed when
// this was wired, so ?sellstub=1 STUBS the published contract shape ({ day, cash, collection[] } — the
// slice C reads) purely for this harness. When F's window.PROCITY.game is real, the stub retires; the
// sell path below is what F wires verbatim (LANE_C_PUB §9.3/§9.4).
const SELL_STUB = new URLSearchParams(location.search).has('sellstub');
if (SELL_STUB) {
window.PROCITY = window.PROCITY || {};
// STUB — R30 ledger #1's published shape, + the §9.2 asks (type, title, artist) the card needs.
window.PROCITY.game = window.PROCITY.game || { day: 1, cash: 0, collection: [
{ townKey: 'test', shopId: 'stub1', slotId: 'stub_0001', type: 'record', title: 'Servo at Midnight', artist: 'THE FIBROS', pricePaid: 24, dayFound: 1 },
{ townKey: 'test', shopId: 'stub2', slotId: 'stub_0002', type: 'record', title: 'Arvo', artist: 'GALAH', pricePaid: 1, dayFound: 1 }, // $1 → offer $0 → SELL disabled (§9.1)
{ townKey: 'test', shopId: 'stub3', slotId: 'stub_0003', type: 'book', title: 'The Long Paddock', artist: '', pricePaid: 15, dayFound: 1 },
{ townKey: 'test', shopId: 'stub4', slotId: 'sku_untyped', pricePaid: 30, dayFound: 1 }, // NO type → never sellable (§9.2 fail-closed)
] };
}
const sell = createSell();
function trySell() {
const game = window.PROCITY && window.PROCITY.game; // absent ⇒ no game layer ⇒ no card
if (!game || !Array.isArray(game.collection) || !current) return false;
if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench
const opened = sell.open({
shopType: current.dims.type, shopName: current.recipe.label,
items: game.collection, // READ-ONLY — sell.js cards a filtered copy
getCash: wallet.cash,
emitters: current.audio && current.audio.emitters,
// The consumer's two halves (§9.3): credit the wallet + remove from the collection. In-game the
// removal is F's, via the game API; HERE the collection is this harness's own stub, so the harness
// splices its own array. C's modules never touch it.
onSell: (item, offer) => {
wallet.sell(item, offer);
const i = game.collection.indexOf(item); if (i >= 0) game.collection.splice(i, 1);
return true;
},
});
if (opened && controls.isLocked) controls.unlock();
return opened;
}
addEventListener('keydown', e => {
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
if ((dig && dig.active) || sell.active) return;
if (DIG_ON && binUnderAim()) return; // bins → dig (that listener handles it)
if (shelfPanel.style.display === 'block') return; // shelf card already up
trySell(); // counter → sell card
});
if (SELL_STUB) document.getElementById('hint').innerHTML += ' · <b>E</b> at the counter: sell';
// ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now();
function frame() {
@ -534,7 +583,8 @@ function frame() {
// expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak };
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak,
SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST };
rebuild();
frame();

View File

@ -172,6 +172,10 @@ export function buildInterior(shop, THREE, opts) {
const norm = normalizeShop(shop);
const recipe = getRecipe(norm.type);
const ctx = new Ctx(THREE, norm.seed);
// [Lane F R30 seam — SLEEP=TOMORROW, LANE_F_NOTES §30.3] opts.stockDay salts ONLY the stock pick
// streams (layout.js `stk-*`): the room/fittings/audio streams are untouched, so the shop is the same
// shop every day and only what's in the crates rotates. Absent (no game / day 1 / real-sourced) ⇒ ''.
ctx.stockSalt = opts.stockDay != null ? ':d' + (opts.stockDay >>> 0) : '';
// room shape → dims → shell
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);

View File

@ -414,7 +414,7 @@ export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
for (const p of placed) {
if (p.removed) continue;
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}`));
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}${ctx.stockSalt || ''}`)); // [Lane F R30 seam] day-salted stock rotation (§30.3)
places.push(...(p.fitting.places || []));
}

160
web/js/interiors/sell.js Normal file
View File

@ -0,0 +1,160 @@
// PROCITY Lane C — the sell counter (v7.0-alpha, R30 ledger #2). Walk to the keeper's counter holding
// items the shop trades in → a sell card (the mirror of the dig's price sticker): item, OFFER, SELL.
// Contract: LANE_C_PUB §9 — this file is the loader-exact authority (the §7 rule applies here too).
//
// THE NO-PUMP LAW IS STRUCTURAL, NOT TUNED (charter law #2): sellOffer() is strictly below what the
// player paid for every pricePaid ≥ 1, so buy-then-immediately-sell loses ≥ $1 per round trip by
// construction — F's monotonic-loss gate holds against the formula's shape, not a balance pass.
//
// Consumer pattern mirrors dig.js: C ships the interaction, F owns the in-game input hook. C consumes
// the game collection READ-ONLY — the single side-effect is onSell(item, offer); the CONSUMER credits
// the wallet and removes the item via the game API (never this file, never a splice of game state).
// DOM-only: zero draws, zero GPU resources; dispose() removes every node + listener. No game layer
// (?classic=1 / ?game=0) → the consumer never constructs/opens this, and open() itself returns false
// on an empty sellable list — zero errors, zero DOM.
export const SELL_MULT = 0.5; // the keeper pays half of what he'll sticker it at (§9.1 — he eats too)
export const SELL_DIST = 2.0; // metres from room.counter.pose (bench centre, room-local)
// The offer (§9.1). Alpha basis = pricePaid (the buy-side price the player actually faced — no guide
// bands until beta; beta swaps the basis to bandLow, the clamp survives untouched). The min(p1, …)
// clamp is the law: no retune of SELL_MULT can ever push an offer to break even. p=1 → $0 (the SELL
// button disables — the keeper won't buy what he can't sticker).
export function sellOffer(pricePaid) {
const p = Math.floor(+pricePaid || 0);
if (p <= 0) return 0;
return Math.min(p - 1, Math.max(1, Math.floor(p * SELL_MULT)));
}
// Alpha matcher (§9.2): any shop of the item's type buys it — strict equality, and FAIL-CLOSED on an
// untyped item (vacuous-gate law: a matcher that passes an untyped item would "match" everywhere).
// Per-keeper taste / haggling / pawn-buys-everything: beta — do not widen here.
export function sellableIn(shopType, item) {
return !!(item && item.type && shopType && item.type === shopType);
}
// Proximity test (§9.4) — pos is the interior camera position (room-local, the same frame the
// counter pose is published in).
export function nearCounter(room, pos, dist = SELL_DIST) {
const c = room && room.counter && room.counter.pose;
if (!c || !pos) return false;
return Math.hypot(pos.x - c.x, pos.z - c.z) <= dist;
}
// [R28 pattern] one-shot distance gain from the counter emitter — same math as dig.js's till.
function emitterGain(em) {
if (!em) return 1;
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
if (!cam) return 1;
const d = Math.hypot(cam.x - em.x, cam.z - em.z);
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
return em.floor + (1 - em.floor) * prox;
}
function ringTill(em) {
const eng = window.PROCITY && window.PROCITY.audio;
if (!eng || !eng.playSfx) return; // no engine → silent-and-happy (?mute/?noassets inside)
eng.playSfx('till', { gain: emitterGain(em) });
}
export function createSell() {
const el = (tag, cls) => { const e = document.createElement(tag); if (cls) e.className = cls; return e; };
// The buy card's manila sticker, mirrored to the LEFT (the sell side of the counter). Same
// typewriter/stamp treatment as .pcdg-panel so the two cards read as one shop's stationery.
const css = el('style'); css.textContent = `
.pcsl-panel{position:fixed;left:6%;top:50%;transform:translateY(-50%) rotate(1.1deg);width:262px;background:#f4ecd8;border:1px solid #cbbf9e;box-shadow:0 10px 26px rgba(0,0,0,.55);padding:15px 16px 14px;color:#241f18;font:14px "Courier New",monospace;display:none;z-index:61}
.pcsl-panel .sticker{float:right;width:54px;height:54px;border-radius:50%;background:#bfe3c0;border:1px solid #5a9a5e;color:#1c5e2a;font:700 18px Arial;line-height:54px;text-align:center;transform:rotate(-5deg);margin:-2px -4px 4px 10px;box-shadow:0 1px 3px rgba(0,0,0,.25)}
.pcsl-panel h3{margin:.1em 0 3px;color:#241f18;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.02em}
.pcsl-panel .ttl{color:#4c4436;font-size:13px;font-style:italic;margin:0 0 9px;line-height:1.25}
.pcsl-panel .meta{clear:both;color:#6f6553;font-size:11px;letter-spacing:.05em;min-height:1px}
.pcsl-panel .nav{display:flex;justify-content:space-between;align-items:center;margin-top:9px;color:#6f6553;font-size:12px}
.pcsl-panel .nav span{cursor:pointer;padding:0 8px;user-select:none}
.pcsl-panel .nav span:hover{color:#241f18}
.pcsl-panel button{margin-top:11px;width:100%;padding:9px;border:1px solid #5a7a5e;background:#dcead0;color:#1c5e2a;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
.pcsl-panel button:hover:not(:disabled){background:#1c5e2a;color:#dcead0;border-color:#1c5e2a}
.pcsl-panel button:disabled{color:#9a917c;border-color:#c3b99f;background:#e7dcc2;cursor:default}
.pcsl-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
.pcsl-toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#0e160e;border:1px solid #3dff8b;color:#3dff8b;padding:14px 22px;font:700 15px "Courier New",monospace;z-index:62;display:none}`;
document.head.appendChild(css);
const panel = el('div', 'pcsl-panel');
const toast = el('div', 'pcsl-toast');
document.body.append(panel, toast);
let active = false, items = [], idx = 0;
let onSellCb = null, onCloseCb = null, emitters = null, shopName = '';
let toastT = null;
const showToast = (msg) => { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); };
const esc = (s) => String(s).replace(/[&<>"]/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
const labelOf = (it) => it.artist || it.a || ''; // headline line (artist), may be empty
const titleOf = (it) => it.title || it.t || it.sku || it.slotId || 'item'; // §9.2 fallback: honest, ugly
function render() {
const it = items[idx];
if (!it) { close(); return; }
const offer = sellOffer(it.pricePaid != null ? it.pricePaid : it.price);
const head = labelOf(it) || titleOf(it);
const ttl = labelOf(it) ? titleOf(it) : '';
const paid = it.pricePaid != null ? it.pricePaid : it.price;
panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(offer)}</div>`
+ `<h3>${esc(head)}</h3>`
+ (ttl ? `<div class="ttl">“${esc(ttl)}”</div>` : '')
+ `<div class="meta">${paid != null ? 'you paid $' + esc(paid) : ''}${it.dayFound != null ? ' · day ' + esc(it.dayFound) : ''}</div>`
+ (items.length > 1 ? `<div class="nav"><span class="pv"></span>${idx + 1} of ${items.length}<span class="nx"></span></div>` : '')
+ `<button ${offer <= 0 ? 'disabled' : ''}>${offer <= 0 ? 'NOT WORTH BUYING' : 'SELL — $' + esc(offer)}</button>`;
panel.style.display = 'block';
panel.querySelector('.x').onclick = () => close();
const pv = panel.querySelector('.pv'), nx = panel.querySelector('.nx');
if (pv) pv.onclick = () => { idx = (idx + items.length - 1) % items.length; render(); };
if (nx) nx.onclick = () => { idx = (idx + 1) % items.length; render(); };
const b = panel.querySelector('button');
if (b && !b.disabled) b.onclick = () => {
// The single side-effect (§9.3): the consumer credits the wallet AND removes the item via the
// game API. false = keeper veto → card unchanged, nothing moves. C never touches game state.
const ok = onSellCb ? onSellCb(it, offer) : false;
if (ok === false) return;
ringTill(emitters && emitters.counter);
showToast(`SOLD — ${esc(head)} · +$${offer}`);
items.splice(idx, 1); // the LOCAL sellable list only, never the collection
if (!items.length) { close(); return; }
idx = Math.min(idx, items.length - 1);
render();
};
}
const onKey = (e) => { if (!active) return; if (e.key === 'Escape') { e.stopPropagation(); close(); } };
// open({ shopType, shopName, items, getCash, onSell, onClose, emitters }) → bool (§9.6).
// `items` = the game collection (read-only; a filtered COPY is carded). Nothing sellable → false,
// no card, no DOM — the fail-soft half of the classic-pure law lives here too.
function open(opts = {}) {
if (active) return true;
const src = Array.isArray(opts.items) ? opts.items : [];
const sellable = src.filter((it) => sellableIn(opts.shopType, it));
if (!sellable.length) return false;
items = sellable; idx = 0; active = true;
onSellCb = opts.onSell || null; onCloseCb = opts.onClose || null;
emitters = opts.emitters || null; shopName = opts.shopName || '';
document.addEventListener('keydown', onKey, true);
render();
return true;
}
function close() {
if (!active) return;
active = false; items = []; idx = 0;
panel.style.display = 'none';
document.removeEventListener('keydown', onKey, true);
const cb = onCloseCb; onCloseCb = null; onSellCb = null; emitters = null;
if (cb) cb();
}
function dispose() {
close();
clearTimeout(toastT);
[css, panel, toast].forEach((e) => e.remove());
}
return { open, close, dispose, get active() { return active; } };
}

View File

@ -26,6 +26,19 @@ export function createWallet(seed = 1) {
notify();
return true;
},
// [R30 §9.3] sell(item, offer) — the credit side of the counter, the exact mirror of buy(): adds the
// offer (computed by sell.js sellOffer — this file never prices) and drops the matching v0-inventory
// entry if one exists (by identity first, then title+artist). The GAME collection is NOT touched
// here — that removal is the consumer's, via the game API (LANE_C_PUB §9.3).
sell(o, offer) {
const amt = Math.max(0, Math.floor(+offer || 0));
cash += amt;
let i = inv.indexOf(o);
if (i < 0) i = inv.findIndex((x) => x.title === (o.t || o.title) && x.artist === (o.a || o.artist || ''));
if (i >= 0) inv.splice(i, 1);
notify();
return true;
},
inventory: () => inv.slice(),
count: () => inv.length,
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },

View File

@ -101,12 +101,20 @@ export function createGigState({ plan, lighting }) {
// single-venue alias retired in R15 (B swept its cross-lane readers in R14; F migrated its own tools).
const venueIds = [...new Set(gigs.map((g) => g.venueShopId))];
// one latch per venue, keyed by its night-0 gig (null → dark tonight → 'quiet')
// one latch per venue, keyed by its night-N gig (null → dark tonight → 'quiet'). N defaults to 0 —
// the pre-v7 "tonight" every consumer was built against. [Lane F R30 — SLEEP=TOMORROW] the game layer
// re-keys N to (day 1) % 7 via setWeekNight(), walking the EXISTING seeded week; no game ⇒ N stays 0
// and this file behaves byte-identically to R29.
let weekNight = 0;
const latch = new Map();
for (const id of venueIds) {
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null;
latch.set(id, createVenueLatch(tonight, lighting));
function buildLatches() {
latch.clear();
for (const id of venueIds) {
const tonight = gigs.find((g) => g.venueShopId === id && g.night === weekNight) || null;
latch.set(id, createVenueLatch(tonight, lighting));
}
}
buildLatches();
// ONE listener drives every latch — the listens-not-polls law, now fanned out across the district.
const onSegment = (e) => { if (e && e.detail) for (const l of latch.values()) l.observe(e.detail.seg); };
@ -118,6 +126,19 @@ export function createGigState({ plan, lighting }) {
const L = (id) => latch.get(id) || null;
return {
// ── the week (R30, SLEEP=TOMORROW) ────────────────────────────────────────────────────────────
// setWeekNight(n): re-key every venue's latch to its night-n gig. Fresh latches ⇒ paid stamps and
// played state reset — correct: a new night means the cover is due again (John's R12 ruling). The
// three hard-won latch laws live inside createVenueLatch, untouched. Idempotent per n (no-op when
// the night hasn't changed, so a boot-time call on night 0 changes nothing).
get weekNight() { return weekNight; },
setWeekNight(n) {
n = ((n % 7) + 7) % 7;
if (n === weekNight) return;
weekNight = n;
buildLatches();
tickAll(); // settle every fresh latch on the current segment
},
// ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
get venueShopIds() { return venueIds.slice(); },
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },

View File

@ -98,7 +98,7 @@ function applyLive(pack, live) {
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null, gigState = null, rosterOf = null,
liveBase = null, sourcingOf = null }) {
liveBase = null, sourcingOf = null, game = null }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
@ -114,6 +114,8 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
let current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
let currentAdapter = null; // [F2 §stock] ?stock=real stockAdapter for this shop (null → parody fallback)
let currentStockDay = null; // [R30 §30.3] the day salt for THIS room's stock streams (null = no rotation:
// no game, day 1, or a REAL-sourced crate — real stock never rotates)
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
@ -153,15 +155,31 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (dig.active) return;
const p = new THREE.Vector3(); bin.getWorldPosition(p);
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic pos)
// [R30 §30.3] the riffle rotates with the day: salt the bin key when this room's stock rotates
// (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7).
const dayTag = currentStockDay != null ? '@d' + currentStockDay : '';
dig.open({
seed: binSeed((currentShop && currentShop.seed) || 1, key), count: 16,
seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), count: 16,
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
shop: currentShop,
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
// [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry
// shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate);
// sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index.
getCash: wallet ? () => wallet.cash() : undefined,
onBuy: wallet ? (item) => wallet.buy(item) : undefined,
onBuy: wallet ? (o) => {
const ok = wallet.buy(o);
if (ok && game) game.recordFind(currentShop, {
type: 'record',
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined,
slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`,
title: o && (o.t || o.title), artist: o && (o.a || o.artist),
price: (o && o.price) || 0,
});
return ok;
} : undefined,
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
});
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
@ -190,6 +208,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (!best) return;
const item = best.item || {};
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
// [R30 — THE COLLECTION] record the find (§30.1): a shelf buy's type is the pack's slot type,
// which is the shop type by construction (SLOT_FOR: book→spine, toy→box — the only shelf packs).
if (game) game.recordFind(currentShop, {
type: currentShop && currentShop.type,
sku: item.id != null ? String(item.id) : undefined,
slotId: item.id != null ? undefined : `shelf#${(best.vStart | 0)}`,
title: item.title, artist: item.artist,
price: item.price || 20,
});
collapseBuyItem(mesh, best); // zero-area the bought quad in place
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
} else {
@ -276,7 +303,14 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
const gigOn = !!(gigState && gigState.onOf(shop.id));
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
// [R30 §30.3 — SLEEP=TOMORROW] the day salt for this room's stock pick streams. RUNTIME ONLY (the
// plan never sees day — A verifies the boundary). Null (= no rotation, byte-identical to pre-v7)
// when: no game layer · day 1 (the day-1 convention) · a REAL-sourced crate (the godverse manifest
// says sourcing:'real' — real stock never rotates, it's real). Mint crates and parody stock rotate.
const realSourced = !!(shop.godverseShopId && sourcingOf && sourcingOf(shop.godverseShopId) === 'real');
currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
stockDay: currentStockDay,
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
currentShop = shop;
scene.add(current.group);
@ -393,6 +427,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
current = null;
currentShop = null;
currentAdapter = null;
currentStockDay = null;
banner.style.display = 'none';
toast.style.display = 'none'; _toastT = 0;
if (doorReturn) {

198
web/js/world/save.js Normal file
View File

@ -0,0 +1,198 @@
// PROCITY Lane F — save.js [F-owned, round 30 / v7.0-alpha THE SAVE CORE]
// The persistence foundation of THE GAME (V7 charter #1). Versioned localStorage save implementing
// THE DELTA LAW: a save carries ONLY player deltas — cash, owned items, current town, day number.
// The world is NEVER saved; it regenerates from seed (the seeded-everything law's biggest dividend:
// saves are tiny and cannot corrupt the town). Contract published in LANE_F_NOTES §30.1 — B builds
// the collection UI on it, C reads it at the sell counter.
//
// LAW (classic-pure, wired from birth): this module performs ZERO storage access at module scope.
// Under ?classic=1 / ?game=0 createGame() is never called, so a classic boot touches localStorage
// exactly never — the ledger-#5 classic-purity gate measures that rather than trusting it.
//
// The wallet facade: when the game is on, PROCITY.wallet IS game.wallet — the full Lane C v0
// interface (cash/start/canBuy/buy/sell/inventory/count/onChange, buy/sell semantics mirroring
// wallet.js exactly) backed by game-owned cash, so a loaded save's cash is authoritative. The
// debit/credit seam every consumer already uses (dig onBuy, shelf buy, the cover charge, C's
// onSell) is therefore unchanged — the proven seam, new backing.
export const SAVE_SCHEMA = 'procity-save/1';
export const SAVE_KEY = 'procity-save';
// ── validation — exact and loud (the delta law's falsifiable half) ─────────────────────────────
// A corrupted/foreign blob is REJECTED WHOLE: schema string must match exactly, every field must
// check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never
// the town (world state isn't in the save, so it can't).
function validEntry(e) {
return !!e && typeof e === 'object'
&& typeof e.townKey === 'string' && e.townKey.length > 0
&& (e.shopId != null || e.godverseShopId != null)
&& ((typeof e.sku === 'string' && e.sku.length > 0)
|| (typeof e.slotId === 'string' && e.slotId.length > 0))
&& Number.isFinite(e.pricePaid) && e.pricePaid >= 0
&& Number.isInteger(e.dayFound) && e.dayFound >= 1;
}
// → { ok:true, state } | { ok:false, why }
export function validateSave(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, why: 'not an object' };
if (obj.schema !== SAVE_SCHEMA) return { ok: false, why: `schema ${JSON.stringify(obj.schema)} != ${SAVE_SCHEMA}` };
if (!Number.isInteger(obj.day) || obj.day < 1) return { ok: false, why: `day ${obj.day} (want int >= 1)` };
if (!Number.isFinite(obj.cash) || obj.cash < 0) return { ok: false, why: `cash ${obj.cash} (want finite >= 0)` };
if (typeof obj.town !== 'string' || !obj.town) return { ok: false, why: 'town missing' };
if (!Array.isArray(obj.collection)) return { ok: false, why: 'collection not an array' };
for (let i = 0; i < obj.collection.length; i++)
if (!validEntry(obj.collection[i])) return { ok: false, why: `collection[${i}] malformed` };
return { ok: true, state: obj };
}
// createGame({ townKey, startCash, storage?, onDay? })
// townKey — this boot's `${plansrc}/${town||'default'}@${seed}` (the shell builds it)
// startCash — fresh-game cash (the shell passes Lane C's seeded wallet.start(), so a fresh game
// opens with exactly the cash a pre-v7 boot did)
// storage — injectable for tests; defaults to window.localStorage (touched only from here on)
// onDay(day)— shell hook fired after sleep()/import() changes the day (gig weekNight re-key,
// wake at dawn). NOT fired for the boot-time load — the shell reads game.day itself.
export function createGame({ townKey, startCash = 0, storage = null, onDay = null } = {}) {
const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null);
let day = 1;
let cash = Math.max(0, Math.floor(+startCash || 0));
let collection = [];
// ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ────────────
function save() {
if (!store) return false;
const payload = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() };
try { store.setItem(SAVE_KEY, JSON.stringify(payload)); return true; }
catch (e) { console.warn('[procity save] write failed (playing on in memory):', e && e.message || e); return false; }
}
function adopt(state) { // state has already passed validateSave
day = state.day;
cash = Math.floor(state.cash);
collection = state.collection.slice();
}
function load() {
if (!store) return false;
let raw = null;
try { raw = store.getItem(SAVE_KEY); } catch (e) { return false; }
if (raw == null) return false; // fresh machine — fresh game, silently
let obj = null;
try { obj = JSON.parse(raw); } catch (e) { return reject(raw, 'not JSON: ' + (e && e.message)); }
const v = validateSave(obj);
if (!v.ok) return reject(raw, v.why);
adopt(v.state);
return true;
}
function reject(raw, why) { // LOUD, stashed, fresh start. The town never breaks.
console.error(`[procity save] REJECTED (${why}) — fresh start. The rejected blob is stashed at `
+ `localStorage['${SAVE_KEY}.rejected']; the world is seeded and untouched.`);
try { store && store.setItem(SAVE_KEY + '.rejected', String(raw)); } catch (e) { /* stash is best-effort */ }
return false;
}
// ── the wallet facade — Lane C's v0 interface, game-backed (semantics mirror wallet.js) ──────
const listeners = new Set();
const notify = () => listeners.forEach((fn) => { try { fn(); } catch (e) {} });
const inv = []; // session inventory (v0 semantics — covers included)
const wallet = {
cash: () => cash,
start: () => Math.max(0, Math.floor(+startCash || 0)),
canBuy: (price) => (price || 0) <= cash,
buy(o) { // mirrors wallet.js buy() exactly
const price = (o && o.price) || 0;
if (price > cash) return false;
cash -= price;
inv.push({ title: o.t || o.title || '?', artist: o.a || o.artist || '', price, band: (o.s || o.price_band || '') });
notify();
return true;
},
sell(o, offer) { // mirrors wallet.js sell() (C §9.3): credit + drop v0 entry
const amt = Math.max(0, Math.floor(+offer || 0));
cash += amt;
let i = inv.indexOf(o);
if (i < 0) i = inv.findIndex((x) => x.title === (o.t || o.title) && x.artist === (o.a || o.artist || ''));
if (i >= 0) inv.splice(i, 1);
notify();
return true;
},
inventory: () => inv.slice(),
count: () => inv.length,
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
};
const game = {
get day() { return day; },
get cash() { return cash; },
get collection() { return collection; }, // LIVE array — READ-ONLY by contract (§30.1)
townKey,
wallet,
save,
load,
// sleep(): day+1 → shell's onDay (gig weekNight = day % 7, wake at DAWN) → save. Increment-then-
// save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into.
sleep() {
day += 1;
if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
save();
return day;
},
export() {
return JSON.stringify({ schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() });
},
import(json) { // loud reject leaves CURRENT state untouched
let obj = null;
try { obj = JSON.parse(json); }
catch (e) { console.error('[procity save] import REJECTED (not JSON):', e && e.message || e); return false; }
const v = validateSave(obj);
if (!v.ok) { console.error(`[procity save] import REJECTED (${v.why}) — state untouched`); return false; }
adopt(v.state);
save();
if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
notify();
return true;
},
// ── the buy seam (interior_mode calls this after a successful wallet.buy) ──────────────────
// info: { type, sku?|slotId?, title?, artist?, price } — entry shape per §30.1. Each purchase is
// its own entry (two copies of a sku are two entries; removal is per-entry identity).
recordFind(shop, info) {
const e = {
townKey,
shopId: shop && shop.id != null ? shop.id : null,
type: (info && info.type) || null, // load-bearing: sellability is type = type (C §9.2)
pricePaid: Math.max(0, Math.floor((info && info.price) || 0)),
dayFound: day,
};
if (shop && shop.godverseShopId != null) e.godverseShopId = shop.godverseShopId;
if (info && info.sku != null) e.sku = String(info.sku);
else e.slotId = String((info && info.slotId) || 'slot#0');
if (info && info.title) e.title = String(info.title);
if (info && info.artist) e.artist = String(info.artist);
collection.push(e);
notify();
return e;
},
// ── the sell seam (C's counter: onSell ⇒ wallet.sell(item, offer) + removeFind(entry)) ─────
removeFind(entry) { // identity removal — the ONLY way collection shrinks
const i = collection.indexOf(entry);
if (i < 0) return false;
collection.splice(i, 1);
notify();
return true;
},
};
// boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.
load();
if (typeof window !== 'undefined') window.addEventListener('beforeunload', () => { save(); });
return game;
}