Compare commits
6 Commits
83ef8c9234
...
f419c1a5e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f419c1a5e2 | ||
|
|
7b7227af73 | ||
|
|
207bcffb54 | ||
|
|
369a2be23e | ||
|
|
53fcd3ca1b | ||
|
|
c8503a5a2e |
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(p−1, 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
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
160
web/js/interiors/sell.js
Normal 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(p−1, …)
|
||||
// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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; } };
|
||||
}
|
||||
@ -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); },
|
||||
|
||||
@ -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'; },
|
||||
|
||||
@ -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
198
web/js/world/save.js
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user