Compare commits

...

39 Commits
v5.1 ... main

Author SHA1 Message Date
type-two
3ef751e1e3 Fable (integrator): THE TOUR — day one to first gem, 6 frames, no staging
The v7.0 charter's money shot, driven through the REAL loop on the pinned seed: arrive hunting
(the splash mission) → the strip → the shop (rumor overhead) → THE GEM (THE VERANDAHS 'Tinny',
asking $19 against ★ RARE guide $60+ — with onboarding beat ③ visible below, walking its talk)
→ the crate → dawn day 2: '⚖ SOLD Tinny — hammer $89, $78 after fees'. Bought $19, hammered
$89: the loop, told in six frames. docs/shots/v7_tour/ + sequence README. What remains for the
v7.0 tag is deliberately NOT mine: docs freeze + John's playtest (hands before tags).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:18:13 +10:00
type-two
25cef5983e Fable (integrator): ROUND 35 CLOSED — the first five minutes land; every v7.0 SYSTEM is built
qa.sh --strict --matrix: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10 towns green. New
STRICT smoke_first_five: the fresh game hunts a roster artist and the splash names hunt + cash
· the beats walked the REAL paths (② at the crate, ③ on the buy, the send-off on the ⚖) · a
returning save gets no tutorial · a second fresh browser hunts the SAME artist (seeded, not
random) · ?game=0 shows nothing · 0 console errors. THE CROSS-ROUND CATCH: the seeded want
moved a fresh-boot invariant (wants 1, not 0) and the R33 gate went red on the FULL run while
green solo — amended to a baseline snapshot, lesson filed in §35 (a feature that moves a boot
invariant must sweep every gate that assumed it; full runs exist for exactly this). Money shot:
docs/shots/v7_beta/first_five_splash.png. Remaining for v7.0: the tour + docs freeze + THE TAG
— which waits for John's hands (the alpha precedent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:13:02 +10:00
type-two
55b984e0b0 Lane C+B R35 (v7.0 close #2): THE FIRST FIVE MINUTES — arrive hunting, three beats walk the hustle
A FRESH game (day 1 · empty crate · empty auction house) seeds ONE artist want off
rng(citySeed,'firstwant',0) × the parody roster (dig.js ARTISTS exported) — the splash names
the mission before the first step ('You're hunting HILLS HOIST — $191 in your pocket'), and
the keeper rumors point at the same hunt from shop one. Three toast-beats ride EXISTING events
(zero new systems): ① find a record shop (on the go-click) → ② riffle · mind the guide · BUY
(first digOpen) → ③ sell at a counter or ⚖ LIST and sleep (first find, via wallet.onChange) →
the send-off on the first sale-or-listing (the listener removes itself). A returning save gets
its town back, not a tutorial (fresh-detection = state, no save flag — the delta law stays
clean); ?game=0 / ?classic=1 never reach the block. #pc-toast body-mounted (the #pc-game rule):
beats fire INSIDE shops, and a SLEEP taken at a counter now shows its morning paper.
2026-07-20 20:13:02 +10:00
type-two
c9e233a7e5 Fable (integrator): ROUND 34 CLOSED — GODBAY hammers, all green; v7.0 needs only the first five minutes
qa.sh --strict --matrix: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10 towns green. New
STRICT smoke_godbay: consigned through the real crate DOM · ONE-HAMMER determinism (a frozen
pre-sleep export slept twice lands byte-identical — the seeded gavel, measured not trusted) ·
honest bounded resolution (net $19 ≤ 2×$24×0.88) · the wake toast carried the morning paper ·
the ride to melbourne hammered a listing on arrival · ?game=0 has no crate to list from · 0
console errors. Money shots in docs/shots/v7_beta/ (the dawn paper + the ⚖ crate). The full
v7 loop now closes end to end: dig the gem → keeper cash now or the gavel tomorrow → the
wantlist points at another town → the ride costs a day and hammers your listings on arrival.
Remaining for the v7.0 tag: THE FIRST FIVE MINUTES (onboarding), the tour, docs freeze — and
John's hands on the whole thing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:32:06 +10:00
type-two
e528c92fed Lane B R34: ⚖ LIST in the crate · AT AUCTION section · the morning paper rides the toasts
hud.js: every crate find row gains ⚖ (mail-in consignment — very 1996); AT AUCTION section
(non-empty only) shows what hammers tomorrow; the rebuild watcher now folds listings in.
index.html: the wake toast and the travel-arrival toast carry the drained auction news —
'🌅 day 2 · ⚖ SOLD "Cane Toad Blues" — hammer $22, $19 after fees' is the come-back-tomorrow
hook paying off at dawn.
2026-07-20 19:32:06 +10:00
type-two
b048f51640 Lane F R34 (v7.0 close #1): GODBAY — the offline seeded auction, one hammer per item, ever
godbayHammer(entry, townKey): THE ONE-HAMMER LAW — seeded by ITEM IDENTITY
(godbay:<townKey>:<sku|slotId>:<dayFound>:<pricePaid>), NEVER by resolution day: a passed-in
item re-lists to the same result forever, so there is no retry farm anywhere in the economy
(the keeper's offer is a formula, the auction is one hammer per item per life). Fixed 4-draw
stream. Rolls: 15% passed in (comes home free) · hammer uniform across the item's band
(grail's open top = 2×low; unbanded alpha finds auction around pricePaid — honest, never
invented) · 12% bidding war ×1.5–2.0 · 12% commission, net floor $1. EV ≈ 0.8× band midpoint
— UNDER the asking-price EV of in-band stock: random flipping bleeds, only FINDS profit
(charter law #2 at the auction house); the keeper's half-of-low stays the instant floor, the
gavel the bounded ceiling (2×high×0.88). Band table imported from C's bible.js and PRNG from
core/prng.js — one authority each, no mirrors.
listings: optional on procity-save/1 (entry shape + listDay ≥ 1, the pulls/wants back-compat
law). listFind (identity — the entry LEAVES the crate, it cannot also sell at a counter) ·
resolveAuctions() runs in adopt() AND sleep(), so sleep, travel, and day-moving imports all
hammer what they pass · takeAuctionNews() drains the transient morning paper (NOT saved — the
ledger is cash/collection; the paper is a toast).
2026-07-20 19:32:06 +10:00
type-two
776b10b599 Fable (integrator): ROUND 33 CLOSED — THE HUNT lands, all green; the beta's last system is in
qa.sh --strict --matrix on the final tree: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10
towns green. New STRICT smoke_wantlist_travel: ☆→★ through the real card (the save carried
{THE VERANDAHS — 'Servo at Midnight'}) · a keeper muttered the hunted artist within 2 shops
('saw one in Castlemaine just last week') · buying the wanted sleeve cleared the hunt · the
synthetic→melbourne ride cost EXACTLY one day with $179 and the find riding along · the
same-town reboot was free (the control discriminates) · the ride home charged again (day 3) ·
?game=0 built zero want DOM and muttered nothing · 0 console errors. Matrix note: town_matrix
shares one browser across towns so travel drifts the day as it walks — safe because
determinism double-boots the SAME town (free by the same-town rule); reasoned before landing.
Money shots: docs/shots/v7_beta/rumor_and_wantlist.png (the keeper's mutter over the toy
shop's 70s wallpaper + THE WANTLIST open in the crate) + want_card.png. README manual row.
v7.0-beta systems are now ALL BUILT — what remains of v7: Esc-in-person (John's hands), then
the v7.0 close (GodBay offline auctions, THE FIRST FIVE MINUTES, the tour, the tag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:01:40 +10:00
type-two
7b26e45fa0 Lane B R33: THE WANTLIST in the crate panel · the arrival toast · the build-race fix the shot found
hud.js: THE WANTLIST section in the crate (non-empty only — no header over nothing): hunted
rows, ✕ stops hunting, refresh watches wants length so an open panel follows adds/clears.
THE FIX: R32's game surface BUILT only in street update() frames — a door entered fast after
boot had no crate/bar inside at all, and the interior tickGame() refreshed a UI that never
existed. Every gate idles on the street long enough to build, so green gates missed it; the
R33 money-shot script entered faster and exposed it. tickGame() now builds-or-refreshes in
ANY mode (shots are a detector, not decoration — filed in LANE_F_NOTES §33).
index.html: game.traveled at boot → '🚌 <town> — the ride cost a day. Day N' toast (save.js
already charged the fare and re-keyed the week before the shell read game.day).
2026-07-20 19:01:40 +10:00
type-two
37773bc81d Lane F R33 (v7.0-beta wave 2): wants on the save · the keeper's rumor · TRAVEL COSTS A DAY
save.js: optional wants:[{artist?|title?|sku?}] on procity-save/1 (validated exact-and-loud,
≥1 string field per entry, cap 40, absent-when-empty — the same back-compat law as pulls).
recordWant (case-insensitive dedupe) / findWant (a want covers an item when every field the
WANT names matches) / removeWant (identity). TRAVEL in adopt(): the adopted save's town !==
this boot's townKey (town OR seed — a new seed is a new town) ⇒ day += 1, traveled = true,
prunePulls — load AND import both pay the fare; same-town adoption is FREE, so reloads and
export→import round trips keep the save-determinism gate's byte equality untouched. The 23
towns are a map you spend days crossing now, not a dropdown.
interior_mode.js: onWant/isWanted seams into dig.open; buying a matching item clears its want
('✓ off the wantlist'). THE RUMOR (charter #6 — the keeper finally gossips): seeded
xmur3('rumor:citySeed:shopId:day') → ~45% of shop entries mutter a hunted want + a real
cached town in the banner. Runtime-only stream, freshly keyed — A's rotation boundary holds;
no game / empty wantlist ⇒ the line never exists. Full persona lines stay D's for the v6
clip epoch; the banner is the keeper's voice until then.
2026-07-20 19:01:40 +10:00
type-two
ce71908bb5 Lane C R33 (v7.0-beta wave 2): ☆ WANT IT on the pull card
The wantlist grows from the card, not a search box the 90s didn't have: you pulled it, it's
dear, you'll hunt one cheaper. ☆ WANT IT → ★ ON THE WANTLIST (a stamp once wanted, not a
button). Construction-gated on opts.onWant/isWanted — ?game=0 / classic cards carry ZERO
wantlist DOM, the classic-pure pattern.
2026-07-20 19:01:40 +10:00
type-two
9aae167d64 Fable (integrator): ROUND 32 CLOSED — v7.0-beta wave 1 all green; smoke_bible + the routing amendment
qa.sh --strict --matrix on the final tree: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10
towns green. New STRICT gate smoke_bible: band law over EVERY bin off the digOffers surface
(80 offers/5 bins), the buy-everything ledger runs on THE GEM'S BIN so the finder-margin leg is
non-vacuous (bin1: 2 gems, grail asking $19 vs keeper $30; ledger closes to the cent — Σask
$333 → Σoffer $135, gems alone +$21, every non-gem strictly negative), scarcity measured
(emptied bin 0/16 on re-open AND across a full reload; 16 pulls in the save) with the ?game=0
negative control re-opening 16/16 (the detector discriminates). sell_routing amended from the
alpha inequality (credited < paid) to the exact formula (credited == min(basis−1, basis//2),
basis = bandLow-or-pricePaid) — the fence moved from the receipt to the band, which is what
charter law #2 always said; it passed on a banded standard item (+$4 on basis $8). _at_bin
grows an idx param (back-compatible). Money shots in docs/shots/v7_beta/ (the guide card + the
crate at the counter). LANE_F_NOTES §32 (incl. the JING5 fresh-checkout trap: gitignored
web/assets/models synced from m3ultra — also un-warned the validator's 15 cross-pack checks).
README manual: the guide/gem/scarcity rows. Remaining beta charter queued in the round doc:
wantlist + rumors · travel costs a day · Esc-in-person · then GodBay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:27:19 +10:00
type-two
bb30767b62 Lane B R32 (§31.6 queue 1–3): the crate lives in shops · cluster-pose spawn · dawn reads dawn
hud.js: #pc-game rides document.body (position:fixed, z 30) instead of the mode-hidden street
root — day/cash/CRATE/SLEEP are YOURS inside a shop too (the playtest's 'crate at the counter').
C toggles the crate ANYWHERE; the surface yields to the dig/sell cards and the map (they own
the screen). tickGame() exported so the interior/map frame branches keep it live (buys update
the count while you shop); dispose removes the body-mounted wrap.
index.html: the frozen spawn (-96,9,east) was tuned for the synthetic fixture — on real towns
it faced dirt (§31.6-1). Non-classic boots now spawn ON the main-street footpath door-line
(block kind 'mainstreet', both generators; every-door fallback) at the door nearest the shop
cluster's heart, facing the LONGER run of shopfronts down the strip's dominant axis. Pure plan
math, zero draws — no stream moves; ?classic=1 keeps the frozen pose forever.
lighting.js: dawnSky opt (shell passes high-cirrus, null under classic) — the DAWN segment
swaps the dome off the golden-SUNSET default that read dusk-orange (§31.6-3), and swaps back.
setSky() is now an explicit override: weather's rain/overcast dome always wins over base+dawn.
2026-07-20 18:26:56 +10:00
type-two
75e5e06288 Lane F R32 (v7.0-beta wave 1): per-day scarcity — pulls ride procity-save/1, the bin remembers
save.js: optional pulls: string[] on the SAME schema (validated exact-and-loud when present;
absent on every alpha save — old savers drop it silently, never reject; payload omits it when
empty so the alpha byte shape survives). recordPull/pullsFor seams; pruned on sleep/import —
day-tagged keys from other days are unreachable by construction (the consumer embeds the
current day), untagged keys are real-sourced and KEPT (sold means gone, tier-2 spirit at
tier 1). Cap 600 FIFO (delta law: saves stay tiny). Collection entries gain optional band
(recordFind stamps dig + shelf buys) — C's §9.1 sell basis reads it.
interior_mode.js: pull keys ${shopId}|${binKey}${pullTag}#${slot} — pullTag @d<game.day> for
rotating crates INCLUDING day 1 (the SEED stays untagged per the day-1 convention; the ledger
doesn't — a day-1 pull must not survive into day 2's identical-looking untagged key space),
'' for real-sourced (their pulls never expire). The seed input to binSeed is UNTOUCHED — A's
rotation boundary holds. Selling an item does NOT restock the bin (pulls are independent of the
collection — the leak the §30.6 filing predicted). digOffers getter = the honest-gate surface
(index/ask/band/sku) so the bible gate asserts identity, not looks.
2026-07-20 18:26:36 +10:00
type-two
f9517f14ab Lane C R32 (v7.0-beta wave 1): THE BIBLE — bands derived, gems seeded, the cards get the guide stamp
bible.js: band ranges MIRRORED from E's pipeline thresholds (record 8/25/60 · book 5/15/40 ·
toy 6/20/50) — derived in JS off the price_band labels the packs already carry, NO pack re-emit
(charter risk #3 never opens). guideText renders a RANGE ('guide $8–24' / '$60+'), never a
verdict — the card never says gem; spotting asking-under-guide is the player's skill.
dig.js: parody sleeves roll seeded bands (25/45/22/8) + a 10% gem on collector/grail only,
asking strictly under the keeper's own bandLow offer (collector $2–9 vs $12 · grail $5–20 vs
$30) — margin by construction; fixed 9-draw stream per item; rare ≡ grail (matches real packs).
Real offers carry band off price_band — and real asking always sits INSIDE its band (the band
derives FROM the price), so with the §9.1 basis swap every real round trip stays strictly
negative: the no-pump gate holds untouched, structurally. open({gone}) omits pulled slots AFTER
the draw (R27 pick-then-omit, zero collateral; o.i survives filtering — identity preserved).
currentOffers() honest surface for F's gate. sell.js: the §9.1 basis swap EXECUTED exactly as
pre-ratified — sellOffer(sellBasis(entry)) = min(bandLow−1, max(1, floor(bandLow·0.5))) when
banded; alpha-era entries (no band) keep the pricePaid basis and sell exactly as before. The
sell card carries the guide line too.
2026-07-20 18:26:15 +10:00
m3ultra
046668e5af feat(gig): club dancers play real dance clips, not procedural sway
loadPedFleet gains dance:!CLASSIC (mirrors the sit/look gate) → fleet.danceClips
= 4 mesh-free mixamorig club dances (party/medium/drink/sway). _make picks one
per dancer off a fresh gigdance seeded stream (zero impact on existing draws /
the same-seed-same-crowd covenant), spawnRig plays it, and update() drops the
fake bounce for real-clip dancers (procedural sway stays as the classic/noassets
fallback). ?classic fetches nothing, byte-identical.

Verified: default boot fetches all 4 dance GLBs (gated load runs), both modules
parse + export clean, page boots. Same clips + _canon/_rotOnly/spawnRig proven
animating in 90sDJsim (its crowd/rigs.js is this file ported).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:11:55 +10:00
m3ultra
b3394027be feat(citizens): +5 trellis DJ bodies in the ped roster
Same 5 Mixamo-rigged DJ characters as 90sDJsim, converted FBX->skinned GLB +
decimated to 4k tris (<=5k ped law) via MESHGOD scripts/fbx_to_ped.py — mixamorig:
skeleton + skin preserved. Added to PED_NAMES (rigs.js). Shares the proven bind:
identical _canon/_rotOnly + the same GLBs verified animating in 90sDJsim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 01:37:10 +10:00
m3ultra
3c5d0a1e95 Fable: THE FRONTIER gains THE VERNACULAR KIT — John's building-variety idea, source corrected (OSM/Overture footprints + parametric archetypes, never Google 3D)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:43:57 +10:00
m3ultra
67913f8b66 Fable: THE FRONTIER gains THE THIRD DIMENSION + TEXTURE LIFE — the noise ruling (semantic streams stay; noise only where continuous), terrain chartered v8+ with costs named
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:40:53 +10:00
m3ultra
bb5f36f1e0 Fable (integrator): ROUND 31 CLOSED — v7.0-alpha tagged and pushed; the playtest found what the gates couldn't
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:42:36 +10:00
m3ultra
2491f1c8ec Lane F R31 wave 2 (v7.0-alpha close): THE SHOT + the ledger. docs/shots/v7_alpha/crate_panel.png — the alpha's face: Monster Robot Party's real frontage, THE CRATE open with 3 real finds (real cover thumbs, paid/where/day honest), game bar day 1 · $116 · CRATE 3 · SLEEP, driven through the REAL loop with the dig param ABSENT (the R31 flip carries the shot), 0 console errors. LANE_F_NOTES §31 (the flip both ways, the two new gate legs + their negative control, harness lesson #4 — the explicit-flag blindfold, THE BETA QUEUE filed) + F-progress R31. Tag v7.0-alpha rides this commit, LOCAL ONLY — Fable reviews and pushes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:38:07 +10:00
m3ultra
50385c0f08 Lane F R31 wave 1 (v7.0-alpha close): THE DIG FLIP — the alpha-blocker Fable's playtest found. DIG_ON = flagOn('dig') (default-ON, ?dig=0 opt-out, ?classic=1 off — the v3.1 pattern; the v2-era opt-in survived 6 epochs past the README's 'dig on' claim because every R30 gate booted dig=1 explicitly: the explicit-flag blindfold). Fenced forever: default_boot_gate asserts dig REACHABILITY on a NAKED boot (E at a bin → digActive true), classic_regression asserts dig INERT at the bin (zero pcdg DOM, measured not assumed). PROCITY.flags gains dig. Splash de-staled ('Every door opens.' — no more '(soon)'; controls line carries E riffle/sell · C crate · SLEEP to save) + README manual row (sell at counter · C crate · SLEEP = save/advance/rotate). PROCITY_QA_PORT env override (flags_check, interior_scale_check, town_matrix) so the suite runs port-isolated instead of silently reusing whatever holds :8130. Verified both ways on :8951 (naked boot digs + closes clean; dig=0 and classic zero dig DOM, classic ZERO storage calls; noassets digs parody, 0 errors). qa.sh --strict --matrix on :8931: 7/0/0/0, flags_check 0 fails 0 warns, MATRIX 10 towns green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:37:02 +10:00
m3ultra
d96cbde004 Fable (integrator): round-31 instructions — the alpha close (wave 0: Fable plays it; then micro-fixes; then the tag)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:58:32 +10:00
m3ultra
7a7da29132 Fable (integrator): ROUND 30 CLOSED — v7.0-alpha wave 1 complete, all green, 7/0/0; the game remembers, spends, and cannot cheat
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:11:54 +10:00
m3ultra
2c9cb94b8e Lane F R30 wave 3 (v7.0-alpha ledger #5 + §9.4): THE GATES + the sell routing — E chain aimed-bin→dig · counter+sellable→C's sell card (removeFind-first onSell, veto-safe) · forgiving-bin · shelf; aim-priority amendment measured both ways (counter-adjacent bin sells, wrong-aim bin still digs); 3 shell seams (sellOpen unlock, sellActive guards). Five STRICT suite gates in flags_check: routing live-loop ($9 dig → +$4 sell → HUD $186·CRATE 0), save/load determinism (byte-equal state+export, plan fp unchanged, weekNight re-key), no-pump 5/5 strictly negative (Σ−$30) + C's negative control (wallet.buy invoked-and-false ⇒ recordFind unreachable, positive arm discriminates), classic purity (Storage-prototype instrument, 0 calls classic/game=0, positive control sees procity-save, plan fp game-on==off), rotation over VERTEX DATA (day 1/2/3 distinct, re-entry + cross-boot byte-identical, game=0==day1). qa.sh --strict --matrix: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10 towns green. NO TAG per brief.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:08 +10:00
m3ultra
5e115688ed Lane A R30 (v7.0-alpha ledger #6): THE ROTATION BOUNDARY VERIFY — HOLDS. F's day salt (207bcff) rides runtime streams only: stk-* stock-pick labels, dig binSeed, setWeekNight night-SELECTION over the plan-time week. Measured 3 ways: grep-zero over the proven citygen/** glob (8 files/2546 lines, pattern non-vacuous vs interior_mode.js); generatePlanFor consumes no day-shaped opt at any call site; day-inertness byte-measured (plan hash identical day-absent/1/37, 3 seeds x synthetic+osm, 6/6). Selfcheck ALL GREEN 156352/156352, fingerprint 0x5f76e76 frozen, all town pins green, zero re-pins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:42:54 +10:00
m3ultra
cb03f07fb2 Lane B R30 (v7.0-alpha ledger #4): THE COLLECTION UI + SLEEP surface — the crate panel in hud.js (cover thumb = pack-atlas CSS crop off the sku, per-shop/town-wide bases, parody = glyph by design; where+day honest, foreign towns fall back to townKey), game bar day/$/CRATE count + SLEEP on F's §30.1 contract; construction-gated on PROCITY.game itself (classic/game=0 ⇒ zero DOM/listeners, measured); venue.js compat venueShopId = live weekNight getter (F's §30.4 filing, 116→490 across a sleep). Verified vs F's REAL committed core (nothing stubbed) on no-store :8151 snapshot; qa 6/6 2026-07-18 14:37:15 +10:00
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
m3ultra
83ef8c9234 Fable (integrator): V7 RATIFIED + round-30 instructions (the save and the crate, wave 1) + README manual (measured controls)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:27:03 +10:00
m3ultra
e2345b544d Fable (integrator): V7 THE GAME draft charter — persistence, the collection, the gem thrill, GodBay; every system on existing rails
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:05:41 +10:00
m3ultra
bebaa66196 Fable: THE FRONTIER — the honest map of what is NOT figured out (John's challenge, answered in writing)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:58:32 +10:00
m3ultra
fe00c11d2c Fable: digalot.fyi/procity private-test deploy script — noindex/no-listing, _raw excluded, licence flag stays armed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:36:18 +10:00
m3ultra
a80ad55f47 Lane F R29 wave 0: the pose gate gains its subject — v6's first clip is in the game
R28's gate had no subject and SKIPPED with its reason printed (look.glb was published, verified,
sha1-proven off the depot — and nothing bound it). D bound it in wave 0. The gate is now
permanent, and flags_check is GREEN:

  ✓ default boot: look.glb BOUND — 6.5s, 64 tracks
  ✓ the glance REACHES THE GAME — peak 46 of 396 peds glancing · 0 console errors
  ✓ ?classic=1: look.glb NEVER FETCHED — the zero-fetch-delta covenant holds
  ✓ ?classic=1: glance inert at the source — 0 of 269 peds, AND the rng stream still draws

That last line is the design, and it is D's cleverness: THE DETERMINISM IS IN THE DRAW, NOT THE
OUTCOME. D takes from a dedicated glanceRng stream always — so the seeded sequence is identical
whether or not a clip exists — and only flips the flag when fleet.lookClip is real. Under
?classic the roll still happens, the flag never sets, nothing is fetched, the covenant stays
byte-identical. A behaviour added without touching a hash.

The gate asserts the clip's ABSENCE under classic, not merely "0 glancing" — because "0
glancing" alone passes on a fleet that failed to load at all. Present-on-default + absent-under-
classic is the pair a broken fleet cannot satisfy.

F'S OWN GATE CRASHED THE SUITE FIRST: the subject check used pg.goto() on a .glb, which starts a
download, aborts the navigation, and took flags_check down before one assertion ran. A gate that
DIES looking for its subject is worse than one that skips — it reports nothing at all and takes
the suite with it. Now it reads the disk. The vacuous-gate law has a sibling: a gate must survive
its own subject being absent.

No tag — wave 0 rides Spike 2's close, gated on John's footage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:15:20 +10:00
m3ultra
10025d4082 Lane D R29 (v6 Spike 1): look.glb bound and wired — peds glance, feet planted (wave 0)
The spike reaches the game. E's look.glb (mesh-free, 65 joints, 6.5s 'Look') is bound
through the fleet gate; street peds now glance around during window-shop stops. `lean`
is absent and never existed (E's R28 measurement). Goldens untouched — clips sit outside
every plan hash; the synthetic anchor stays R27's amended 0x5f76e76.

WIRED: loadPedFleet(base, {sit, look}) -> fleet.lookClip, the exact twin of the R16 sit
gate; the shell passes look:!CLASSIC so ?classic fetches NOTHING (a raw fetch would have
breached the zero-fetch-delta covenant — E flagged it, the gate is the answer).
makeActor gains lookA, played only on setLooking(true) — crossfaded (0.3s), not snapped
like sit, since idle->idle-variant reads naturally and there's no replant for a fade to
fight. sim.js gains a dedicated `glance` rng stream, rolled ALWAYS (deterministic) but
only flipped when a clip exists => classic inert at the source. Mutually exclusive with
bench-sit (sit rolls first at 35%, glance takes 40% of the rest). RigPool threads
lookClip; release snaps the glance off so no rig is pooled mid-look.

THE FINDING — my own prediction was wrong, and the measurement caught it. I wrote that a
STANDING clip needs no foot replant ("no hip descent to recover — that trap was sit's
alone"). Measured over the full loop: idle keeps the lowest bone within 4cm of the ground
(-0.04..0.00); look.glb ranges -0.009..+0.205 — the ped HOVERS up to 20.5cm, drifting
through the loop. Same trap, different coat: _rotOnly drops the Hips POSITION track for
rig-height independence, so the clip's authored weight-shift dip can't move the hips and
comes out as the FEET RISING. Sit's one-time replant can't fix it — the float varies per
frame.

FIX: plantFeet() — the per-frame twin of sit's replant. Reset to bind plant -> measure
posed feet -> drop inner by float/scale. ABSOLUTE (reset->measure->correct), never
incremental, so it cannot drift. Reproduces the authored motion exactly: the hips dip
relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones
cached once; cost paid only by glancing peds, on frames their mixer ran (both mixer sites
— the drummer's post-mix lean taught that coupling rule).

PROOFS: foot float 0.205m -> 0.000m (pinned); head yaw sweep span 1.735 (-0.85..+0.89) so
the glance still animates; ?classic — no look fetch, no Look action on any rig, glance
never flips (0); identity signature unchanged, no glance term; sit AND glance never
simultaneous (0); 0 NaN; goldens 0x5f76e76 untouched. A glancing ped is NOT tagged
seated/posed — standing pose at standing stature, so F's no-giants gate needs no arm.

D->F: the pose gate HAS ITS SUBJECT — un-SKIP it. Fresh ?localdepot=1 boot shows a ped
glancing on the footpath, head sweeping, feet planted.
Spike 2 readiness: plantFeet() is the general machinery the brief asked for ("data, not
new code") — riffle and wall-lean should land as clip + roll; the lean's wall-contact
placement is the only genuinely new part.
FLAGGED (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays
0x3fa36874" while line 267 pins the amended 0x5f76e76. A stale comment, not a rule — but
the same species as the epoch's five holds, so: flagged, not touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:44:07 +10:00
m3ultra
09dff6b788 Fable (integrator): round-29 instructions — the v6 spikes (wave 0 now, the rest gated on John's footage) + charter amended (lean doesn't exist)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:13:01 +10:00
65 changed files with 4267 additions and 82 deletions

View File

@ -4,6 +4,25 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser. `generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 30 (v7.0-alpha ledger #4) — THE COLLECTION UI + SLEEP surface · qa GREEN 6/6
The crate panel + game bar in `hud.js` (my DOM-overlay patterns), on F's §30.1 `PROCITY.game`
contract — and verified against F's REAL committed core (`207bcff` landed mid-round; my test
snapshot diffed byte-identical to it — **nothing stubbed**, unlike C's wave-1 run).
- **Game bar** (bottom-right): `day N · $cash` · `CRATE n` (count in the standing HUD; opens the
panel, `C` toggles) · `☾ SLEEP``game.sleep()`, enabled any time (alpha).
- **Crate panel**: cover thumb (pack-atlas CSS crop off the entry's sku — per-shop godverse base or
town-wide; parody = type glyph by design), title/artist, `paid $p · where · day d`, newest first,
header totals. 50-row rebuild 0.9 ms; 0 draws (DOM-only).
- **Construction-gated on the game object itself** (lazy, R19-selector style): `?classic=1`/`?game=0`
⇒ zero game DOM, zero listeners, help line byte-identical, 0 console errors (measured, clean tabs).
`?noassets` ⇒ 0 stock fetches, placeholders stand.
- **SLEEP proven end-to-end**: day 1→2, weekNight 0→1, wake DAWN seg 0, `procity-save/1` on disk,
next boot loads it and the UI reads it. **venue.js §30.4 filing fixed as filed** — compat
`venueShopId` is now a live weekNight-keyed getter (116 → 490 across a sleep, measured).
- Full measurement table + honest notes: LANE_B_NOTES §30. Verified on a port-isolated no-store
:8151 snapshot server (not :8130/:8144 — Lane F was mid-flight in the shared tree).
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + tram (`?tram=1`) · qa GREEN ## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + tram (`?tram=1`) · qa GREEN
Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical. Ship-v2.0 round. Two deliverables, both v2-law clean, `qa.sh --strict` GREEN, flags-off byte-identical.

View File

@ -3,11 +3,71 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens *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.* 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. 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,159 @@
--- ---
## Round 35 (v7.0 close, wave 2) — THE FIRST FIVE MINUTES (Fable, solo, JING5)
A fresh game arrives HUNTING: one seeded artist want (`rng(citySeed,'firstwant',0)` × the
parody roster) named on the splash with the pocket money ("You're hunting HILLS HOIST — $191
in your pocket"), pointing the rumors and the tour at the same target. Three toast-beats walk
the hustle off existing events (walk-in → digOpen → first find → first sale-or-listing, then
the listener removes itself). Returning saves get no tutorial; ?game=0/classic never reach it.
#pc-toast body-mounted so beats and morning papers surface inside shops too. STRICT
`smoke_first_five` green (roster want + splash · real-path beats · returning/?game=0 controls
· cross-browser seed determinism). Full record: LANE_F_NOTES §35.
---
## Round 34 (v7.0 close, wave 1) — GODBAY: the offline auction (Fable, solo, JING5)
⚖ LIST from the crate (mail-in consignment) → `listings` on procity-save/1 → resolves the day
after, wherever the day moves (sleep, travel, imports — adopt() and sleep() share the
resolver). THE ONE-HAMMER LAW: identity-seeded (never day-seeded) — a passed-in item re-lists
to the same result forever, no retry farm. 15% pass · uniform-across-band hammer (grail 2×low)
· 12% war ×1.52 · 12% fees, net floor $1; EV ≈ 0.8×mid so random flips bleed and only finds
profit; keeper = floor, auction = bounded ceiling. The morning paper rides the wake/arrival
toast ("⚖ SOLD 'Cane Toad Blues' — hammer $22, $19 after fees"). Band table imported from C's
bible.js (one authority), PRNG from core. STRICT `smoke_godbay` green (real-DOM consign ·
byte-identical double-sleep · bounded honest resolution · travel hammers · game=0 control).
Full record: LANE_F_NOTES §34.
---
## Round 33 (v7.0-beta wave 2) — THE HUNT: wantlist · rumor · travel (Fable, solo, JING5)
☆ WANT on the pull card → `wants` on procity-save/1 (optional, capped, deduped); buying a
matching item clears the hunt; THE WANTLIST section in the crate panel. The keeper gossips:
seeded 45% banner mutter naming a hunted want + a real cached town. TRAVEL COSTS A DAY:
adopting a save from another townKey (town OR seed) charges +1 day in save.js adopt() — load
and import both pay, same-town stays free (byte-equality gates untouched); arrival toast names
the fare. New STRICT `smoke_wantlist_travel` (real-card want · rumor scan · auto-clear ·
+1-day with intact cash/collection · same-town control · return fare · ?game=0 zero-DOM).
Full record: LANE_F_NOTES §33.
---
## Round 32 (v7.0-beta wave 1) — THE BIBLE + scarcity + the §31.6 queue (Fable, solo, JING5)
Guide bands derived in `js/interiors/bible.js` (E's thresholds mirrored, no pack re-emit) →
the pull/sell cards carry the **guide stamp**; parody bins roll seeded bands + 10% collector/
grail **gems** (asking under the keeper's bandLow offer — margin by construction); §9.1 sell
basis swapped to bandLow exactly as pre-ratified (alpha entries keep the pricePaid basis).
**Scarcity:** optional `pulls` on procity-save/1 — a bought dig slot stays gone all day
(pick-then-omit, zero collateral), pruned on sleep; real-sourced pulls never expire. §31.6
queue: cluster-pose spawn (main-street doors, classic keeps the frozen pose) · THE CRATE +
game bar live in interiors (C anywhere, yields to the cards) · DAWN dome swaps to high-cirrus
(weather override wins). **Gates:** new STRICT `smoke_bible` (band law over every bin ·
ledger closes to the cent on the GEM's bin, Σask $333 → Σoffer $135, gems +$21 · emptied bin
0 across reload · `?game=0` control re-opens 16/16) + sell_routing amended to the exact
formula (the fence moved from the receipt to the band). Full record: LANE_F_NOTES §32.
---
## Round 31 (v7.0-alpha CLOSE) — THE DIG FLIP + the tag
**Fable's wave-0 playtest found the one alpha-blocker: the dig was default-OFF on a plain boot.**
`index.html` still carried the v2-era opt-in (`params.has('dig')`) six epochs after the README
claimed "dig on" — and every R30 game-loop gate booted `dig=1` explicitly, so the suite was green
while E at a bin did nothing on the boot every player gets. Fixed with the one-line flip
(`DIG_ON = flagOn('dig')` — ?dig=0 opt-out, ?classic=1 off, `dig` on the flags surface) and fenced
with two new STRICT legs: **default_boot_gate asserts dig REACHABILITY on a NAKED boot** (E at an
aimed bin → digActive true → closed clean) and **classic_regression asserts dig INERT at the bin**
(zero `pcdg-*` DOM, zero Storage calls — measured, not assumed). *Harness lesson #4: the
explicit-flag blindfold — a gate that force-feeds its subject's flag can never notice the default
is broken; every claimed-default-on flag needs at least one flag-ABSENT gate.* Splash de-staled
("Every door opens." — the "(soon)" is over; controls line = E riffle/sell · C crate · SLEEP to
save) + the README manual row. `PROCITY_QA_PORT` override so the suite never silently reuses
whatever holds :8130 (this round: Fable's own preview server).
**The re-gate (final tree, :8931): `qa.sh --strict --matrix` 7 passed · 0 failed · 0 warn ·
0 skipped; flags_check 0/0 incl. both new legs; MATRIX 10 towns green.** Money shot:
`docs/shots/v7_alpha/crate_panel.png` — Monster Robot Party's frontage, THE CRATE with 3 real
finds (real cover thumbs, honest paid/where/day), game bar day 1 · $116 · CRATE 3 · SLEEP, driven
through the real loop with the dig param absent. **Tag `v7.0-alpha` — annotated, LOCAL ONLY**
(Fable pushes). THE BETA QUEUE (Fable's design wants, filed NOT fixed) is in LANE_F_NOTES §31.6.
Commits: `50385c0` (flip + gates + splash) · shot + notes commit (tag target). Full record: §31.
---
## 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.
### Wave 3 — THE GATES SESSION: the routing landed, five STRICT gates, the loop closes end-to-end
**The held join is in.** E inside a shop now routes: **aimed bin → dig · near counter with sellables →
C's sell card · forgiving nearest-bin → dig · shelf-buy** (`interior_mode.js`; 3 marked shell seams in
`index.html``procity:sellOpen` unlock + `sellActive` on the Esc/relock guards, the dig's exact
contract). `onSell` does F's two §9.3 halves in the safe order: `removeFind` first (veto ⇒ nothing
moves), credit second. **The loop, measured through the real DOM:** dig pull **$9 debited** → walk to
the counter → E → card → SELL **+$4** (< $9, the clamp) → collection 1→0 → street HUD **$186 ·
CRATE 0**. One amendment filed with its measurement: the old nearest-bin fallback ignored aim and would
have swallowed E at any counter near a bin — aimed-raycast now outranks the sell trigger, the
forgiving pick runs after it; both branches proven live (Moe Vinyl counter: card opens; Kelpie Sound
bin, wrong aim: dig opens).
**The gates (ledger #5), all landed as STRICT suite smokes in `flags_check.py` — no more session-local
instruments:** (a) **save/load determinism** — scripted buy 2/sell 1/sleep 2× via B's real button →
export → fresh browser → import **byte-equal** (state 243 B, normalized export 269 B), plan fp
`0xb1c7843e` **unchanged** across the import, weekNight re-keyed 2; (b) **no-pump v0** — 5 real
UI round trips **all strictly negative**$30), plus **C's negative control**: broke player,
`wallet.buy` invoked-and-false on the real shelf path ⇒ **recordFind unreachable, zero entry** (dig BUY
disables outright), with a positive arm proving the control discriminates; (c) **classic purity**
Storage-prototype instrument pre-page-script: `?classic=1`/`?game=0` **0 calls, 0 game DOM ids**,
positive control sees the default boot's `procity-save` read + all 7 ids, plan fp game-on == game-off;
(d) **rotation determinism over VERTEX DATA** (the wave-3 binding) — day 1/2/3 fps all distinct,
re-entry byte-identical each day, **cross-boot byte-identical off the persisted save**, `?game=0` ==
day 1 (88,320 hashed bytes / 20 stock meshes printed — the subject provably present). Harness bug #3
caught en route: the HUD is hidden in interiors by design; the gate read it on the street.
**NO TAG** — per the brief, the alpha tags when it plays (Fable's call, likely R31). Full record:
`LANE_F_NOTES` §30.6.
---
## Round 26 (v5.0-beta — EVERY CRATE DIFFERENT) — F: the manifest consumed, the tolerance retired ## 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 **Every crate is different, and F measured it rather than taking G's word.** Not by entering one lucky

View File

@ -14,9 +14,36 @@ cd web && python3 -m http.server 8130
# http://localhost:8130/map.html (2D plan debugger — Lane A) # http://localhost:8130/map.html (2D plan debugger — Lane A)
``` ```
## The manual — how to play (private test)
Click **START** to lock the mouse. Then:
| input | does |
|---|---|
| **W A S D** + mouse | walk / look (first person) |
| walk into a shop door | enter the interior (open shops only — check the hours tooltip) |
| **E** (inside a shop) | open the crate at a record bin — then it's cursor-driven: click sleeves to riffle, click BUY |
| **E** (at the counter) · **C** (anywhere) · **SLEEP** (bottom bar) | sell a find to the keeper (he pays under the **guide band**, never over) · open THE CRATE — your collection, in shops too now · sleep = save, the day advances, stock rotates |
| read the **guide** stamp on every price card | the 90s price-guide band ("guide $824"). An item **asking under its band is a gem** — buy it, sell it to a keeper, pocket the margin. Bought slots stay gone for the day (the bin remembers) |
| **☆ WANT IT** on a pull card | start hunting it — keepers gossip ("saw one in Castlemaine"), and buying one clears the hunt. **Switching towns costs a day** — the 23 towns are a map now, not a dropdown |
| **⚖ LIST** in the crate | consign a find to **GodBay** — the auction hammers overnight (sleep or ride somewhere). One hammer per item, ever: keeper cash now, or the gavel tomorrow |
| click a book spine / toy box | pull-and-buy card (real price stickers in godverse shops) |
| walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
| **M** | 2D town map (street only) |
| **[** / **]** | step time of day back / forward · **T** pauses the clock |
| **P** | capture screenshots (street) |
| HUD dropdown (top-left) | switch towns — synthetic, fixtures, 23 real AU towns, godverse |
Worth trying: `?classic=1` (the original v2 town, byte-identical forever) · `?noassets=1`
(zero-network boot) · `?stock=real` in a godverse town (Monster Robot Party's actual crate) ·
`?seed=<n>` (any number = a different town). Night is when the gigs happen — step time with `]`.
## Read first ## Read first
- **`docs/CITY_SPEC.md`** — the treaty: schema, units, seeds, budgets, file ownership. - **`docs/CITY_SPEC.md`** — the treaty: schema, units, seeds, budgets, file ownership.
- **`docs/V7_THE_GAME.md`** — the CURRENT epoch (ratified): persistence, the collection, the
gem thrill. `docs/V6_LIVING_STREET.md` runs in parallel (mocap NPCs, footpaths, cars).
- **`docs/THE_FRONTIER.md`** — the honest map of what is not figured out yet.
- **`docs/RESEARCH.md`** — what the parent repos already solved, with exact paths to port from. - **`docs/RESEARCH.md`** — what the parent repos already solved, with exact paths to port from.
## The lanes (parallel Opus 4.8 sessions) ## The lanes (parallel Opus 4.8 sessions)

View File

@ -1,9 +1,20 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 227 (incl. wave 5) closed**. *Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 230 closed**.
R27 w5 THE FACADE FIX: every town — synthetic included — had its shopfronts facing away from the street R30 (v7.0-alpha ledger #6): the rotation boundary VERIFIED — F's `day` salt rides runtime streams only,
since **v1**. Fixed in all four ry sites; **51 goldens re-pinned**; the classic covenant amended on John's plan generation structurally cannot receive it; all goldens frozen, zero re-pins.*
ruling (`0x3fa36874 → 0x5f76e76`); a cross-convention gate added so it can never recur.*
## Round 30 (2026-07-18) — the rotation boundary verify (ledger #6, small round as briefed)
One job: prove F's day-salted stock rotation (207bcff) never reaches plan generation. **It holds.**
Three measured legs (full detail in LANE_A_NOTES §30): (1) grep-zero for day identifiers over the proven
`citygen/**` glob (8 files / 2,546 lines, census printed; pattern proven non-vacuous against
`interior_mode.js`); (2) structural absence — `generatePlanFor` consumes no day-shaped opt and neither
shell call site passes one; (3) day-inertness byte-measured — plan hash identical with `{day, stockDay}`
hostilely injected as 1 and 37 vs absent, 3 seeds × synthetic+osm, 6/6. The salt's actual reach: `stk-*`
stock-pick stream labels + dig `binSeed` + `setWeekNight` night-selection over the plan-time 7-night week
(day picks a night, never generates one). Selfcheck ALL GREEN 156,352/156,352, fingerprint `0x5f76e76`
frozen, every town pin green. No files of mine changed except these two notes.
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX (the epoch's biggest catch) ## Round 27 wave 5 (2026-07-17) — THE FACADE FIX (the epoch's biggest catch)

View File

@ -2,6 +2,34 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 30 (2026-07-18) — THE ROTATION BOUNDARY VERIFY (ledger #6): **HOLDS**
### → Fable / F: `day` rides RUNTIME streams only — plan generation cannot see it. Measured, not read.
**Verdict: the seeded-rotation boundary HOLDS.** F's `day` salt (207bcff) touches exactly three runtime
seams and nothing upstream: (1) `interiors.js:178` `ctx.stockSalt` from `opts.stockDay`, consumed ONLY in
`layout.js:417` as a suffix on the `stk-*` stock-pick stream labels (room/fittings/audio streams untouched
`stockSalt` has exactly those two references in the tree); (2) the dig `binSeed` key `@d` tag in
`interior_mode.js` (same null-gating: no game / day 1 / real-sourced ⇒ absent); (3) `gig_state.setWeekNight`,
which only **selects** a night from `plan.gigs` — the whole 7-night week is generated at plan time
(`gigs.js:227` `for night < NIGHTS`) from citySeed streams; day picks, never feeds.
**How measured (three falsifiable legs):**
1. **Grep-zero over the proven glob:** `citygen/**` = exactly 8 files, no subdirs, 2,546 lines (census
printed, ls-verified). `grep -riE 'day|stockDay|stockSalt'` → zero identifier hits (only lexical noise:
fixture name "Heydays", shop name "Sunday Stall", "today" in comments, `daylesford_real` golden keys).
Non-vacuous: the same pattern hits `stockDay` 4× in `world/interior_mode.js`.
2. **Structural absence at the entry:** `generatePlanFor(seed, source, opts)` consumes `opts.{town, gigs,
customBands, cache, report}` only (grepped every `opts.` in citygen); both shell call sites
(`index.html:127`, `map.html:315`) pass `{town, gigs, customBands}` — no day exists to pass.
3. **Day-inertness, byte-measured:** hostilely injected `{day, stockDay}` into `generatePlanFor` anyway —
plan JSON hash identical for day-absent / day 1 / day 37 across 3 seeds × both sources (6/6 legs,
e.g. synthetic 20261990 `0xec7a2d39` all three ways). Even a rogue caller can't leak day into a plan.
**Goldens frozen:** selfcheck **ALL GREEN 156,352/156,352**, fingerprint `0x5f76e76` (the covenant),
gig golden `0xec7a2d39`, all town pins (melbourne/katoomba/silverton + real caches) green, no re-pins.
Pre-existing informational ⚠ (redhill "Empire Revival" lift-drop) unchanged, not mine, not new.
## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones ## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones
### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them** ### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them**

View File

@ -1,5 +1,64 @@
# LANE B — NOTES (measured) # LANE B — NOTES (measured)
## Round 30 (v7.0-alpha ledger #4) — THE COLLECTION UI + the SLEEP surface: **shipped, verified against F's REAL core (nothing stubbed)**
**The crate is on screen: cover thumb · title/artist · price paid · where + day found; count, day and
cash in the standing HUD; SLEEP saves and wakes tomorrow. All of it built on `PROCITY.game` exactly as
§30.1 publishes it — and by verify time none of it was a stub: F committed the save core (`207bcff`)
mid-round, and every game-layer file my test server carried is byte-identical to that HEAD (diffed,
not assumed). Lane C stubbed because F hadn't landed; B landed after F did — the wave order worked.**
### What ships (`web/js/world/hud.js` — my file, my DOM-overlay patterns; +8 lines in `venue.js`, F's §30.4 filing)
- **The game bar** (bottom-right): `day N · $cash` readout + `CRATE n` (the standing-HUD count, opens
the panel; `C` toggles it on the street) + `☾ SLEEP``game.sleep()` — enabled any time (alpha;
home-base gating is beta). The shell's `onDay` hook owns the wake toast; the bar just re-reads the
contract getters.
- **The crate panel**: newest find first; each row = cover thumb, title/artist (C's §30.1 fallback
chain title → sku → slotId), `paid $p · <where> · day d`. Header totals finds + $ spent. *Where* =
the shop's name off the plan when the entry's `townKey` is this boot's, else the town part of its
townKey (`redhill godverse`) — honest, never guessed.
- **Cover thumbs = the pack atlas, cropped in CSS.** The entry's `sku` resolves against its pack index
(per-shop `assets/stock_godverse/<godverseShopId>/` when set, else the town-wide `assets/models/`),
and the row's 44 px thumb is `background-image` + `background-size: 100/du% × 100/dv%` +
`background-position: u0/(1du)` off the item's `uv` rect (origin top-left = CSS's origin, no flip —
the WebGL V-flip in stockpack.js is exactly the transform NOT to apply here). Zero canvas, zero
texture decode beyond the browser's own; B keeps its own tiny index-only fetch cache (`base|type`,
read-only GETs — can't fight stockpack's texture cache). Parody finds (slotId, no sku) wear a type
glyph by design (§30.4); so does any missing pack / unknown sku / remote (`depot:`/http) atlas.
- **Construction-gated like the R19 selector, but on the game itself:** the HUD builds NOTHING at
`createHUD` — a lazy check (every 30th frame) builds the surface on first sight of a truthy
`window.PROCITY.game` (the shell constructs the game after the HUD). `?classic=1` / `?game=0`
`game` is null forever ⇒ **none of this DOM, the KeyC listener, or the help-line suffix ever
exists** — the gate is the game object, not a re-derivation of the flags.
- **`venue.js` — F's §30.4 filing, fixed as filed:** the compat `venueShopId` ("tonight's venue") was
frozen at night-0's gig; now a live getter keyed to `PROCITY.gigs.weekNight` (0 pre-game/day-1 ⇒
exact pre-v7 value). No consumer of the field exists in-tree today (grepped), so the getter is pure
forward-correctness for whoever reads "tonight" next.
### Measured (fresh contexts, port-isolated no-store server :8151 — NOT :8130/:8144 — serving a code
### snapshot + assets symlink, so F's mid-flight edits couldn't shift under the test; snapshot later
### diffed clean against F's landed commits)
| check | result |
|---|---|
| `?classic=1` / `?game=0` (clean tab) | `game: null` · game DOM **0/5 ids** · help line byte-identical · **0 console errors** |
| default boot | bar lazily built, `day 1 · $191` (wallet-backed start), CRATE 0 |
| 3 finds via the REAL `game.recordFind` (godverse sku · town-wide sku · parody slotId) | CRATE 3 · head `3 finds · $92 spent` · thumbs from **both** pack bases (`stock_godverse/158/`, `models/`) — real cover art on screen (screenshotted), parody = ♪ · exactly 4 fetches (2 index + 2 atlas) |
| foreign-town entry via the REAL `game.import()` | row reads `paid $25 · redhill godverse · day 1` (townKey fallback) · missing sku ⇒ placeholder, 0 errors |
| sell seam (`game.removeFind`) | 4 → 3: bar + open panel both follow within one poll (≤6 frames) |
| **SLEEP** (button) | day 1→2 · weekNight 0→1 · wake DAWN 06:30 seg 0 · `procity-save/1` on disk with day 2 + 3 entries · shell toast `🌅 day 2` · cash untouched · **`venueShopId` re-keyed 116 → 490** (the §30.4 fix, proven behaviourally) |
| next boot (same profile) | the saved game LOADS and the bar/panel read it — persistence proven from the UI side too |
| `?noassets` + a real-sku find | **0** stock/atlas fetches (asset law) · placeholder stands · 0 errors |
| cost | 50-row full rebuild **0.9 ms** · first 3-row open 2.1 ms · DOM-only = 0 draws (C proved the class; unchanged) |
**Honest notes:** (1) the four `THREE.PointerLockControls` console errors seen mid-drive are the
automation browser refusing the Pointer Lock API on my splash click — reproduced click-free at zero,
absent on every clean-tab boot; environment, not code. (2) F's splash ("walk into town") was new in
the tree mid-round and sat over my first screenshot — noted, F's surface, untouched. (3) Beta polish,
not done: foreign-town rows could carry real display names via E's towns index (the selector already
fetches it) — one map at build time, when the money-shot pass comes.
---
## Round 28 (v5.1) — ledger #2, the awning fabric ripple: **KILLED with the measurement** ## Round 28 (v5.1) — ledger #2, the awning fabric ripple: **KILLED with the measurement**
**Built it, proved it worked, measured what it was worth, and killed it. A 10×-over-driven billow — **Built it, proved it worked, measured what it was worth, and killed it. A 10×-over-driven billow —

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** — > **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: > `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. > 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 *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 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 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 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. 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

@ -6,6 +6,70 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
--- ---
## ROUND 29 — v6 SPIKE 1: `look.glb` bound and wired — peds glance (wave 0)
*PROCITY-D, 2026-07-17. The spike finally reaches the game: E's `look.glb` (mesh-free, 65 joints, 6.5 s
'Look') is bound through the fleet gate and street peds now glance around during window-shop stops. `lean`
is not here and never was — E measured the stash (R28). Goldens untouched (clips sit outside every plan
hash); the synthetic anchor is R27's amended `0x5f76e76`, unchanged by me.*
### What shipped
- **`rigs.js``loadPedFleet(base, { sit, look })`** → `fleet.lookClip`, the exact twin of the R16 sit gate.
The shell passes `look: !CLASSIC`, so **`?classic` fetches nothing** — a raw fetch would have breached the
zero-fetch-delta covenant (E flagged this; the gate is the answer).
- **`makeActor({ …, lookClip })`** → a `lookA` action **played only on `setLooking(true)`**, so walkers,
placeholders and `?classic` never advance it. `setLooking` **crossfades** (0.3 s) rather than snapping the
way sit does — idle → idle-variant reads naturally, and there's no discrete replant for a fade to fight.
- **`sim.js`** — a dedicated `glance` rng stream (`rng(citySeed,'glance',id)`), rolled **always** (deterministic)
but only flipped when a clip exists ⇒ classic inert at the source. **Mutually exclusive with the bench-sit**:
the sit rolls first at 35 %, the glance takes 40 % of what's left. `RigPool` threads `lookClip`; release
snaps the glance off so no rig is pooled mid-look.
### The finding — I predicted this needed no re-plant, and the measurement said otherwise
I wrote in my own first draft that a *standing* clip needs no foot re-plant ("no hip descent to recover —
that trap was sit's alone"). **Wrong, and the numbers caught it:**
| lowest bone over the full loop | idle clip | **look.glb, no plant** |
|---|---|---|
| min → max | 0.04 → 0.00 (span **4 cm**) | 0.009 → **+0.205** (span **21 cm**) |
The ped **hovers up to 20.5 cm** (`mixamorigLeftToe_End` clean off the ground), drifting through the loop. It
is the sit trap wearing a different coat: `_rotOnly` drops the Hips POSITION track (rig-height independence),
so the clip's authored weight-shift **dip cannot move the hips — it comes out as the feet rising instead**.
Sit's one-time replant cannot fix it because **the float varies frame to frame**.
**Fix: `plantFeet()` — the per-frame twin of sit's replant.** Reset to the bind plant → measure the posed
feet → drop `inner` by float / scale. **Absolute (reset→measure→correct), never incremental, so it cannot
drift.** It reproduces the authored motion *exactly*: the hips dip relative to the ground instead of the feet
leaving it. Scale-aware like sit's. Foot bones cached once; cost paid **only** by glancing peds, and only on
frames their mixer actually ran (both mixer sites — the drummer's post-mix lean taught me that coupling rule).
### Proofs
| | before | after |
|---|---|---|
| foot float over the loop | **0.205 m** | **0.000 m** (pinned) |
| head yaw sweep (the glance itself) | — | **span 1.735** (0.85 → +0.89) |
| `?classic`: look fetch / `Look` action / glance flips | — | **none / none / 0** — inert at the source |
| identity signature | `-1,-1#0:6:0:1.777:1.171:1:-1` | **unchanged — no `glance` term** |
| sit ∧ glance simultaneously | — | **0** (mutually exclusive) |
| NaN | 0 | **0** |
| goldens | `0x5f76e76` | **`0x5f76e76`** untouched |
A glancing ped is **not** tagged `procitySeated`/`procityPosed` — it is a standing pose at standing stature,
so F's no-giants gate needs no exemption arm for it.
### Handshakes
- **D → F:** **the pose gate has its subject** — un-SKIP it. A fresh `?localdepot=1` boot shows a ped glancing
on the footpath, head sweeping, feet planted (screenshot taken). Classic/`?noassets` untouched.
- **Spike 2 readiness:** `plantFeet()` is the general machinery the brief wanted ("data, not new code") — the
riffle and the wall-lean will land as **clip + roll**, not new pose code. The lean's wall-contact placement
is the only genuinely new thing it will need.
- **Stale line spotted (A's file, A rests):** `selfcheck.js:301` still says *"the synthetic golden above stays
0x3fa36874"* while line 267 pins the R27-amended `0x5f76e76`. A comment, not a rule — but it is the same
species as the epoch's five holds (a line outliving the case that wrote it), so: flagged, not touched.
---
## ROUND 28 — v5.1 THE SWEEP: instrument LOD — **KILLED with the measurement** (ledger #4) ## ROUND 28 — v5.1 THE SWEEP: instrument LOD — **KILLED with the measurement** (ledger #4)
*PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule: *PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule:

View File

@ -1413,3 +1413,478 @@ precious. **What ships is the sweep, and the tag says exactly that:**
**Filed for R29 (D, one opt):** `rigs.js` gains `look` the way it gates `sit`; F passes it from the shell. **Filed for R29 (D, one opt):** `rigs.js` gains `look` the way it gates `sit`; F passes it from the shell.
Then the pose gate has a subject and F will run it. **v6 has not begun in-game — the tag must not say it Then the pose gate has a subject and F will run it. **v6 has not begun in-game — the tag must not say it
has.** has.**
---
## 29. Round 29 wave 0 — **the pose gate gains its subject. v6's first clip is in the game.**
R28's gate had **no subject** and skipped with the reason printed. D bound the clip in wave 0, so the
gate binds too — and it is now permanent, not a hand-proof:
| assertion | result |
|---|---|
| default boot: `look.glb` **bound** | **6.5 s, 64 tracks** |
| **the glance reaches the game** | **peak 46 of 396 peds glancing** · 0 console errors |
| `?classic=1`: the clip | **never fetched** — the zero-fetch-delta covenant holds |
| `?classic=1`: the behaviour | **inert at the source** — 0 of 269 peds, **and the rng stream still draws** |
**That last row is the whole design, and it's D's cleverness worth naming: the determinism lives in the
DRAW, not the outcome.** D takes from a dedicated `glanceRng` stream *always* — so the seeded sequence is
identical whether or not a clip exists — and only flips the flag when `fleet.lookClip` is real. Under
`?classic` the roll still happens, the flag never sets, nothing is fetched, and the covenant is
byte-identical. Add a behaviour without touching a hash.
**And the gate asserts the clip's ABSENCE under classic, not just "0 glancing."** "0 glancing" alone
passes on a fleet that failed to load entirely — the vacuous form of this exact check. Present on the
default boot + absent under classic is the pair that can't both be satisfied by a broken fleet.
### ⚠ F's gate crashed the suite before it ran — worth recording
The first cut checked for its subject with `pg.goto('.../look.glb')`. Navigating to a `.glb` starts a
**download**, Playwright aborts the navigation, and the exception took `flags_check` down with it —
before a single assertion ran. **A gate that dies looking for its subject is worse than one that skips:
it doesn't report "no subject", it reports nothing at all, and takes the suite with it.** The
subject-presence check now reads the disk. *The vacuous-gate law has a sibling: a gate must survive its
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.
### §30.6 THE GATES SESSION (wave 3) — the routing landed, five gates wired STRICT, all green
All measured on the house harness (:8130, fresh Playwright contexts per gate — no module cache to lie).
Committed as five new STRICT smokes in `tools/flags_check.py` (`smoke_sell_routing` ·
`smoke_save_determinism` · `smoke_nopump` · `smoke_classic_purity` · `smoke_rotation`), so every future
`qa.sh` run re-proves them; the session-local instruments from §30.5 are now suite property.
**THE E-KEY SELL ROUTING (§9.4, the held join) — landed + verified live through the real UI:**
`interior_mode.js` grew `trySell()` (C's `createSell`/`nearCounter`, lazily built, only when the game is
on) and the E chain is now: **AIMED bin → dig · else near counter with ≥1 sellable → sell card · else
forgiving nearest-bin → dig · else shelf-buy.** F does both §9.3 halves in `onSell`, in this order:
`game.removeFind(item)` FIRST (false ⇒ keeper veto — no credit can exist for an item not in the
collection), then `wallet.sell(item, offer)`. Shell seams (3 lines, `index.html`): `procity:sellOpen`
`player.unlock()`, and `sellActive` guards the Esc-leaves-shop + click-relock paths (the dig's exact
contract). The full loop, measured through the real DOM: **dig pull $9 debited → entry
{type:'record', sku|slotId, pricePaid:9} → E at the counter → C's card → SELL +$4 (< $9, the clamp) →
collection 1→0 → back on the street B's HUD reads $186 · CRATE 0.** Negative legs: empty collection ⇒
E opens NOTHING (open() false, zero DOM); `?game=0` ⇒ no game, no card, zero errors.
**One routing amendment, filed with the measurement (F's own file, F's own routing):** `binUnderAim`'s
forgiving fallback picks the nearest bin within 2.5 m of the camera and **ignores aim** — at a counter
that sits near a bin it would swallow every E and the sell card could never open there. The chain now
asks aimed-raycast FIRST (the §9.4 contract's literal "aimed bin"), lets the sell trigger decline, and
only then runs the forgiving pick. Both sides proven live: at Moe Vinyl's counter-adjacent bin the card
now opens (`sell:true, dig:false` — the old chain opened the dig); by a bin away from the counter with
a sellable held and aim wrong, the dig still opens (`dig:true, sell:false` — the fallback survives).
**THE GATES (ledger #5) — each with its subject named and touched:**
| gate | verdict + numbers |
|---|---|
| (a) save/load determinism | scripted session buy 2 · sell 1 · sleep 2× (via **B's real SLEEP button**) → day 3 · $177 · 1 item → export → FRESH browser → import: state **byte-equal** (243 B) + savedAt-normalized export **byte-equal** (269 B) · plan fp **unchanged** `0xb1c7843e` across export→import (the delta law's falsifiable form) · weekNight re-keyed to 2 == (31)%7 · 0 errors both contexts |
| (b) no-pump v0 | 5 real round trips (dig UI pull+BUY → counter card SELL-all): **5/5 strictly negative, deltas [6,6,6,6,6], Σ $30** — identical deltas are the deterministic re-seeded crate, not a stuck instrument (the controls prove discrimination) |
| (b) C's negative control | broke player ($0 via import): dig BUY **disabled**, clicked anyway — zero debit, zero entry · shelf path: `wallet.buy` **INVOKED once (counted, not mocked), returned false, recordFind unreachable** — no entry, cash still $0 · positive arm: same aim with cash → debit $14 == entry.pricePaid, 1 entry — **C's pump is dead, and the control can tell** |
| (c) classic purity | Storage **prototype** instrumented before any page script: `?classic=1` AND `?game=0`**0 calls**, game null, **0 of B's 7 game DOM ids** · POSITIVE control: default boot → instrument saw the `procity-save` getItem + all 7 ids present (a zero nobody proved the instrument could see would be vacuous) · plan fp game-on **==** game-off `0xb1c7843e` (the layer moves zero plan bytes; classic's own fp differs by design — its golden is classic_regression's) |
| (d) rotation determinism | **VERTEX-DATA fp (position+uv arrays), per the wave-3 binding** — transforms measured vacuous in §30.5: day 1 `0x50edd91b` (**88,320 bytes / 20 stock meshes** — subject present, printed) · re-entry byte-identical · day 2 `0x2121f187` ≠ day 1, re-entry identical · day 3 `0x2f4a9b93` ≠ both · **cross-boot: day-3 fp byte-identical after a full reboot** off the persisted save · `?game=0` fp **== day-1 fp** (the pre-v7 town IS day 1, measured again from the suite) |
| (e) the suite | all five wired **STRICT** into flags_check (FAIL-level, house pattern) · `qa.sh --strict --matrix` full run: see §30.6 numbers below |
**Harness bug #3 of the round (recorded per house habit):** the routing gate first read B's HUD
*inside* the shop and called it stale — but the HUD is **hidden and unrefreshed in interior mode by
design** (`hud.setVisible(m === 'street')`; `gameUI.refresh()` rides the street loop). The gate now
walks back onto the street and reads it where a player could — $186 · CRATE 0, correct. Same species
as §30.5's closed-door: a verify that forgets the world's rules reads the rules as bugs.
**Design fact, filed not fixed (alpha-acceptable, for the beta ledger):** parody/mint dig stock has no
`gone` tracking — the same $11 sleeve is back in the bin on re-entry (the crate is a seeded stream, not
an inventory). Buying it again is possible **and loses money every time** (the $6/trip measurement is
exactly this); scarcity-per-day would need the save to carry per-day pull records. The no-pump clamp is
what makes the infinite crate safe. Rule it in beta with the guide bands, not here.
---
## 31. Round 31 — the v7.0-alpha CLOSE: THE DIG FLIP (wave 1) + THE TAG (wave 2)
### §31.1 The alpha-blocker (Fable's wave-0 playtest) and the flip
**The dig was default-OFF on a plain boot.** `web/index.html` still carried the v2-era opt-in
(`const DIG_ON = params.has('dig') && params.get('dig') !== '0'`) six epochs after the README's
flags table began claiming "dig on" — and every R30 game-loop gate booted `dig=1` EXPLICITLY, so
the whole suite was green while the boot every player gets had no crate-riffle at all: E at a bin
did nothing. The gates tested the game; nobody tested that the default boot IS the game.
**The flip (one line + the law):** `DIG_ON = flagOn('dig')` — default-ON, `?dig=0` opts out,
`?classic=1` master-off: the exact pattern the other flipped flags ride. `dig` joins the
`PROCITY.flags` intent surface (the gates read intent there). Measured both ways in fresh
contexts on a port-isolated server (:8951):
| boot | measured |
|---|---|
| NAKED (no params) | E at a bin → riffle **OPENED** + closed clean · `flags.dig` true · 0 errors |
| `?dig=0` | opt-out honoured — riffle does NOT open, **ZERO** dig DOM |
| `?classic=1` | dig INERT at the bin (E falls through `trySell` (null game) + shelf no-op) · ZERO `pcdg-*` DOM · **ZERO Storage-prototype calls** (instrumented) · 0 errors — the covenant untouched |
| `?noassets=1` naked | dig opens on **parody canvas** · 0 errors (existing law holds) |
*(Observation filed, pre-existing, NOT touched: `assets/gen/*.jpg` skin textures fetch under
`?noassets` — 22 fetches, fail-soft, present since v1; the dig added zero fetches to that boot.)*
### §31.2 The gate that makes the regression impossible (vacuous-gate law, turned on F's own suite)
Two new STRICT legs in `tools/flags_check.py`:
- **default_boot_gate** grows **dig REACHABILITY on the NAKED boot**: enter a record shop, E at an
aimed bin, `digActive` must go true (and close clean via WALK OUT); plus a `PROCITY.flags.dig`
intent assert. Un-flip the default again and this line goes red. Negative control: the identical
measurement on a `?dig=0` boot reads `opened:false` (pre-flight, :8951) — the detector
discriminates.
- **classic_regression** grows the **dig-INERT leg**: the old `digActive` assert was vacuously
false on the street; it is now measured AT THE BIN — E opens nothing, zero dig DOM, filed as
"measured, not assumed".
**Harness lesson #4 of the epoch (house habit): the explicit-flag blindfold.** A gate that
force-feeds its subject's flag (`dig=1` in every boot query, belt-and-braces) can never notice the
default is broken. Where a flag is CLAIMED default-on, at least one gate must exercise it with the
flag ABSENT. Now true for dig; gigs/weather/winmap/tram always had it (the default-boot gate boots
naked by construction).
### §31.3 Port isolation (small, suite-wide)
`PROCITY_QA_PORT` env override added to `flags_check.py`, `qa/interior_scale_check.py`,
`qa/town_matrix.py`. `ensure_server()` silently REUSES anything parked on :8130 — this round that
was Fable's own preview server, and a shared server is somebody else's cache semantics + somebody
else's lifecycle. The R31 re-gate rode `:8931`; default behaviour unchanged.
### §31.4 The splash (same file, same commit)
"Every door opens **(soon)**" — stale by six epochs — is gone: the tagline now sells the loop
("Every door opens. Dig the crates, sell the flips, sleep — the town remembers.") and the controls
line carries the game beats (E riffle a bin / sell at the counter · C your crate · SLEEP to save).
README manual gains the matching row (sell at the counter · C crate · SLEEP = save/advance/rotate).
### §31.5 The re-gate, the shot, the tag — RESULTS
All on the FINAL tree (commit `50385c0`), suite port-isolated on `:8931` (`PROCITY_QA_PORT` —
Fable's preview holds :8130, untouched):
- **`qa.sh --strict --matrix`: 7 passed · 0 failed · 0 warn · 0 skipped.** flags_check **0 fails /
0 warns** — both new R31 legs green in the run (`dig REACHABLE on the naked boot — E at a bin
opened the riffle (and closed clean)` · `?classic dig INERT at the bin — E opened nothing, zero
dig DOM`), all five R30 STRICT gates re-proven over the flipped tree, classic covenant fingerprint
green, no-giants green. **MATRIX GREEN — 10 towns (synthetic + fixtures + real caches) × 7 gates.**
- **The money shot: `docs/shots/v7_alpha/crate_panel.png`** — Monster Robot Party's real frontage
(redhill_godverse, `stock=real`), THE CRATE open with **3 real finds** (The Return $40 ·
First Filtration Of The Duplex Brains $15 · Blue Blackness $20 — real cover thumbs off the
per-shop atlas, every row honest: `paid $N · Monster Robot Party · day 1`), game bar
**day 1 · $116 · CRATE 3 · SLEEP**. Driven through the REAL loop with the dig param ABSENT (the
flip carries the shot): enter → E at three different bins → riffle → pull → BUY ×3 → street →
C. 0 console errors. Script: session scratchpad (`crate_shot.py`) — the PNG is the deliverable.
- **The tag: `v7.0-alpha`**, annotated, **LOCAL ONLY** on the notes commit — Fable reviews and
pushes main + tag together (treaty).
### §31.6 THE BETA QUEUE (Fable's playtest, filed NOT fixed — John's ruling queue)
1. **Spawn faces dirt/building-backs** instead of the strip — wants a cluster-pose for spawns.
2. **THE CRATE is unreachable inside shops** — C is street-only by design (hud.js); a player wants
it at the counter.
3. **Dawn sky reads dusk-orange** (cosmetic).
4. The pull card's **value hint is THE BIBLE** (chartered — the beta's guide bands).
5. Automation-only, unverified in person: **dig Esc-close under synthetic events**.
6. *(carried from §30.6)* no per-day `gone` tracking on parody/mint dig stock — scarcity-per-day
needs the save to carry pull records; rule it with the guide bands.
## §32 — R32: v7.0-beta wave 1 — THE BIBLE + scarcity + the §31.6 queue (Fable, solo round, JING5)
**The round in one line:** the guide bands land (derived, not baked), gems exist (parody bins only,
by construction), a bought slot stays gone for the day (pulls ride the save), and §31.6 items 13
are fixed. All gates green; the no-pump law survives the §9.1 basis swap structurally.
### §32.1 What shipped, by seam
- **`js/interiors/bible.js`** (C) — the band table mirroring E's pipeline thresholds
(record 8/25/60 · book 5/15/40 · toy 6/20/50), `bandRange`/`guideText`/`sellBasis`. Derivation
ruling: NO pack re-emit (charter risk #3 never opens); if E's thresholds move, bible.js moves in
the same commit.
- **dig.js** (C) — parody sleeves roll seeded bands (25/45/22/8%) + a 10% gem on collector/grail
(asking strictly under the keeper's own bandLow offer: collector $29 vs $12 · grail $520 vs
$30); fixed 9-draw stream shape per item; `rare` now ≡ grail (matches real packs). Real offers
carry `band` off `price_band`. `open({gone})` omits pulled slots AFTER the draw (the R27
pick-then-omit seam, zero collateral) — real via `'sku:<id>'`, parody via original offer index
(`o.i` survives filtering, identity preserved). The pull card + sell card gain the **guide
stamp** (`guide $824` / `$60+`) — present-fields-only, and the card NEVER says "gem": spotting
asking-under-guide is the player's skill.
- **sell.js** (C) — §9.1 basis swap executed exactly as pre-ratified: `sellOffer(sellBasis(entry))`
= `min(bandLow1, max(1, floor(bandLow·0.5)))` when banded, pricePaid basis for alpha-era
entries (they sell exactly as before — no migration needed).
- **save.js** (F) — optional `pulls: string[]` on procity-save/1 (validated loudly when present;
absent on alpha saves — old savers drop it silently, never reject). `recordPull`/`pullsFor`
seams; pruned on sleep/import (day-tagged keys from other days are unreachable by construction;
untagged = real-sourced, kept — sold means gone). Cap 600 FIFO. Collection entries gain
optional `band` (recordFind stamps dig + shelf buys).
- **interior_mode.js** (F) — pull keys `${shopId}|${binKey}${pullTag}#${slot}`; pullTag =
`@d<game.day>` for rotating crates (INCLUDING day 1 — the seed stays untagged per the day-1
convention, the ledger doesn't), `''` for real-sourced. The SEED input is untouched (A's
rotation boundary holds). `digOffers` honest surface (index/ask/band/sku) for the gate.
- **hud.js + index.html** (B/F) — §31.6-2: `#pc-game` rides document.body (position:fixed, z 30),
ticks in every mode (`hud.tickGame()` from the interior/map branches), yields to the dig/sell
cards and the map; C toggles the crate anywhere. §31.6-1: cluster-pose spawn — main-street
shop doors (block kind, both generators), stand at the door nearest the cluster heart, face the
longer run of shopfronts; pure plan math, zero draws; `?classic=1` keeps the frozen pose.
§31.6-3: DAWN swaps the dome to `high-cirrus` (lighting `dawnSky` opt, null under classic);
a weather `setSky` is now an explicit override that always wins.
### §32.2 THE GATE (`smoke_bible`, STRICT) + the R30 amendment
Scans EVERY bin of the record shop off `digOffers`: band law on all offers (in-band unless a
collector/grail gem), then runs the buy-EVERYTHING ledger on **the gem's bin** (leg is
non-vacuous by construction — 80 offers/5 bins, bin1 holds 2 gems on the pinned seed, grail
asking $19 vs keeper $30). Ledger closes exactly: Σask $333 → Σoffer $135 (gems alone +$21,
every non-gem strictly negative). Scarcity: the emptied bin re-opens at 0, STAYS 0 across a full
reload (16 pulls in the save); negative control: `?game=0` re-opens it at 16 — the detector
discriminates. 0 console errors. **R30 amendment (sell_routing):** `credited < paid` was the
alpha inequality; the gate now asserts the exact formula (`credited == min(basis1, basis//2)`,
basis = bandLow-or-pricePaid, `credited < basis`) — the fence moved from the receipt to the
band, which is what charter law #2 always said.
### §32.3 Environment note (JING5)
`web/assets/models/` is GITIGNORED build output — a fresh checkout runs stock=real/book-toy/
no-pump gates RED with 404s (the R31-green tree lives on m3ultra). Fixed here by rsync from
m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E's pipeline.
## §33 — R33: v7.0-beta wave 2 — THE HUNT (wantlist · rumor · travel) (Fable, solo, JING5)
- **save.js:** optional `wants: [{artist?|title?|sku?}]` on procity-save/1 (validated
exact-and-loud when present; ≥1 string field per entry; cap 40; absent-when-empty keeps the
alpha byte shape). `recordWant` (case-insensitive dedupe) / `findWant` (a want covers an item
when every field the WANT names matches — sku exact, artist/title by value) / `removeWant`
(identity). **TRAVEL in `adopt()`:** adopted save's `town !== townKey``day += 1`,
`traveled = true`, prunePulls — load AND import both pay the fare; same-town adoption free
(save-determinism byte-equality untouched). `game.traveled` getter → the shell's arrival toast.
- **dig.js:** the pull card's ☆ WANT line — construction-gated on `opts.onWant` (game absent ⇒
zero want DOM, the classic-pure pattern). ☆ click → ★ ON THE WANTLIST (a stamp, not a button,
once wanted).
- **interior_mode.js:** onWant/isWanted seams into dig.open; buy of a matching item removes the
want (+ '✓ off the wantlist' toast). **THE RUMOR** in the enter() banner: seeded
`xmur3('rumor:<citySeed>:<shopId>:<day>')` → 45% roll → hunted want + a town from
RUMOR_TOWNS (the repo's real cached towns, current town excluded). Runtime-only stream —
A's boundary holds; no game / empty wants ⇒ the line never exists.
- **hud.js:** THE WANTLIST section in the crate panel (non-empty only): artist/title rows, ✕
stops hunting; refresh watches wants length so an open panel follows adds/clears.
- **Gate:** `smoke_wantlist_travel` STRICT (see ROUND33_INSTRUCTIONS for the green run) — real
card path, seeded rumor scan, auto-clear, +1-day travel with same-town negative control and
the return fare, `?game=0` zero-DOM control, 0 console errors.
- **Matrix note:** town_matrix shares one browser across towns, so travel drifts the day as it
walks the matrix — safe because determinism double-boots the SAME town (free by the
same-town rule) and no matrix gate reads absolute day. Reasoned before landing, not after.
- **The shot found what the gate didn't (harness lesson #5 candidate):** R32's crate-in-shops
surface BUILT only in street update() frames — a door entered fast after boot had no game
surface inside at all, and the interior tickGame() happily refreshed a UI that never existed.
Green gates missed it because every gate's boot idles on the street long enough to build.
Fixed: tickGame() now builds AND refreshes (any mode); found by taking the money shot, which
entered the shop faster than any gate does. Shots are a detector, not decoration.
## §34 — R34: GODBAY, the offline auction (v7.0 close opens) (Fable, solo, JING5)
- **save.js:** `godbayHammer(entry, townKey)` — THE ONE-HAMMER LAW: seeded by ITEM IDENTITY
(`godbay:<townKey>:<sku|slotId>:<dayFound>:<pricePaid>`), never by resolution day — a
passed-in item re-lists to the same result forever, no retry farm. Fixed 4-draw stream. Rolls:
15% passed in (comes home free) · uniform hammer across the band (grail top = 2×low; unbanded
alpha finds auction around pricePaid — honest, never invented) · 12% bidding war ×1.52.0 ·
12% commission, net floor $1. EV ≈ 0.8×mid — under in-band asking EV, so random flipping
bleeds and only FINDS profit; the keeper's half-of-low stays the instant floor, the auction
is the gambler's ceiling (bounded 2×high×0.88). PRNG imported from core/prng.js; the band
table imported from C's bible.js (one authority — no mirror after all).
- `listings` optional on procity-save/1 (entry shape + listDay, same back-compat law).
`listFind` (identity, entry leaves the crate — can't also sell it) · `resolveAuctions()` runs
in adopt() AND sleep(): sleep, travel, and day-moving imports all hammer what they pass.
`takeAuctionNews()` drains the transient morning paper (NOT saved — the ledger is cash/
collection; the paper is a toast).
- **hud.js:** ⚖ LIST on crate find rows (mail-in consignment) + AT AUCTION section; rebuild
watches listings. **index.html:** the wake toast and the arrival toast fold the paper in.
- **Gate `smoke_godbay` (STRICT):** consign through the real crate DOM → save carries it ·
ONE-HAMMER determinism (a frozen pre-sleep export slept twice lands byte-identical) · bounded
honest resolution (SOLD net $19 on a standard record, ≤ 2×24×0.88) · the wake toast carried
"⚖ SOLD Cane Toad Blues — hammer $22, $19 after fees" · the ride to melbourne hammered a
listing on arrival · ?game=0 has no crate to list from · 0 console errors.
- G's later LIVE tier must match these laws (the ladder rule: the offline tier is the contract).
## §35 — R35: THE FIRST FIVE MINUTES (Fable, solo, JING5)
Fresh-game detection = day 1 ∧ empty collection ∧ empty listings (no flag in the save — the
delta law stays clean; a returning empty-crate player re-seeing beats is onboarding working,
not a bug). Seeded first want: `rng(citySeed,'firstwant',0)` × dig.js ARTISTS (exported R35);
guarded on empty wants so the persisted want isn't re-added. Splash gains #pc-hunt (fresh+game
only). Beats: ① on the go-click (900ms), ② on first `procity:digOpen`, ③ on first find via
wallet.onChange, send-off on first sale-or-listing (listener self-removes). #pc-toast body-
mounted (z 31) — visible in interiors/map; hud.dispose removes it. Gate `smoke_first_five`
STRICT (see ROUND35_INSTRUCTIONS). All v7.0 SYSTEMS are now built — tour + freeze + tag remain,
tag gated on John's playtest.
- **Cross-round amendment the suite caught (the reason full runs exist):** R35's seeded first
want changed the FRESH-BOOT CONTRACT (wants starts at 1, not 0) and the R33 gate's three
fresh-boot assumptions went red on the full run — green solo, red together. Amended to a
baseline snapshot (base = wants at boot; card adds base+1; the rumor may name ANY live want;
buying clears ITS want back to base). A feature that moves a boot invariant must sweep every
gate that assumed it — grep the harness for the invariant before calling a round closed.

View File

@ -0,0 +1,82 @@
# PROCITY — Round 29 lane instructions (from Fable, integrator)
Date: 2026-07-17 · **v5.1 SHIPPED** (`cdce67c`, tag on origin; qa 7/0/0, matrix 10×7, goldens
unmoved as predicted). R28 review, short: the sweep was the culture working — B and D each
**killed their item with a measurement** (the wind-sway rule twice in one round; the backlog
polish debt is now effectively drained), D's mid-round STOP saved E a pointless bake, C shipped
`audioEmitter` after measuring its own v3-era prescription inert (panner ratio 1.0000 —
bit-identical door vs stage), E shipped `look.glb` with the bytes sha1-verified BACK off the
public depot and found **`lean` does not exist** (the brief's sixth corrected premise — the
stash never had it), and F tagged with the pose gate honestly SKIPPED: **the spike never
reached the game.** Round 29 finishes Spike 1 and runs Spikes 2/3 — the v6 charter ratifies on
their results. Most of this round is GATED ON JOHN (below); wave 0 runs today.
## WAVE 0 — finish Spike 1 (runnable NOW, no dependencies)
**D:** bind and wire `look.glb``loadPedFleet(base, { look })` per the sit gate (a raw fetch
breaches the classic zero-fetch covenant, E already flagged it), a seeded glance during
window-shop loiter (own rng stream, identity signature untouched, byte-identical proofs — your
own R17/R28 pattern). **F:** the pose gate gains its subject — un-SKIP it, verify a glancing
ped in a fresh boot, spot-check one old frame. No tag for this alone; it rides Spike 2's close.
## THE GATE ON THIS ROUND — John's two unblockers (everything below waits)
1. **Film the Spike-2 shot list into MOCAPGOD's queue** (protocol: MOCAPGOD `docs/CAPTURE.md`):
- **the crate-riffle** (at the shop if you can — it's the epoch's poster clip),
- **the wall-lean** (E's R28 finding made this a *need*, not a nice-to-have — the stash
never had one),
- one free pick (busker strum, trolley push, phone check — dealer's choice).
2. **The ultra integration call**: MOCAPGOD Lane C (retarget) vs MIRPAMO's retarget — one
machine, decided by Fable-ultra before either is built twice. Nudge that session.
## SPIKE 2 — the full mocap loop (when the queue has vids)
Runs mostly on ultra (MOCAPGOD C/D lanes + the integration call), lands on m3 as clips:
- **E (m3):** receive the retargeted clips, convert/validate to the house clip form (the
look.glb checklist: mesh-free, 65 joints, bind-verified), **licence line: in-house / NC-
flagged** per the MOCAPGOD decision — the validator prints it or fails (the R25 law).
- **D:** wire the riffle to a crate-digger context and the lean to shopfront loiter (the R28
wave-0 machinery generalizes — this should be data, not new code).
- **F:** the spike's falsifiable gate: a ped performs a clip that DID NOT EXIST before John
filmed it — assert the clip's provenance chain (queue video id → BVH → clip sha) end to end,
not "a ped moves". Then tag **`v6.0-spike`** (a checkpoint tag, not an epoch tag) with the
spike report.
## SPIKE 3 — a generated body (parallel to Spike 2, ultra-heavy)
flux_local concept → hunyuan3d_mlx mesh → **MIRPAMO autorig** → walk retarget → **in-game
through the no-giants gate**. Owner: G/ultra produces; E validates + licences (CC0-equiv,
generated); D spawns it as a fleet variant behind the same opt pattern. HONESTY CLAUSE: this is
the least-proven link in the whole v6 stack — a clean failure report ("autorig quality on
generated meshes: X") is a full deliverable and shapes the charter as much as a success.
## After the spikes — the ratification
Fable amends `V6_LIVING_STREET.md` DRAFT → ratified (or re-scoped) on the spike reports, and
the v6 rounds proper get chartered: clips at scale · personas · street discipline (footpaths +
occasional cars) · bodies. **A, B, C rest this round** (A: nothing touches a plan; B/C: your
R28 kills closed your queues) — you're first up in v6 proper, where street discipline (B: cars;
A: crossings data) and persona interiors (C) are the headline tracks.
## Laws
Unchanged, plus the R28 additions to the case law: **a kill with a measurement is a full
deliverable** (two more this round — the backlog never carries a zombie again) and **a
wishlist is not an inventory** (E measured the stash instead of trusting a four-epoch-old
list; briefs cite measured stock only).
## THE EXACT RUN ORDER
0. **Now: D + F (wave 0, Spike 1 closes).**
1. **John: film the vids + nudge ultra (the gate).**
2. **When clips flow: E → D → F (Spike 2), G/ultra → E → D (Spike 3, parallel).**
3. **F: the spike gate + `v6.0-spike` checkpoint tag.**
4. **Fable: charter ratification → v6 rounds proper.**
## Standing
- **John — the two unblockers above are the whole critical path.** Everything m3-side is one
small D+F wave and then waiting on footage of you leaning on a wall. The till-sync note from
the retro stands too (no POS sale recorded since 2026-07-01).
- **Backlog after R28's kills:** tram-stop tri diet · flag retirement (policy, not urgent) ·
Option B (your hold budget) · tier-2 auto-discovery. Thinner than it has ever been.

View File

@ -0,0 +1,120 @@
# PROCITY — Round 30 lane instructions (from Fable, integrator)
Date: 2026-07-17 · **V7 THE GAME is RATIFIED** ([docs/V7_THE_GAME.md](../V7_THE_GAME.md) —
read it first, it is the epoch's truth). Round 30 opens **v7.0-alpha: THE SAVE AND THE CRATE**
— wave 1 of the smallest set that makes one town play as a complete small game. *(Housekeeping:
the phantom R30 that two lanes were spun up for never had a brief — that was Fable's miss, now
fixed. If your session finds prior R30 work anywhere, it doesn't exist; this document is R30.)*
## The ledger (v7.0-alpha wave 1)
1. **THE SAVE CORE (F — the foundation everything consumes; publish the contract FIRST).**
- `web/js/world/save.js` (F-owned): versioned localStorage save implementing **the delta
law** — cash · owned items (each = `{townKey, godverseShopId|shopId, sku|slotId, pricePaid,
dayFound}` — the sku IS the identity, per the R25/26 fences) · current town · **day
number** · schema `procity-save/1`. Save on SLEEP (see #3) and on `beforeunload`;
export/import as JSON (John moves machines).
- **The published contract** (`window.PROCITY.game`): `{ day, cash, collection[], save(),
load(), sleep(), export(), import(json) }` — B builds UI on this, C reads it at the
counter. Publish the shape in LANE_F_NOTES §30 in your FIRST commit, implementation after.
- **Laws wired from birth:** `?classic=1` → NO save layer constructed, zero localStorage
touches (the covenant stays a pure v2 boot); `?game=0` opt-out; a corrupted/foreign save
→ rejected loudly, fresh start, the town NEVER breaks (delta law: world state is not in
the save, so it can't).
2. **THE SELL COUNTER CONTRACT (C — publish first, implement second).** Walk to a keeper's
counter with items → a sell card (mirror of the buy card): item, offer price, SELL.
**Offer = below the buy-side band, structurally** (the no-pump law — C picks the multiplier,
documents it in LANE_C_PUB §9). v7.0-alpha sells at any shop of the item's type (record
shop buys records); the fancy stuff (per-keeper taste, haggling) is beta+. C consumes
`PROCITY.game.collection` read-only; the debit/credit goes through the wallet exactly like
buying (the proven seam).
3. **SLEEP = SAVE = TOMORROW (F + B).** v7.0-alpha's day-advance: the HUD gains a SLEEP action
(B's surface; enabled any time for the alpha — home-base gating is beta) → `game.sleep()`:
save, `day+1`, **stock rotation** (the parody/mint pick streams re-seed as
`rng(citySeed, shopId, 'stock', day)` — F threads `day` into the existing stock seeding;
REAL stock never rotates, it's real), gig night = `day % 7` (plugs into the existing week
schedule), wake at dawn (segment 0).
4. **THE COLLECTION UI (B — after F's contract lands).** A collection panel in hud.js (your
selector/HUD patterns): cover thumb, title/artist, price paid, where + day found; count in
the standing HUD; the SLEEP button + day/cash readout. Money-shot-grade is beta; readable
and correct is alpha.
5. **THE GATES (F, last).**
- **Save/load determinism:** play a scripted session (buy 2, sell 1, sleep 2×) → export →
fresh boot → import → assert byte-equal game state AND the world untouched (fingerprint
unchanged — the delta law's falsifiable form).
- **The no-pump gate v0:** script buy-then-immediately-sell N times → assert monotonic cash
LOSS (the spread is the house's). The full adversarial playtest-bot is beta.
- **Classic purity:** `?classic=1` → zero localStorage reads/writes (measure, don't trust),
no HUD additions, fingerprint frozen.
- **Rotation determinism:** same seed, same day → byte-identical shop stock; day N ≠ day
N+1 (actually different, not just asserted different).
- `qa.sh --strict --matrix` green throughout. **No tag** — the alpha tags when it plays
(likely R31, after the loop closes end-to-end).
6. **A — the rotation boundary verify (small).** F threads `day` into stock seeding — A
verifies it rides RUNTIME streams only: plan generation never sees `day`, all goldens
frozen, selfcheck proves it. One note, one line. **D, E, G — rest** (E's guide-band bake
and G's GodBay open in beta; D's rumors likewise. If footage lands in the MOCAPGOD queue
meanwhile, v6's Spike 2 takes priority for E/D — the epochs interleave, Fable sequences).
## Laws
The charter's five (delta · no-pump · game-money · classic-pure · seeded-rotation) + everything
inherited. No goldens move this round (rotation is runtime; A verifies the boundary). The
vacuous-gate law applies to every new gate above — each names its subject and proves it touched
it.
## THE EXACT RUN ORDER
1. **Start together: F (save core — contract in first commit) + C (sell contract first,
implementation second).**
2. **When F's contract is published: B (collection UI + SLEEP surface).** F continues
(sleep/rotation threading) in parallel — disjoint files.
3. **When F + B + C are in: F gates (#5), A verifies the boundary (#6).**
4. Fable reviews; R31 charters the rest of the alpha (or its close, if it plays).
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.
## ROUND CLOSED (Fable, at the wave-3 review) — **v7.0-alpha wave 1: COMPLETE, ALL GREEN**
Ten commits, five lane-agent sessions, zero holds, zero cross-lane incidents beyond the one
adjudicated. The loop is real and gated: dig → buy → the crate remembers → counter → E → sell
(strictly at a loss) → sleep → tomorrow rotates the mint crates and never the real one → the
save survives the reboot and the UI reads it. `qa.sh --strict --matrix` **7/0/0, MATRIX 10
towns**, selfcheck 156,352, covenant frozen, the day provably cannot enter the world.
**Fed to R31 (the alpha close — charter it after John PLAYS):** the beta rulings queue
(scarcity-per-day pull records · guide bands · per-keeper taste · foreign-town display names
via E's index) · F's harness lesson #3 (the HUD hides in interiors by design) · the design
fact that re-buying a rotated sleeve is a strict money loser (correct, filed). **The alpha
tags when it plays — John's hands on the real URL are the acceptance environment.**
## Standing
- **John — nothing blocks this round.** The deploy to digalot.fyi/procity still waits on your
VPS-access pick (grant me the ssh rule / run the script yourself) — worth doing THIS round:
testing the save loop in a real browser on the real URL is exactly the alpha's acceptance
environment. And the MOCAPGOD queue still wants your footage whenever.
- **The drive question stands:** say "run it" and Fable spawns these lanes as subagents in the
wave order above, reviews between waves, and reports once at the end.

View File

@ -0,0 +1,53 @@
# PROCITY — Round 31 instructions (from Fable, integrator)
Date: 2026-07-18 · **R30 closed all green** (7/0/0, matrix 10 towns, the loop live and gated).
Round 31 is **the v7.0-alpha CLOSE**: play it, fix the friction, tag it. Charter stage check
([V7_THE_GAME.md](../V7_THE_GAME.md)): the alpha = persistence + collection + sell-to-keeper +
sleep/day + rotation, "ONE town plays as a complete (small) game" — every system landed in R30;
what remains is the PLAYING and the honesty about what it feels like.
## WAVE 0 — THE PLAYTEST (Fable, in person)
Fable boots the game in a real browser and plays the loop AS A PLAYER, not as a gate: arrive,
find a shop, dig, buy, check the crate, sell at a counter, sleep, verify tomorrow feels like
tomorrow, reboot, confirm the town remembered. Deliverable: **the friction list** — every
"why can't I", every confusing beat, every moment the game fails to explain itself — split
into (a) alpha-blocking friction (bugs/confusion that would embarrass the tag) and (b) beta
input (design wants, John's ruling queue). Honest numbers where they apply. *John's own
playtest remains the beta charter's real input — Fable's hands are the alpha's acceptance
stand-in, and the tag notes it.*
## WAVE 1 — micro-fixes (only if wave 0 surfaces alpha-blockers)
Assigned to owning lanes as tightly-scoped agents, same treaty. Nothing speculative — only
what the playtest actually hit.
## WAVE 2 — the tag (F)
Money shot: the crate panel with real finds (the collection IS the alpha's face). Fresh
`qa.sh --strict --matrix` on the final tree. Tag **`v7.0-alpha`** (annotated, the playtest
verdict in the message), push main + tag. The README gains one line in the manual: sell at
the counter with E, sleep to save.
## ROUND CLOSED (Fable) — **v7.0-alpha TAGGED AND PUSHED** (`2491f1c`)
Wave 0: Fable played the loop end to end — dig $10 → sell $5 (the spread felt) → keep a $12
NM soul record → THE CRATE → full reload → **the town remembered** → SLEEP → day 2. Verdict:
**the loop plays.** The playtest found the alpha-blocker every green gate had missed: **the
dig was default-OFF on a plain boot** (v2-era opt-in, six epochs past the README's claim; every
R30 gate booted `dig=1` explicitly — harness lesson #4, THE EXPLICIT-FLAG BLINDFOLD: a gate
that boots its own flags cannot see a broken default). Waves 1+2: the flip (classic-safe,
fenced with a naked-boot reachability assert + negative control), the de-staled splash, the
money shot (Monster Robot's real crate, 3 real finds), qa 7/0/0 + MATRIX 10 towns on
port-isolated servers (a second fix the round forced: the suite no longer silently reuses
whatever holds :8130). **The beta queue is filed in LANE_F_NOTES §31.6** — spawn-faces-the-
strip, crate-at-the-counter, dawn sky, THE BIBLE, per-day scarcity. The beta charters on
John's own playtest whenever he walks the strip.
## Standing
- **John — the alpha's real acceptance is you:** play it (locally now, or grant the VPS ssh
and Fable ships digalot.fyi/procity same hour). Your friction list charters the beta. The
queued beta rulings await: per-day scarcity (pull records in the save) · guide bands (the
BIBLE) · keeper taste · foreign-town names via E's index.
- v6 footage: the MOCAPGOD queue is still empty; Spike 2 wakes whenever you film.

View File

@ -0,0 +1,61 @@
# PROCITY — Round 32 instructions (from Fable, integrator)
Date: 2026-07-20 · **R31 closed** — v7.0-alpha tagged and pushed; the loop plays. Round 32 opens
**v7.0-beta — THE THRILL, wave 1**: the guide bands land ([V7_THE_GAME.md](../V7_THE_GAME.md)
stage 2), scarcity rides the save, and the alpha playtest's friction queue (LANE_F_NOTES §31.6)
gets its cosmetic items. **Solo round:** John asked for direct progress; Fable executes all lanes
in one session on JING5, same treaty, same gates — lane ownership is honoured in the commit
messages, not the parallelism.
## The design rulings this round encodes (from the ratified charter + §31.6 queue)
1. **THE BIBLE derives, it is not baked.** E's band thresholds (build_stock_pack.py:88-94 —
record 8/25/60 · book 5/15/40 · toy 6/20/50) become a tiny JS mirror
(`js/interiors/bible.js`). No pack re-emit, so risk #3 (re-baked atlases orphaning owned
skus) never opens. The guide shows a BAND ("guide $2559"), never a verdict — spotting the
gem stays the player's skill.
2. **Gems live in the parody bins.** Real/mint packs derive `price_band` FROM `price`, so real
asking is always inside its band → with the §9.1 basis swap (sellOffer basis = bandLow),
every real round trip stays strictly negative and **the R30 no-pump gate holds untouched,
by construction**. Procedural stock gains seeded bands + a seeded under-band gem roll —
the only place asking can sit under the guide, which is exactly the op-shop fantasy.
3. **Scarcity is what makes the gem loop safe** (§31.6 item 6, ruled WITH the bands as filed):
a bought dig slot enters `pulls` in the save (optional field, procity-save/1 — old saves
stay valid, old loaders drop the field without rejecting) and that bin omits it after the
draw (the proven `pack.gone` pick-then-omit seam — zero collateral). A gem is profit ONCE;
tomorrow's rotation re-keys the bin. Growth is bounded by finds, not arbitrage (charter
law #2's real meaning).
4. **§31.6 items 13:** spawn becomes a computed cluster-pose facing the shop strip (classic
keeps the frozen v2 pose); THE CRATE surfaces inside interiors (C key — the §31.6
"crate at the counter" want, hidden while dig/sell cards own the screen); DAWN gets a
cool sky dome swap (classic + weather-override fenced).
## Gate additions (F)
`smoke_bible` in flags_check.py: guide stamp on the pull card (real + parody) · offer < bandLow
structurally · a seeded parody gem exists and its round trip is positive ONCE (then the slot is
gone — re-open the bin and it stays gone; reload and it stays gone) · negative control:
`?game=0` re-opens the same bin with the slot PRESENT (the detector discriminates — scarcity is
the save's doing, not the dig's). Classic purity untouched (no new module constructs under
`?classic=1` / `?dig=0`).
## ROUND CLOSED (Fable) — ALL GREEN
`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green.
`smoke_bible`: 80 parody offers across 5 bins all band-lawful; bin1 holds 2 seeded gems (grail
asking $19 vs keeper $30); the buy-everything ledger closes to the cent (Σask $333 → Σoffer
$135, gems alone +$21, every non-gem strictly negative); the emptied bin stays empty across a
full reload (16 pulls in the save) and `?game=0` re-opens it 16/16 (the control discriminates);
0 console errors. sell_routing now asserts the exact §9.1 formula and passed on a BANDED item
(standard, basis $8 → +$4). Money shots: `docs/shots/v7_beta/guide_card.png` (the guide stamp —
"1974 COUNTRY guide $824" beside the $24 sticker) + `crate_at_the_counter.png` (THE CRATE open
inside a shop, game bar riding bottom-right). Environment note: JING5's gitignored
`web/assets/models/` was absent (fresh-checkout trap, now §32.3 in LANE_F_NOTES) — synced from
m3ultra, which also un-warned the manifest validator's 15 cross-pack checks. Full record:
LANE_F_NOTES §32. Shipped: bible.js + guide stamps + parody bands/gems (C) · pulls on the save
+ gone-wiring + digOffers surface (F) · crate-in-interiors + cluster-pose spawn + dawn sky (B)
· smoke_bible + the routing amendment (F) · README/docs.
**The beta's remaining charter** (next rounds): the WANTLIST + seeded rumors (D's keepers
finally gossip) · travel costs a day (23 towns become the map) · §31.6-5 (dig Esc under real
hands) · then v7.0 = GodBay offline auctions (G) + onboarding + the tour.

View File

@ -0,0 +1,52 @@
# PROCITY — Round 33 instructions (from Fable, integrator)
Date: 2026-07-20 · **R32 closed all green** (7/0/0, matrix 10 towns — THE BIBLE + scarcity live).
Round 33 is **v7.0-beta wave 2 — THE HUNT**: the wantlist, the rumor, and travel that costs a
day ([V7_THE_GAME.md](../V7_THE_GAME.md) system #6, the last unbuilt beta system). Solo round
again (John's "keep going"), same treaty, lane ownership honoured in the commits.
## The design rulings this round encodes
1. **A want is something you SAW.** The wantlist grows from the pull card (☆ WANT IT — you
pulled it, it's dear, you'll hunt one cheaper) — not from an abstract search box the 90s
didn't have. Buying a matching item clears its want automatically (the hunt ends when
you're holding it). Wants ride the save (`wants: [{artist?|title?|sku?}]`, optional field,
same procity-save/1 back-compat law as `pulls`).
2. **The keeper finally gossips** (charter: "D's citizens finally gossip for a reason").
Seeded per (citySeed, shop, day): ~45% of shop entries mutter a hunted want + a real cached
town in the banner — flavor and a travel nudge, honest about being neither promise nor
stock lookup (town-wide packs mean any town CAN surface the record eventually). Runtime
stream, freshly keyed — no plan/golden moves. Full persona lines remain D's when the v6
clip work lands; the banner is the keeper's voice until then.
3. **Travel = the townKey changing hands.** Booting a save in a different town (different
plansrc/town/**seed** — a new seed is a new town) costs exactly one day, applied in
save.js `adopt()` so load and import both pay it, and pulls prune on the way. Same-town
reloads and export→import round trips stay free — the save-determinism gate's byte
equality survives untouched. The town selector IS the bus; the arrival toast names the
fare. The 23 towns are now a map you spend days moving across, not a dropdown.
## Gate (F)
`smoke_wantlist_travel` (STRICT): ☆→★ through the real card → the save carries the want ·
a keeper names the hunted artist within the open shops (seeded scan) · buying the wanted
sleeve auto-clears it · synthetic→melbourne costs exactly one day with cash/collection intact
· same-town reboot is FREE (the control) · the ride home costs a day again · `?game=0` builds
zero want DOM and mutters nothing.
## ROUND CLOSED (Fable) — ALL GREEN
`qa.sh --strict --matrix`: **7 passed · 0 failed · 0 warn · 0 skipped**, MATRIX 10 towns green
(the shared-context day drift travel introduces across matrix towns was reasoned through
before landing: determinism double-boots the SAME town, so no gate sees it). The new gate's
run: want carried {THE VERANDAHS — "Servo at Midnight"} · rumor after 2 shops ("🗣 THE
VERANDAHS? saw one in Castlemaine just last week") · buy cleared the want · travel day 1→2
with $179 and the find riding along · same-town free · return trip day 3 · game=0 clean ·
0 console errors. Full record: LANE_F_NOTES §33.
Also caught and fixed here: the R32 crate-in-shops surface only BUILT in street frames — a
door entered fast after boot had no game surface inside (the money-shot script entered faster
than any gate does and exposed it; tickGame() now builds-or-refreshes in any mode). Money
shots: `docs/shots/v7_beta/rumor_and_wantlist.png` + `want_card.png`.
**What remains of v7:** §31.6-5 (dig Esc in person — John's hands) · then the v7.0 close:
GodBay offline auctions (G's lane), THE FIRST FIVE MINUTES (onboarding), the tour, the tag.

View File

@ -0,0 +1,57 @@
# PROCITY — Round 34 instructions (from Fable, integrator)
Date: 2026-07-20 · **R33 closed all green** — every v7.0-beta system is built. Round 34 opens
**the v7.0 close: GODBAY, the offline auction** ([V7_THE_GAME.md](../V7_THE_GAME.md) system #4:
"list an item, a SEEDED offline auction resolves NEXT DAY — the come-back-tomorrow hook").
Offline tier FIRST per the proven ladder (G3's live rails are v7.x; this round is the seeded
brain in the game layer, G's later live tier must match these laws). Solo round (John's "keep
rolling").
## The laws (GODBAY v0 — named now, gated below)
1. **Mail-in consignment.** You list from THE CRATE (⚖ LIST on a find) — the 90s answer
(classifieds + the post). The entry leaves the collection into `listings` (you cannot also
sell it at a counter), `listDay` stamped. Resolution happens when the day moves past
`listDay` — sleep, travel, or a day-moving import all count (adopt() and sleep() both run
the resolver).
2. **THE ONE-HAMMER LAW (no retry farming).** The hammer is seeded by ITEM IDENTITY —
`godbay:<townKey>:<sku|slotId>:<dayFound>:<pricePaid>` — NOT by the resolution day. A
passed-in item re-lists to the SAME result, forever. There is no re-roll anywhere in the
economy: the keeper's offer is a formula, the auction is one hammer per item, per life.
3. **The house always eats.** Hammer rolls uniform across the band (grail's open top = 2×low);
~12% chance of a bidding war (×1.52.0); ~15% passed in (returns to the crate, free);
12% commission, net floor $1. Expected value ≈ 0.8× band midpoint — UNDER the asking-price
EV of in-band stock, so random flipping bleeds and only FINDS profit (charter law #2's
spirit at the auction house: growth bounded by finds, the keeper's instant half-of-low
remains the coward's floor and the auction the gambler's ceiling — bounded at 2×high×0.88).
4. **Delta law / classic purity unchanged.** `listings` is an optional procity-save/1 field
(same back-compat law as pulls/wants); no game layer ⇒ no listings, no DOM, no news.
5. **The morning report.** Resolution queues transient news ("⚖ SOLD 'Sunburnt' — hammer $38,
$33 after fees"); the shell folds it into the wake/arrival toast. News is NOT saved — it's
the morning paper, not the ledger (cash/collection already carry the truth).
## Gate (F): `smoke_godbay` (STRICT)
List through the real crate DOM (C → ⚖ LIST) → entry moves collection→listings, the save
carries it · sleep → resolved: listings empty, cash+net OR item returned, news queued, net
within [1, 2.2×bandHigh] when sold · DETERMINISM: a pre-sleep export imported twice sleeps to
byte-identical cash/collection both times (the seeded hammer, measured not trusted) · travel
resolves too (list → ride to melbourne → resolved on arrival) · `?game=0` builds zero ⚖ DOM ·
0 console errors.
## ROUND CLOSED (Fable) — ALL GREEN
`qa.sh --strict --matrix` on the final tree: **7 passed · 0 failed · 0 warn · 0 skipped**,
MATRIX 10 towns green. `smoke_godbay`: consigned through the real crate DOM (collection 2→1,
listDay stamped) · ONE-HAMMER determinism (a frozen pre-sleep export slept twice → byte-
identical) · honest bounded resolution (SOLD net $19 on a standard record, ≤ 2×$24×0.88) ·
the wake toast carried "⚖ SOLD 'Cane Toad Blues' — hammer $22, $19 after fees" · the ride to
melbourne hammered a listing on arrival · `?game=0` has no crate to list from · 0 console
errors. Design note vs the brief: the band table is IMPORTED from C's bible.js and the PRNG
from core/prng.js (one authority each — the drafted mirror died in review). Money shots:
`docs/shots/v7_beta/godbay_morning_paper.png` (dawn day 2, the paper in the toast, the crate's
⚖ row) + `godbay_at_auction.png`. Full record: LANE_F_NOTES §34.
**What remains of v7.0:** THE FIRST FIVE MINUTES (onboarding — arrive with a want, three
toast-beats) · the tour ("day one to first gem") · docs freeze · the tag. Then John's playtest
charters what's next.

View File

@ -0,0 +1,36 @@
# PROCITY — Round 35 instructions (from Fable, integrator)
Date: 2026-07-20 · **R34 closed all green** (GODBAY hammers). Round 35 is **THE FIRST FIVE
MINUTES** ([V7_THE_GAME.md](../V7_THE_GAME.md) system #7 — "smallest system, biggest
stranger-facing payoff"), the last unbuilt v7.0 system before the tour/freeze/tag.
## What ships
1. **You arrive with a want.** A FRESH game (day 1, empty crate, empty auction house) seeds ONE
artist want off `rng(citySeed,'firstwant',0)` × C's parody roster (ARTISTS now exported) —
so the splash, the keeper rumors, and the tour all point at the same hunt on a given seed.
The splash names it: "You're hunting HILLS HOIST — $191 in your pocket." A returning save
gets its town back, not a tutorial; ?game=0 / classic never reach the block.
2. **Three toast-beats walk the hustle** (fresh games only): ① find a record shop / E at a
crate (on walk-in) → ② riffle · mind the guide · BUY (first digOpen) → ③ sell at a counter
or ⚖ LIST and sleep (first find banked) → the send-off ("that's the whole hustle") on the
first sale-or-listing. Driven off existing events + wallet.onChange — zero new systems.
3. **Toasts surface in every mode**#pc-toast moved to document.body (the #pc-game body-mount
rule): beats fire INSIDE shops, and a SLEEP taken at a counter now shows its morning paper.
## Gate (F): `smoke_first_five` (STRICT)
Fresh game: 1 seeded want from the roster + the splash names hunt AND cash · beats ②/③/send-off
through the REAL paths (E at crate / dig buy / crate ⚖) · a returning save gets NO tutorial
(splash line hidden, no ② on dig) · a second fresh browser hunts the SAME artist (seeded, not
random) · ?game=0 shows nothing · 0 console errors.
## ROUND CLOSED (Fable) — ALL GREEN
`qa.sh --strict --matrix`: 7 passed · 0 failed · 0 warn · 0 skipped, MATRIX 10 towns green
(final tree). Gate run: hunts HILLS HOIST with $191, beats walked through the real dig/buy/list
paths, returning-save and ?game=0 controls clean, second fresh browser hunts the same artist,
0 console errors. Money shot: `docs/shots/v7_beta/first_five_splash.png`.
**v7.0 remaining:** the tour ("day one to first gem") · docs freeze · the tag — and the tag
waits for John's playtest (the alpha precedent: hands before tags).

116
docs/THE_FRONTIER.md Normal file
View File

@ -0,0 +1,116 @@
# PROCITY — THE FRONTIER (what is genuinely not figured out)
*Fable (m3), 2026-07-17. John's challenge, verbatim: "there is heaps more to figure out — all
the systems are set you say?" He's right. This doc is the honest map: what 29 rounds built is
the WORLD and the PIPELine; the GAME — the reason a stranger would play for an hour — is
mostly unfigured. Nothing here is chartered; it's the space the next epochs get picked from.
Sibling docs: V2_IDEAS (the old parking lot), V6_LIVING_STREET (chartered, in flight).*
## What is actually solid (don't re-litigate)
Procedural + real towns · enterable interiors · citizens with identity · gigs · weather/tram ·
the dig with real/mint/parody stock · the tier ladder + offline law · the two-studio pipeline ·
the QA culture. The platform is genuinely done-ish. Everything below stands ON it.
## THE BIG ONE — it isn't a game yet
You walk, you dig, you buy. Then you close the tab and it never happened. The whole loop that
makes digging MEAN something is unbuilt:
1. **Persistence.** No saves. The wallet resets, the collection vanishes, "gone this session"
is the only memory. (localStorage fits the seeded-everything law trivially — the world
regenerates from seed; only the DELTA needs saving: cash, owned items, day. This is days of
work, not an epoch, and it unlocks everything below.)
2. **The collection.** You buy records and they go... nowhere. No crate at home, no album/
shelf UI, no "look what I found in Katoomba." The collection IS the trophy case of the
whole game and it does not exist.
3. **The thrill loop.** thriftgod's `mint()` gem bands exist as DATA (price bands, gems,
CBD-distance curation) and drive NOTHING. No underpriced gem in a dusty crate, no "is this
worth more than they're asking", no sell/flip loop, no wantlist hunting across towns. The
entire reason op-shopping is exciting in real life is un-simulated.
4. **Why today matters.** No day structure — no opening hours pressure (hours exist! nothing
makes you race them), no end-of-day, no "the gig starts at 9, the shops shut at 5, choose."
The clock is scenery.
5. **The player.** No hands, no home, no inventory UI beyond a number, no character. Who are
you in this town?
6. **Onboarding.** A stranger at digalot.fyi/procity gets pointer-lock and silence. The first
five minutes are unfigured entirely.
## Chartered but unbuilt (in flight or waiting)
- **v6 LIVING STREET** — spikes gated on footage; then clips-at-scale, personas, footpaths +
occasional cars, generated bodies.
- **Option B** — real reservation holds (John's hold budget).
- **Tier-2 auto-discovery** — needs a reachability contract.
- **The editor/override layer** (V2_IDEAS, still the best long-game idea: hand-author any shop
interior over the procedural base).
- **Multiplayer presence** (see who's digging the same town).
## Unowned design questions (nobody has even sketched these)
- **What is a session?** 20 minutes? A day-cycle? A town per sitting?
- **The economy loop:** buy low → ??? → profit. Sell where? To whom? GodBay (thriftgod's
auction sim) is sitting right there as the obvious answer and no design connects them.
- **Cross-town play:** 23 towns exist; nothing makes you travel. Regional wantlists? A gem
rumor system? The tram/train BETWEEN towns?
- **The gig layer as gameplay:** you can attend a gig; you can't book one, play one, or care
about one. 90sDJsim overlap sits unexplored (DJ the venue?).
- **Music identity:** procedural beds exist; a game this specific deserves a sound.
- **The 90s-ness:** it's in the fabric (video shops, milk bars) but no system USES the era —
no phone-free navigation puzzles, no street-press gig guide, no mixtapes.
## THE THIRD DIMENSION + TEXTURE LIFE (filed 2026-07-18 — John's Perlin question, answered from the code)
**The ruling that frames this section:** world *structure* stays on the semantic hash-stream
system (`prng.js` — `seedFor(citySeed, kind, id)`, independently-keyed streams per entity).
That architecture is why 31 rounds of features never moved a golden: a draw added to one
stream shifts no other. Perlin answers a different question — a *continuous* value over
space/time — and it enters the toolbox only where the question really is continuous. When it
does, it seeds from the same citySeed keying: deterministic, gate-able, no exceptions.
Candidates, ranked by honest cost:
1. **TERRAIN / ELEVATION — the headline, epoch-scale, chartered as a v8+ candidate.**
Everything is flat, which is *wrong for the real map*: Katoomba is a mountain town rendered
as a billiard table. Per the real-map DNA, the primary source is REAL elevation (SRTM /
contour data for each town bbox, the same discipline as the roads fetch) — noise is only
the infill between samples, never the shape of a real hill. **The cost is named now so
nobody charters it lightly:** terrain touches everything — roads follow grades, buildings
need foundations and steps, footpaths slope, the chunk streamer and the walkers and every
budget meet a Y axis, and the classic covenant needs a flat-forever fence. This is an
epoch with an alpha/beta/close arc of its own, not a round.
2. **Sky / cloud domes — cheap, near-term (v6/v7 polish).** Skies are baked textures today; a
seeded FBM dome gives every day its own weather-shaped sky, composes with the existing
weather states, and is one lane's item (B, lighting).
3. **Wear & grime — cheap, high-charm (E/C canvas painters).** Seeded value-noise for sleeve
ring-wear, facade sun-fade, footpath staining — per-item, deterministic, and exactly the
90s patina the parody sleeves deserve. Pairs with the beta's price-guide work (a worn
sleeve *reading* worn next to its condition grade).
4. **Crowd ebb (LIVING STREET track, D).** A slow 1D noise curve modulating street density
across the day — mornings trickle, arvo swells — richer than uniform draws, one stream,
identity-safe.
5. **THE VERNACULAR KIT (filed 2026-07-18, John's idea, source corrected) — buildings that
feel real.** The town's massing is same-y boxes; the fix is **1520 parametric Aussie
vernacular archetypes** (Queenslander, Federation cottage, terrace row, weatherboard
shopfront + verandah, deco corner pub, brick milk bar, fibro shack, tin warehouse…) with
seeded knobs — width, storeys, verandah depth, roof pitch, parapet, tint, and the wear
noise from item #3 above. **Source ruling: NOT Google Maps 3D** (licence-fatal — the one
thing the provenance spine must never touch — and photogrammetry mush at house scale
anyway). Instead: (a) **OSM/Overture building footprints + heights** for the real towns —
ODbL, the licence and pipeline we already ship, one more fetch class under the standing
authorization; (b) the kit designed from Street View *reference* (looking is not
extracting) and built parametrically on B's existing instanced-attribute machinery, which
already varies tint/hours per instance. Real footprints place real shapes on real streets;
the kit + knobs cover everything unfootprinted. Pairs with terrain as the two halves of a
future "the streetscape gets real" epoch — but unlike terrain, the kit is INCREMENTAL
(archetype by archetype, budget-measured, classic fenced), so it could also drip in as
B/E polish rounds. Goldens untouched either way (plan hashes don't see meshes); budgets
re-measured per archetype.
## The honest sequencing opinion (Fable's, not a ruling)
The private-test deploy is the design instrument: John walks the game cold and writes down
every "why can't I..." — that list IS the v7 charter input. My candidate for v7 once v6's
spikes land: **THE GAME (persistence + collection + the thrill loop + a day that matters)**
it's the smallest set that turns the platform into something a stranger plays twice.

View File

@ -33,10 +33,12 @@ for Fable-ultra before either builds it twice.
The pipeline test John asked for, as three end-to-end spikes. No epoch work until these run. The pipeline test John asked for, as three end-to-end spikes. No epoch work until these run.
1. **SPIKE 1 — the free stash (motion, zero generation).** Wire 2 of the 34 cached Mixamo 1. **SPIKE 1 — the free stash (motion, zero generation).** ~~Wire 2 of the 34 cached clips
clips (`lean`, `look-around`) into PROCITY loiter via the exact sit.glb pattern (E converts, (`lean`, `look-around`)~~ **AMENDED R28: `lean` does not exist in the stash** — E measured
D wires, classic-gated fetch). *Proves:* the clip bank expands cheaply; D's four-epoch-old the 34 instead of trusting the wishlist (the brief's premise was half wrong). `look.glb` is
wishlist starts clearing. Risk: ~zero. shipped + depot-verified (R28); D's wiring closes in R29 wave 0. The lean moves to Spike
2's shot list — John films a wall-lean. *Proves:* the clip bank expands cheaply, and
wishlists are not inventories. Risk: ~zero (confirmed).
2. **SPIKE 2 — the full mocap loop (the one that matters).** John films ONE move on a phone — 2. **SPIKE 2 — the full mocap loop (the one that matters).** John films ONE move on a phone —
**the crate-riffle, at Monster Robot** (the exact clip D's notes flagged "bespoke, won't be **the crate-riffle, at Monster Robot** (the exact clip D's notes flagged "bespoke, won't be
on Mixamo" two epochs ago) → MOCAPGOD ingest → BVH → retarget (MOCAPGOD-C or MIRPAMO — on Mixamo" two epochs ago) → MOCAPGOD ingest → BVH → retarget (MOCAPGOD-C or MIRPAMO —

106
docs/V7_THE_GAME.md Normal file
View File

@ -0,0 +1,106 @@
# PROCITY v7 — THE GAME (epoch charter — **RATIFIED by John, 2026-07-17**)
*(Ratified as drafted; the fantasy stands. R30 opens the epoch — v7.0-alpha wave 1.)*
*Fable (m3), 2026-07-17. From THE_FRONTIER's headline gap: the world is built, the game isn't.
v7 is the smallest set of systems that makes a stranger play twice. Design principle: every
system below rides machinery that ALREADY EXISTS — nothing here invents a new world layer.*
## The fantasy, one line
**You're a crate-digger in 90s Australia: build the collection, learn the prices, work the
towns — and be home in time for the gig.**
## The loop
Wake (save point) → plan the day (shops shut per their real hours; the gig starts at night —
the clock finally matters) → dig shops, spot the gems (priced below the guide), spend scarce
cash → sell the flips (keeper today; the auction tomorrow) → sleep = save, the day advances,
stock rotates → the collection grows → the wantlist points at another town → travel costs a
day. Repeat, richer and one gem wiser.
## The systems (each with its existing foundation)
1. **PERSISTENCE — the foundation stone.** Save = THE DELTA ONLY: cash, owned items, town, day
number, wantlist, stats. The world is never saved — it regenerates from seed (the
seeded-everything law finally pays its biggest dividend: saves are tiny and can't corrupt
the world). localStorage, schema-versioned, JSON export/import for moving machines.
*Foundation: item identity is already stable — parody items are seeded, real/mint carry
skus (the R25/R26 sku fences were built for exactly this, before we knew it).*
2. **THE COLLECTION.** Your crate, as a UI: cover, title, artist, price paid, where and what
day you found it. The trophy case of the whole game. (A walkable home-room shelf is v7.x —
UI first.) *Foundation: the atlas indexes already carry every field the card needs.*
3. **THE GUIDE + THE GEM (the thrill loop).** An in-game price guide — thriftgod's BIBLE
concept — gives a VALUE BAND per item (imperfect, like the real 90s guide books). Shops
price with seeded variance around it → a **gem** is an item priced under its band. Skill =
spotting them. *Foundation: mint() gem/price bands exist as data and drive nothing; real
items have real dealgod prices; the dig card already shows asking price.*
4. **SELLING — where cash comes back.** v7.0-alpha: sell to a keeper at the counter (below
guide, always — the no-money-pump law). v7.0: **GodBay** — list an item, a SEEDED offline
auction resolves NEXT DAY (the come-back-tomorrow hook; thriftgod's auction brain, G's
lane, offline tier first per the proven tier-ladder pattern — a live tier is v7.x and
rides the G3 rails).
5. **THE DAY.** Sleep advances the day; **stock rotates per (shop, day)** — seeded, so day 3
in Bendigo is the same for every player on that seed (parody/mint rotate; the real shop's
real stock stays real); gigs already run a 7-night week — `day % 7` plugs straight in;
hours already exist — now closing time costs you something. *Foundation: the segment spine,
`isOpen`, and `plan.gigs` are all sitting there waiting.*
6. **THE WANTLIST + TRAVEL.** Mark items/artists you're hunting; seeded rumors surface hints
("saw one in Castlemaine" — a keeper line, a gig-crowd mutter: D's citizens finally gossip
for a reason); **travel costs a day** → 23 towns become a strategy map instead of a
dropdown. *Foundation: the selector, the pack, the personas.*
7. **THE FIRST FIVE MINUTES.** You arrive with $50 and a want. Three toast-beats: dig, buy,
sell. A real splash. *(Smallest system, biggest stranger-facing payoff.)*
## The stages
- **v7.0-alpha — THE SAVE AND THE CRATE:** persistence + collection UI + sell-to-keeper +
sleep/day cycle + stock rotation. ONE town plays as a complete (small) game.
- **v7.0-beta — THE THRILL:** the guide bands + gem variance + wantlist + rumors + travel
costs a day. The 23 towns become the map.
- **v7.0 — THE AUCTION:** GodBay offline tier, onboarding, the tour ("day one to first gem"
as the money shot), docs freeze, tag.
## The laws (new ones this epoch earns)
1. **The delta law:** a save NEVER contains world state — only player deltas. A corrupted
save can cost you your stuff, never the town.
2. **The no-pump law:** every sell price < the buy-side guide band, structurally. The economy
gets a GATE (an agent plays N seeded days and asserts net-worth growth is bounded by finds,
not arbitrage loops).
3. **Game money is game money:** the write-back law extends — no bridge to real money, ever,
in either direction.
4. **Classic stays pure:** `?classic=1` boots the v2 town with NO game layer (no save, no
HUD additions) — the covenant is untouched. The game layer defaults ON for normal boots
(the v3.1 flip precedent) with `?game=0` as the opt-out.
5. **Rotation is seeded:** stock rotation draws from (citySeed, shopId, day) — deterministic,
gate-checkable, same for everyone on a seed.
## Lane map (first cut)
**F** the save core + gates (delta law, no-pump gate, save/load determinism) · **B** the UI
surfaces (collection, wantlist, day clock, splash — hud.js territory) · **C** the counter
sell interaction + dig-card guide hints (+ the home room, v7.x) · **A** the day/rotation
seeding contracts (new sub-streams, goldens untouched — rotation rides runtime, not plan
gen... A verifies that boundary) · **E** guide-band data baked into packs/atlases (mint
bands, real prices) · **D** rumor lines through keepers/citizens · **G** GodBay (the auction
brain, offline seeded tier) + real guide prices from dealgod.
## Risks (named now, v4-style)
1. **Economy balance** — pumps, dumps, degenerate flips. The no-pump gate is the fence; a
playtest-bot (an agent that greedily arbitrages for N days) is the adversarial verifier.
2. **Save schema drift** — versioned from day one, migration-or-reject, never silent.
3. **Item identity across re-emits** — a re-baked atlas must not orphan owned items (the sku
IS the identity; E's validator grows an owned-item stability check).
4. **Scope gravity** — the game wants everything (housing! romance! a phone!). The stage
gates are the fence: alpha ships ONE town playing well before beta touches 23.
5. **v6 collision — none by design:** v7-alpha is systems/UI (F/B/C-heavy); v6's spikes are
clips/ultra (E/D/G-heavy). They interleave; Fable sequences the rounds.
## Sequencing
v7.0-alpha can start **now** — it's unblocked (v6 waits on footage; the two don't collide).
Recommended: R30 = v7.0-alpha wave 1 (F save core + B collection UI + C sell counter contracts
first). John ratifies this charter (or amends the fantasy — it's your shop, your era, your
game) and says go.

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

View File

@ -0,0 +1,15 @@
# THE TOUR — day one to first gem (v7.0's money-shot sequence, R36)
Seed 20261990 (Boolarra Heads), one real playthrough driven end to end, no staging:
1. **01_you_arrive_hunting** — the splash names the mission: hunting HILLS HOIST, $191 in your pocket.
2. **02_the_strip** — the cluster-pose spawn: midday on the main street, shopfronts down the block.
3. **03_every_door_opens** — inside Curio & Wax; the keeper's rumor overhead.
4. **04_the_gem** — THE VERANDAHS "Tinny", asking **$19** against **★ RARE guide $60+**. The
skill of the whole game in one card. (Beat ③ visible below — the onboarding walking its talk.)
5. **05_the_crate** — the find banked, ⚖ a click away.
6. **06_the_morning_paper** — dawn, day 2: **"⚖ SOLD 'Tinny' — hammer $89, $78 after fees."**
Bought for $19, hammered for $89. That's the loop.
Regenerate: the scratch driver lives in the R36 session; it reuses tools/flags_check.py helpers
against any port via PROCITY_QA_PORT.

42
tools/deploy_digalot_procity.sh Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# PROCITY → digalot.fyi/procity — PRIVATE TEST deploy (Fable, R29-era).
# John's ruling: private test only, no payments, NO SEO/indexing. The licence flag
# (docs/V5_REAL_SHOP.md law #3) stays ARMED for any real public release.
#
# Usage: tools/deploy_digalot_procity.sh # rsync + install nginx block + verify
# tools/deploy_digalot_procity.sh --dry # show what would ship
#
# Ship-check status (run 2026-07-17): static files only — no endpoints, no auth
# surface, no money paths. Secrets sweep clean (no .env/.sql; grep hits were stock
# titles). _raw/ Overpass snapshots excluded (pipeline provenance, not runtime).
set -euo pipefail
VPS="root@100.94.195.115"
DEST="/opt/procity/web/"
HERE="$(cd "$(dirname "$0")/.." && pwd)"
DRY=""; [ "${1:-}" = "--dry" ] && DRY="--dry-run"
echo "== 1/4 rsync web/ -> $VPS:$DEST (excluding pipeline provenance + junk)"
rsync -az --delete $DRY \
--exclude '_raw/' --exclude '.DS_Store' --exclude '*.map' \
"$HERE/web/" "$VPS:$DEST"
[ -n "$DRY" ] && { echo "(dry run — stopping)"; exit 0; }
echo "== 2/4 install nginx location (noindex, no autoindex, no listing)"
ssh "$VPS" 'CONF=$(docker exec dealgod-nginx sh -c "grep -rl digalot /etc/nginx/conf.d/" | head -1); \
echo "digalot server block: $CONF"; \
docker exec dealgod-nginx sh -c "grep -q location\\ /procity/ $CONF" 2>/dev/null && { echo "location exists — skipping insert"; exit 0; }; \
docker exec dealgod-nginx sh -c "sed -i \"/server_name.*digalot/a\\ location /procity/ { alias /srv/procity/web/; autoindex off; add_header X-Robots-Tag \\\"noindex, nofollow, noarchive\\\" always; }\" $CONF"'
# NOTE: the alias path above must be the path AS THE CONTAINER SEES IT. If /opt/procity
# is not already bind-mounted into dealgod-nginx, add a mount (or symlink under an
# existing mounted root) BEFORE reload — the installer checks and tells you.
echo "== 3/4 robots: ensure digalot.fyi/robots.txt disallows /procity/"
ssh "$VPS" 'R=/opt/procity/robots-note.txt; echo "add to digalot robots.txt: Disallow: /procity/" > $R; \
docker exec dealgod-nginx sh -c "nginx -t" && docker exec dealgod-nginx nginx -s reload'
echo "== 4/4 verify with own eyes (cache-busted)"
curl -sf -o /dev/null -w "index: %{http_code}\n" "https://digalot.fyi/procity/?cb=$(date +%s)"
curl -sf -o /dev/null -w "module: %{http_code} %{content_type}\n" "https://digalot.fyi/procity/js/citygen/index.js?cb=$(date +%s)"
curl -sI "https://digalot.fyi/procity/" | grep -i x-robots-tag || echo "WARN: noindex header missing"
echo "DONE — boot https://digalot.fyi/procity/ in a browser; classic check: ?classic=1"

File diff suppressed because it is too large Load Diff

View File

@ -24,10 +24,10 @@ Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N]
Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py. Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py.
Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F). Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F).
""" """
import sys, time, socket, subprocess, pathlib import sys, os, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130 PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py)
HOST = f'http://127.0.0.1:{PORT}' HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990 SEED = 20261990
if '--seed' in sys.argv: if '--seed' in sys.argv:

View File

@ -16,10 +16,10 @@ Gates per town (all on the DEFAULT boot — gigs/weather/winmap/tram on, post-R1
Run: tools/.venv/bin/python tools/qa/town_matrix.py Run: tools/.venv/bin/python tools/qa/town_matrix.py
Needs the Playwright venv + Lane B's ?dbg=1 hook + Lane F's real-cache shell wiring (R17). Needs the Playwright venv + Lane B's ?dbg=1 hook + Lane F's real-cache shell wiring (R17).
""" """
import sys, time, socket, subprocess, pathlib import sys, os, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130 PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py)
HOST = f'http://127.0.0.1:{PORT}' HOST = f'http://127.0.0.1:{PORT}'
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000 STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
# [R21 ledger #6c] B's R20 tram ruling: on a REAL-ROADS town the tram only runs if its best main chain # [R21 ledger #6c] B's R20 tram ruling: on a REAL-ROADS town the tram only runs if its best main chain

View File

@ -31,10 +31,11 @@
<div id="pc-start"> <div id="pc-start">
<h1>PROCITY</h1> <h1>PROCITY</h1>
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town. <div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
Every door opens (soon). Walk the strip, watch the day turn.</div> Every door opens. Dig the crates, sell the flips, sleep — the town remembers.</div>
<div class="sub" id="pc-hunt" style="color:#ffd75e;display:none"></div>
<button id="pc-go">walk into town</button> <button id="pc-go">walk into town</button>
<div class="seed" id="pc-startseed"></div> <div class="seed" id="pc-startseed"></div>
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · [ ] time of day · M map · P screenshot</div> <div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · E riffle a bin / sell at the counter · C your crate · SLEEP to save · [ ] time · M map</div>
</div> </div>
<script type="module"> <script type="module">
@ -62,6 +63,9 @@ 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 { 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 { 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 { 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)
import { ARTISTS } from './js/interiors/dig.js'; // [R35] the parody roster — a fresh game's seeded first want
import { rng, pick } from './js/core/prng.js'; // [R35] seeded first-want draw (runtime stream, fresh key)
main().catch((err) => { main().catch((err) => {
console.error('[procity] init failed:', err); console.error('[procity] init failed:', err);
@ -167,7 +171,8 @@ const skins = createSkinLibrary();
const ground = buildGround(plan, skins); const ground = buildGround(plan, skins);
scene.add(ground.group); scene.add(ground.group);
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS }); const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS,
dawnSky: CLASSIC ? null : 'high-cirrus' }); // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome
lighting.attachRenderer(renderer); lighting.attachRenderer(renderer);
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS }; const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
@ -186,11 +191,17 @@ const minimap = createMinimap(plan);
// unless) a GLB lands, so the town is fully playable the instant it boots. // unless) a GLB lands, so the town is fully playable the instant it boots.
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders. // • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
// • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot). // • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot).
const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC }); // [R16] sit.glb on the default boot (the seated drummer); ?classic keeps the zero-fetch-delta covenant const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC, look: !CLASSIC, dance: !CLASSIC }); // [R16] sit.glb (the seated drummer) + [R29] look.glb (the street glance) on the default boot; ?classic keeps the zero-fetch-delta covenant
if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings
// [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper) // [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper)
// [Lane F §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1. // [Lane F R31 — THE DIG FLIP] The crate-riffle is DEFAULT-ON: `?dig=0` opts out, `?classic=1` forces it off
const DIG_ON = params.has('dig') && params.get('dig') !== '0'; // — the exact flagOn pattern the other flipped flags ride. The v2-era opt-in (`params.has('dig')`) survived
// here six epochs after the README's flags table claimed "dig on", and every R30 game-loop gate booted
// `dig=1` explicitly — so the whole suite was green while the plain boot (the boot every player gets) had
// no dig at all: E at a bin did nothing (Fable's R31 wave-0 playtest, the alpha-blocker). The default-boot
// gate now asserts dig REACHABILITY on a naked boot, so un-flipping this line goes red in qa, not in a
// player's hands. ?noassets composes unchanged: the dig falls to parody canvas (existing law).
const DIG_ON = flagOn('dig');
// [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so // [Lane F §F2 stock=real] real GODVERSE sleeves in the crates (v2). Preload the record pack at boot so
// the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets. // the first interior/dig can batch real covers; fail-soft (missing pack → parody canvas). Off under ?noassets.
const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS; const STOCK_REAL = params.get('stock') === 'real' && !NOASSETS;
@ -240,22 +251,97 @@ if (STOCK_REAL) {
} }
// [Lane F R8 — Lane C buy loop v0] ONE session wallet (seeded start cash) bound to the dig BUY across shops. // [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. // 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);
// [R34] the morning paper: GODBAY resolved before this hook fired — fold the results in
const news = game ? game.takeAuctionNews() : [];
hud.showToast(`🌅 day ${day}` + (news.length ? ' · ' + news.join(' · ') : ''), news.length ? 6 : 2.6);
},
}) : 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) // [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 // → 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 // 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). // 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; 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));
// [R33 — TRAVEL COSTS A DAY] this boot adopted a save from another town: the ride ate a day (save.js
// adopt() did the increment + pull-pruning; weekNight above already read the incremented day). Tell John.
// [R34] the ride also resolved any GODBAY listings the day moved past — the arrival toast carries the news.
if (game) {
const bootNews = game.takeAuctionNews();
if (game.traveled) hud.showToast(`🚌 ${plan.name || 'a new town'} — the ride cost a day. Day ${game.day}`
+ (bootNews.length ? ' · ' + bootNews.join(' · ') : ''), bootNews.length ? 7 : 4);
else if (bootNews.length) hud.showToast(bootNews.join(' · '), 6);
}
// ── [R35 — THE FIRST FIVE MINUTES (charter #7)] ────────────────────────────────────────────────
// A stranger arrives with cash and A WANT, and three toast-beats walk the whole hustle: dig →
// buy → sell-or-list. FRESH games only (day 1, empty crate, empty auction house) — a returning
// save gets its town back, not a tutorial. ?game=0 / ?classic=1 never reach this block. The
// first want is SEEDED (citySeed → the parody roster), so the splash line, the keeper rumors,
// and the tour all point at the same hunt on a given seed — deterministic, gate-checkable.
if (game && game.day === 1 && !game.collection.length && !game.listings.length) {
if (!game.wants.length) game.recordWant({ artist: pick(rng(plan.citySeed, 'firstwant', 0), ARTISTS) });
const hunt = document.getElementById('pc-hunt');
if (hunt && game.wants.length) {
hunt.textContent = `You're hunting ${game.wants[0].artist} — $${game.cash} in your pocket.`;
hunt.style.display = 'block';
}
let stage = 1;
const go = document.getElementById('pc-go');
if (go) go.addEventListener('click', () =>
setTimeout(() => { if (stage === 1) hud.showToast('① find a record shop — walk in, press E at a crate', 7); }, 900),
{ once: true });
window.addEventListener('procity:digOpen', () => {
if (stage === 1) { stage = 2; hud.showToast('② riffle with the wheel · pull a sleeve · mind the guide — BUY', 7); }
});
let n3 = 0;
const offBeats = game.wallet.onChange(() => {
if (stage <= 2 && game.collection.length) {
stage = 3; n3 = game.collection.length;
hud.showToast('③ yours now. Sell it at a counter (E) — or ⚖ LIST it in the crate and SLEEP on it', 8);
} else if (stage === 3 && (game.listings.length || game.collection.length < n3)) {
stage = 4;
hud.showToast('Thats the whole hustle. ☆ want things · keepers gossip · 🚌 towns cost a day. Good digging.', 9);
offBeats();
}
});
}
// [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter() // [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. // time (door click), long after citizens is initialized — so the forward reference is safe.
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS,
dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id), 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 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 // [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 // 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". // doesn't read as "walked out of the shop".
window.addEventListener('procity:digOpen', () => player.unlock()); window.addEventListener('procity:digOpen', () => player.unlock());
// [Lane F R30 §9.4] the sell card is cursor-driven too (SELL button, cycling) — same unlock contract.
window.addEventListener('procity:sellOpen', () => player.unlock());
// [Lane F §F1 — ROUND 7 ROSTER FLIP · prime-law amendment] The chunk-streamed roster is now the // [Lane F §F1 — ROUND 7 ROSTER FLIP · prime-law amendment] The chunk-streamed roster is now the
// DEFAULT: the town is permanently busier. `?roster=v1` restores the old fixed roster (escape hatch); // DEFAULT: the town is permanently busier. `?roster=v1` restores the old fixed roster (escape hatch);
// `?roster=stream` is still accepted (now a no-op — stream is default). Gated on Lane D's GO: E1's // `?roster=stream` is still accepted (now a no-op — stream is default). Gated on Lane D's GO: E1's
@ -317,8 +403,37 @@ const venuePresentation = gigState ? createVenuePresentation(plan, skins, scene)
const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue const venueQueues = gigState ? new Map() : null; // venueShopId → VenueQueue
const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; }; const queueCountOf = (id) => { const q = venueQueues && venueQueues.get(id); return q ? q.count() : 0; };
// spawn on the footpath near the west end of the strip, looking east down main street // [R32 §31.6-1 — THE CLUSTER-POSE SPAWN] The frozen pose (-96, 9, looking east) was tuned for the
player.teleport(-96, 9, -Math.PI / 2); // synthetic fixture; on real OSM towns it faced dirt and building-backs (the R31 playtest's #1).
// Non-classic boots now spawn at one END of the shop cluster's dominant axis, ON the footpath door-line
// (a point every shop already computes for patronage — known walkable), looking down the strip toward
// the cluster's heart. Deterministic (pure plan math, no draws — no stream moves), and ?classic=1
// keeps the frozen pose forever (the covenant is a POSE here, not just a plan).
let SPAWN = { x: -96, z: 9, yaw: -Math.PI / 2 };
if (!CLASSIC && plan.shops && plan.shops.length >= 2) {
// main-street shop doors only (block kind — both generators stamp it); every-door fallback
const msBlocks = new Set((plan.blocks || []).filter((b) => b.kind === 'mainstreet').map((b) => b.id));
const doorOf = (s) => {
const l = plan.lots.find((x) => x.id === s.lot); if (!l) return null;
const ry = l.ry || 0;
return { x: l.x - Math.sin(ry) * (l.d / 2 + 2.6), z: l.z - Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
};
let doors = plan.shops.map(doorOf).filter(Boolean);
const ms = doors.filter((d) => msBlocks.has(d.block));
if (ms.length >= 2) doors = ms;
if (doors.length >= 2) {
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
const ax = vx >= vz ? 'x' : 'z'; // the strip's dominant axis
let stand = doors[0], bd = Infinity; // stand at the door nearest the cluster's heart
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
let lo = Infinity, hi = -Infinity;
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; // face the longer run of shopfronts
SPAWN = { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
}
}
player.teleport(SPAWN.x, SPAWN.z, SPAWN.yaw);
chunks.warmup(player.position); chunks.warmup(player.position);
lighting.update(0.0001, player.position); lighting.update(0.0001, player.position);
@ -390,9 +505,9 @@ addEventListener('blur', () => keys.clear());
const startPanel = document.getElementById('pc-start'); const startPanel = document.getElementById('pc-start');
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`; document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); }); document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior' && !interiorMode.digActive && !interiorMode.sellActive) leaveShop(); }); // [Lane F] Esc leaves the shop — but not when the unlock is us opening a dig (§F2) or the sell card (R30 §9.4)
// click the world to (re)grab the pointer when walking // click the world to (re)grab the pointer when walking
canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig canvas.addEventListener('click', () => { if (!player.isLocked && !interiorMode.digActive && !interiorMode.sellActive && (MODE === 'street' || MODE === 'interior')) player.lock(); }); // [Lane F §F2] re-lock for interior walk after closing a dig/sell card
addEventListener('resize', () => { addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
@ -471,8 +586,10 @@ function frame() {
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
composer.render(); composer.render();
} else if (MODE === 'interior') { } else if (MODE === 'interior') {
hud.tickGame(); // [R32 §31.6-2] the crate/day/cash surface lives in shops too (yields to the cards)
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
} else if (MODE === 'map') { } else if (MODE === 'map') {
hud.tickGame(); // [R32] map mode: the same check hides the surface (the map owns the screen)
camera.getWorldDirection(_fwd); camera.getWorldDirection(_fwd);
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z }); minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
} }
@ -485,7 +602,8 @@ frame();
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins, window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS, 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) 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 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 // 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) venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off)
@ -494,7 +612,9 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
// ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off. // ({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 // [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. // 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)
dig: DIG_ON }, // [R31] the dig flip (default-on; ?dig=0 / ?classic=1 → off) — gate reads intent here
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks 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 // [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 // 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 { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js'; import { createWallet } from './js/interiors/wallet.js';
import { collapseBuyItem } from './js/interiors/stockpack.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. // ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real'; const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
@ -522,6 +523,54 @@ async function shelfBuySoak() {
return out; 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 ─────────────────────────────────────────────────────────────────────── // ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now(); let last = performance.now();
function frame() { function frame() {
@ -534,7 +583,8 @@ function frame() {
// expose for headless verification (screenshot harness, workflow checks) // 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, 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, 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(); rebuild();
frame(); frame();

View File

@ -138,7 +138,7 @@ export class GigCrew {
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot. // one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the // `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip. // fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false }) { _make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) {
const r = rng(this.citySeed, 'gig', key); const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3; const h = height != null ? height : 1.6 + r() * 0.3;
let actor, kind, usedIndex = null; let actor, kind, usedIndex = null;
@ -146,7 +146,10 @@ export class GigCrew {
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r()); const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index]; const rig = pk && this.fleet.all[pk.index];
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : this.fleet.idleClip, phase: r(), seated: useSit }); // dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
const dcs = this.fleet.danceClips || [];
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null;
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit });
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; } if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
} }
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; } if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
@ -200,8 +203,8 @@ export class GigCrew {
pts.forEach((w, i) => { pts.forEach((w, i) => {
const who = roster[i] || null; const who = roster[i] || null;
const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, const { actor, kind, pedIndex } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height } who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance }
: { x: w.x, y: 0, z: w.z, ry: w.ry }); : { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance });
if (who) fromRoster++; if (who) fromRoster++;
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who, this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] }); base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
@ -284,10 +287,14 @@ export class GigCrew {
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055; f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02; f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
} }
} else if (m.dance) { // dancer: bounce + sway + a little step } else if (m.dance) { // dancer
if (this.fleet && this.fleet.danceClips && this.fleet.danceClips.length) {
f.rotation.y = b.ry + Math.sin(t * 0.6 + ph) * 0.10; // real dance clip drives the body; just a slow facing drift
} else { // fallback (classic/noassets): procedural bounce + sway
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075; f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07; f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13; f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
}
} else { // stander: slow weight shift, feet planted } else { // stander: slow weight shift, feet planted
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022; f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03; f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;

View File

@ -18,7 +18,8 @@ export const PED_NAMES = {
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03', normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01', 'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01', 'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'], 'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01',
'woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // trellis'd DJ bodies (fbx_to_ped, 4k tris)
comical: ['comical_luchador_01', 'comical_boy_01'], comical: ['comical_luchador_01', 'comical_boy_01'],
}; };
@ -55,10 +56,10 @@ function loadRig(ref) {
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default // sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls // boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism. // back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) { export function loadPedFleet(base = 'models/peds/', { sit = false, look = false, dance = false } = {}) {
const fleet = { const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null, sitClip: null, walkClip: null, idleClip: null, sitClip: null, lookClip: null, danceClips: [],
ready: false, whenReady: null, ready: false, whenReady: null,
}; };
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array // DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
@ -82,10 +83,26 @@ export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) {
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
const c = r && r.anims && r.anims[0]; const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } })); if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
// [R29 Spike 1] E's look.glb (mesh-free, 65 joints, 6.5 s 'Look'). Same gate as sit — the shell passes
// `look: !CLASSIC`, so ?classic fetches nothing and the zero-fetch-delta covenant holds. `_rotOnly` is
// correct here for the same reason as walk/idle: it's a STANDING clip, so there's no hip descent to
// recover and no foot re-plant needed (that trap was sit's alone — R16).
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _rotOnly(c); } }));
// dance clips (mesh-free mixamorig): the club dancers play a REAL move instead of procedural sway.
// GATED like sit/look — ?classic fetches nothing so the zero-fetch-delta covenant holds. The per-dancer
// pick is deterministic (a separate seeded stream in _make), so 'same seed -> same crowd' is preserved.
const DANCE_CLIPS = ['dance_party', 'dance_medium', 'dance_drink', 'dance_sway'];
const danceSlots = new Array(DANCE_CLIPS.length).fill(null);
if (dance) DANCE_CLIPS.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); danceSlots[i] = _rotOnly(c); } })));
fleet.whenReady = Promise.all(jobs).then(() => { fleet.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean); fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.filter(Boolean); fleet.comical = cSlots.filter(Boolean);
fleet.all = [...fleet.normal, ...fleet.comical]; fleet.all = [...fleet.normal, ...fleet.comical];
fleet.danceClips = danceSlots.filter(Boolean);
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip; fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
return fleet; return fleet;
}); });
@ -223,7 +240,7 @@ export function seatedLean(spine, angle = 0.34) {
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim // ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ---- // scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeight = 1.75 } = {}) { export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = null, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null; if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight); const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
@ -233,9 +250,16 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where // R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op. // sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
const sitA = sitClip ? _action(mixer, inner, sitClip) : null; const sitA = sitClip ? _action(mixer, inner, sitClip) : null;
// [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers,
// placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer.
const lookA = lookClip ? _action(mixer, inner, lookClip) : null;
let seatBone = null; let seatBone = null;
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
let sitting = false; // [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip
// exists, so walkers/?classic pay nothing.
let footBones = null;
if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; }
let sitting = false, looking = false;
let moving = null; // tri-state so the first setMoving always applies let moving = null; // tri-state so the first setMoving always applies
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them // both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); } if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
@ -279,13 +303,53 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig
fig.userData.procitySeated = false; fig.userData.procitySeated = false;
} }
} }
// [R29 Spike 1] the standing glance during a window-shop loiter. Deliberately SIMPLER than setSitting:
// it's a standing clip, so there is no posed foot-replant and no scale trap — the feet never leave the
// bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this
// crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped.
// No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
function setLooking(l, fade = 0.3) {
if (!lookA || l === looking) return;
looking = l;
moving = null; // force the next setMoving to re-apply on the way out
if (l) {
lookA.reset().play();
if (idleA) idleA.crossFadeTo(lookA, fade, false);
else lookA.setEffectiveWeight(1);
if (walkA) walkA.setEffectiveWeight(0);
} else {
if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); }
else lookA.setEffectiveWeight(0);
inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant
}
}
// [R29] MEASURED, and it corrected my own assumption: I expected a standing clip to need no re-plant
// ("no hip descent to recover"). It does. `_rotOnly` drops the Hips POSITION track to stay rig-height
// independent, so look.glb's authored weight-shift dip cannot move the hips — it comes out as the FEET
// rising instead: lowest bone ranges 0.009…+0.205 m over the loop vs idle's 0.04…0.00. A 20 cm hover.
// Sit's one-time re-plant can't fix it because the float VARIES per frame, so this is the per-frame twin:
// reset to the bind plant, measure the posed feet, drop `inner` by the float / scale. Absolute (reset →
// measure → correct), never incremental, so it cannot drift. The result reproduces the authored motion
// exactly — the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's.
// Cost is paid ONLY by glancing peds (a handful), and only on frames their mixer actually ran.
function plantFeet() {
if (!footBones || !looking) return;
inner.position.y = bindPlantY; // reference pose
inner.updateWorldMatrix(true, true);
let w = Infinity;
for (const b of footBones) { b.getWorldPosition(_wp); if (_wp.y < w) w = _wp.y; }
if (!isFinite(w)) return;
const S = fig.scale.y || 1;
inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
}
// seed the clip phase so the crowd isn't in lockstep // seed the clip phase so the crowd isn't in lockstep
function setPhase(p) { function setPhase(p) {
if (walkA) walkA.time = p * (walkA.getClip().duration || 1); if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
if (idleA) idleA.time = p * (idleA.getClip().duration || 1); if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too
} }
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setPhase, dispose, return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose,
hasClips: !!(walkA && idleA) }; hasClips: !!(walkA && idleA) };
} }

View File

@ -35,6 +35,7 @@ const PATRON_STRIDE = 10; // m walked between patronage check
const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront
const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day)
const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded) const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded)
const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
// near-empty at night). Sampled at 8 control points, linearly interpolated. // near-empty at night). Sampled at 8 control points, linearly interpolated.
@ -93,6 +94,7 @@ class RigPool {
release(actor) { release(actor) {
if (!actor) return; if (!actor) return;
if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant
if (actor.setLooking) actor.setLooking(false, 0); // R29: nor mid-glance — snap back to idle before pooling
let arr = this.free.get(actor.pedIndex); let arr = this.free.get(actor.pedIndex);
if (!arr) this.free.set(actor.pedIndex, arr = []); if (!arr) this.free.set(actor.pedIndex, arr = []);
arr.push(actor); arr.push(actor);
@ -258,6 +260,7 @@ export class CitizenSim {
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id), patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
}; };
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
this._placeOnLane(c); this._placeOnLane(c);
@ -307,6 +310,7 @@ export class CitizenSim {
turn: rng(this.citySeed, 'turn', id), turn: rng(this.citySeed, 'turn', id),
loit: rng(this.citySeed, 'loiter', id), loit: rng(this.citySeed, 'loiter', id),
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched
}; };
// assign a real ped type if the fleet is already up (roster can grow after upgrade) // assign a real ped type if the fleet is already up (roster can grow after upgrade)
if (this.mode === 'rig' && this.fleet.ready) { if (this.mode === 'rig' && this.fleet.ready) {
@ -522,7 +526,7 @@ export class CitizenSim {
this._setImpostorLayer(atlas); this._setImpostorLayer(atlas);
this.mode = 'rig'; this.mode = 'rig';
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip }); // sitClip null under ?classic → bench-sit inert this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip, lookClip: this.fleet.lookClip }); // sit/lookClip null under ?classic → bench-sit + glance inert
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
const assign = c => { const assign = c => {
const pk = pickRig(this.fleet, c.pedRoll); const pk = pickRig(this.fleet, c.pedRoll);
@ -589,6 +593,12 @@ export class CitizenSim {
// so ?classic (no sit.glb) is inert at the source, not just at the render. // so ?classic (no sit.glb) is inert at the source, not just at the render.
const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false; const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false;
c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip); c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip);
// [R29 Spike 1] a standing glance — the other way to spend a window-shop stop. Same discipline as
// the sit roll: draw ALWAYS from a dedicated stream (deterministic; never shifts turn/loit/patron),
// but only flip the flag when a clip exists ⇒ ?classic inert at the source. Mutually exclusive with
// the sit: you can't bench-sit and stand glancing, so the sit wins and the glance takes the rest.
const wantsGlance = c.glanceRng ? (c.glanceRng() < GLANCE_FRAC) : false;
c.glance = !c.sit && wantsGlance && !!(this.fleet && this.fleet.lookClip);
} }
} }
this._placeOnLane(c); this._placeOnLane(c);
@ -732,7 +742,11 @@ export class CitizenSim {
// so every non-sitting ped is byte-identical. // so every non-sitting ped is byte-identical.
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting); const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
if (a.setSitting) a.setSitting(wantSit); if (a.setSitting) a.setSitting(wantSit);
if (!wantSit) a.setMoving?.(c.loiter <= 0); // [R29 Spike 1] the glance rides the same seam: a stopped ped either sits, glances, or plain idles.
// No-op for walkers, placeholders and ?classic (no lookClip) ⇒ every non-glancing ped byte-identical.
const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking);
if (a.setLooking) a.setLooking(wantLook);
if (!wantSit && !wantLook) a.setMoving?.(c.loiter <= 0);
} else if (want === 'mid') { } else if (want === 'mid') {
mid.push(c); mid.push(c);
} }
@ -747,7 +761,9 @@ export class CitizenSim {
const c = near[i], a = c.actor; const c = near[i], a = c.actor;
c._acc += dt; c._acc += dt;
if (a.mixer) { if (a.mixer) {
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; } else extra.push(c); // [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's
// post-mix lean taught us. Only glancing peds pay it; it self-guards on `looking`.
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance) a.plantFeet?.(); } else extra.push(c);
} else { } else {
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder
} }
@ -758,6 +774,8 @@ export class CitizenSim {
for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) { for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
const c = extra[(this._mixerCursor + k) % extra.length]; const c = extra[(this._mixerCursor + k) % extra.length];
c.actor.mixer.update(c._acc); c._acc = 0; c.actor.mixer.update(c._acc); c._acc = 0;
if (c.glance) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran
} }
this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length; this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;
} }

58
web/js/interiors/bible.js Normal file
View File

@ -0,0 +1,58 @@
// PROCITY Lane C — bible.js [v7.0-beta wave 1, R32 — THE GUIDE + THE GEM (charter system #3)]
// The in-game price guide: thriftgod's BIBLE concept as a VALUE BAND per item. The band is the
// 90s guide book, not an oracle — it names a range ("guide $2559"), never a verdict. A **gem**
// is an item ASKING under its band's low edge; spotting one is the player's skill, so nothing in
// any UI ever says "gem".
//
// THE DERIVATION RULING (R32): band NUMBERS mirror Lane E's pipeline thresholds
// (pipeline/build_stock_pack.py price_band(): record 8/25/60 · book 5/15/40 · toy 6/20/50 —
// duplicated verbatim in godverse_stock.py, validated by validate_pack.py). We derive ranges in
// JS from the band LABEL the packs already carry instead of re-baking band data into atlases —
// so charter risk #3 (a re-emitted pack orphaning owned skus) never opens. If E's thresholds
// move, this table moves with them in the same commit (the two files name each other).
//
// THE STRUCTURAL CONSEQUENCE (why the no-pump law survives the beta, LANE_C_PUB §9.1):
// real/mint pack items derive `price_band` FROM `price`, so real asking always sits INSIDE its
// band ⇒ bandLow ≤ asking ⇒ sellOffer(bandLow) ≤ bandLow 1 < asking: every real round trip
// stays strictly negative with the basis swapped. Only PARODY stock (proceduralSleeves) rolls a
// seeded under-band gem — and per-day scarcity (the save's `pulls`) makes each gem profit ONCE.
// Growth is bounded by finds, not arbitrage loops — charter law #2, made structural.
//
// Zero DOM, zero draws, zero fetch, zero storage: a pure table + pure functions. ?classic=1 /
// ?dig=0 never import a caller, and importing this module alone has no effect at all.
// kind → band → [low, high]; `high: null` = open-ended (the card renders "$60+").
// Lows/highs are E's threshold edges: a band [t0, t1) renders as $t0(t11).
export const BANDS = {
record: { bargain: [2, 7], standard: [8, 24], collector: [25, 59], grail: [60, null] },
book: { bargain: [1, 4], standard: [5, 14], collector: [15, 39], grail: [40, null] },
toy: { bargain: [2, 5], standard: [6, 19], collector: [20, 49], grail: [50, null] },
};
export const BAND_ORDER = ['bargain', 'standard', 'collector', 'grail'];
// bandRange('record','collector') → [25, 59] | null on unknown kind/band (fail-closed: no range,
// no guide line, no basis swap — never an invented number).
export function bandRange(kind, band) {
const k = BANDS[kind];
const r = k && k[String(band || '').toLowerCase()];
return r ? [r[0], r[1]] : null;
}
// The guide line for a card stamp — "guide $2559" / "guide $60+" / '' when unknowable.
export function guideText(kind, band) {
const r = bandRange(kind, band);
if (!r) return '';
return r[1] == null ? `guide $${r[0]}+` : `guide $${r[0]}${r[1]}`;
}
// The sell basis for a collection entry (LANE_C_PUB §9.1, the pre-ratified beta swap): bandLow
// when the entry carries a band the BIBLE knows, else the alpha basis (pricePaid) — so every
// alpha-era find (no band field) sells exactly as it did before this round. The sellOffer()
// CLAMP SHAPE is sell.js's and survives untouched; this function only chooses the number fed in.
export function sellBasis(entry) {
if (entry && entry.band) {
const r = bandRange(entry.type, entry.band);
if (r) return r[0];
}
return entry ? (entry.pricePaid != null ? entry.pricePaid : entry.price) : 0;
}

View File

@ -10,34 +10,48 @@
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1. // runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
import { mulberry32, xmur3 } from '../core/prng.js'; import { mulberry32, xmur3 } from '../core/prng.js';
import { BANDS, guideText } from './bible.js'; // [R32] THE BIBLE — band ranges + the card's guide stamp
const MAXFLIP = 1.95, BACK = -0.12; const MAXFLIP = 1.95, BACK = -0.12;
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl) const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
// 90s-Australian crate genres → sleeve card colour. // 90s-Australian crate genres → sleeve card colour.
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'], const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']]; ['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG', // [R35] exported: the shell seeds a fresh game's FIRST WANT from this roster (THE FIRST FIVE MINUTES).
export const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH']; 'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
const TITLES = ['Sunburnt', 'Back o Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny', const TITLES = ['Sunburnt', 'Back o Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo']; 'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX']; const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers. // Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
// [R32 — THE BIBLE + THE GEM] Every parody sleeve now rolls a seeded BAND (the guide's range) and an
// ASKING price with seeded variance: non-gems price INSIDE their band; a 10% gem roll (collector/grail
// only — an under-band bargain is not a thrill) prices UNDER the band's low edge, which is the entire
// op-shop fantasy made mechanical. Draw COUNT is fixed per item (9 rolls, outcome-independent) so the
// stream stays deterministic and re-orderable never. `rare` (the ★) now means grail, matching the real
// packs — the card never says "gem"; spotting the asking-under-guide gap is the player's skill.
const PARODY_BAND = (roll) => (roll < 0.25 ? 'bargain' : roll < 0.70 ? 'standard' : roll < 0.92 ? 'collector' : 'grail');
const GRAIL_ASK_HI = 120; // parody grail asking ceiling (band renders "$60+")
function proceduralSleeves(seed, n) { function proceduralSleeves(seed, n) {
const r = mulberry32(seed >>> 0); const r = mulberry32(seed >>> 0);
const out = []; const out = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const g = GENRE[(r() * GENRE.length) | 0]; const g = GENRE[(r() * GENRE.length) | 0];
out.push({ const a = ARTISTS[(r() * ARTISTS.length) | 0];
i, kind: 'record', const t = TITLES[(r() * TITLES.length) | 0];
a: ARTISTS[(r() * ARTISTS.length) | 0], const y = 1971 + ((r() * 28) | 0);
t: TITLES[(r() * TITLES.length) | 0], const cond = CONDS[(r() * CONDS.length) | 0];
y: 1971 + ((r() * 28) | 0), const band = PARODY_BAND(r());
s: g[0], color: g[1], const [lo, hi] = BANDS.record[band];
cond: CONDS[(r() * CONDS.length) | 0], const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape
price: 2 + ((r() * 28) | 0), const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail');
rare: r() < 0.12, // gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo1, floor(lo/2)) — sell.js),
}); // so a spotted gem always nets margin: collector asks $29 vs offer $12 · grail $520 vs $30.
const price = gem
? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0))
: lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0);
out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' });
} }
return out; return out;
} }
@ -103,6 +117,7 @@ export function createDig(THREE, renderer) {
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1; let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null; let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
let onWantCb = null, isWantedCb = null; // [R33] the wantlist seams — absent (?game=0/classic) ⇒ no WANT DOM at all
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional. let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary // shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2])); const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
@ -128,6 +143,9 @@ export function createDig(THREE, renderer) {
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer} .pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18} .pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default} .pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
.pcdg-panel .want{margin-top:7px;text-align:center;font:700 11px "Courier New",monospace;letter-spacing:.08em;color:#8a7f66;cursor:pointer;user-select:none}
.pcdg-panel .want:hover{color:#241f18}
.pcdg-panel .want.on{color:#b3121f;cursor:default}
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px} .pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60} .pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b} .pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
@ -250,14 +268,30 @@ export function createDig(THREE, renderer) {
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · " const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`); if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`); if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
// [R32 THE BIBLE] the guide stamp — a RANGE off the 90s guide book, never a verdict. Present-fields-
// only law holds: an unknown band leaves no stamp (guideText returns ''). The asking price sits on
// the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem.
const guide = guideText('record', o.band);
if (guide) stamps.push(`<span class="tag">${esc(guide)}</span>`);
// [R33] the WANT line — only when the game layer wired the seams (construction-gated DOM: a
// ?game=0 / classic card carries no wantlist trace). Already-wanted reads as a stamp, not a button.
const wanted = isWantedCb ? !!isWantedCb(o) : false;
const wantLine = onWantCb
? `<div class="want${wanted ? ' on' : ''}">${wanted ? '★ ON THE WANTLIST' : '☆ WANT IT — hunt one cheaper'}</div>`
: '';
panel.innerHTML = `<div class="x">✕</div>` panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(o.price)}</div>` + `<div class="sticker">$${esc(o.price)}</div>`
+ `<h3>${esc(o.a)}</h3>` + `<h3>${esc(o.a)}</h3>`
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '') + (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '') + (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`; + `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`
+ wantLine;
panel.style.display = 'block'; panel.style.display = 'block';
panel.querySelector('.x').onclick = unpull; panel.querySelector('.x').onclick = unpull;
const wantEl = panel.querySelector('.want');
if (wantEl && !wanted) wantEl.onclick = () => {
if (onWantCb(o) !== false) { wantEl.classList.add('on'); wantEl.textContent = '★ ON THE WANTLIST'; wantEl.onclick = null; }
};
const b = panel.querySelector('button'); const b = panel.querySelector('button');
if (b && !b.disabled) b.onclick = () => { if (b && !b.disabled) b.onclick = () => {
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin. // STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
@ -342,13 +376,16 @@ export function createDig(THREE, renderer) {
// Both forms hide the sold record, so "the gone item never renders" cannot tell them apart — the // Both forms hide the sold record, so "the gone item never renders" cannot tell them apart — the
// collateral is what a gate must assert on. F: update item FIELDS in place (live price) freely; do NOT // collateral is what a gate must assert on. F: update item FIELDS in place (live price) freely; do NOT
// add, remove, or reorder `pack.items` — that identity/order IS the seeded pick's stability. // add, remove, or reorder `pack.items` — that identity/order IS the seeded pick's stability.
function pickRealOffers(pack, seed, n) { function pickRealOffers(pack, seed, n, pulledGone) {
const rr = mulberry32(seed >>> 0), out = []; const rr = mulberry32(seed >>> 0), out = [];
const gone = pack.gone instanceof Set ? pack.gone const gone = pack.gone instanceof Set ? pack.gone
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null); : (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0]; const it = pack.items[(rr() * pack.items.length) | 0];
if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty
// [R32 scarcity] the PLAYER bought it (this day, this bin) → same pick-then-omit seam, same
// zero-collateral guarantee: the pull leaves, the survivors keep their slots.
if (pulledGone && pulledGone.has('sku:' + it.id)) continue;
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on // Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index, // POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated. // so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
@ -358,22 +395,30 @@ export function createDig(THREE, renderer) {
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price, out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: band === 'GRAIL' ? '' : band, y: '', s: band === 'GRAIL' ? '' : band, y: '',
cond: it.condition || '', sleeveCond: it.sleeve_cond || '', cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
band: it.price_band || null, // [R32] canonical band for the guide stamp + the sell basis
rare: it.price_band === 'grail' }); rare: it.price_band === 'grail' });
} }
return out; return out;
} }
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters }) // open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters, gone })
// `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the // `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the
// right distance. Omit it and the till still rings, just un-positioned. Never required. // right distance. Omit it and the till still rings, just un-positioned. Never required.
// `gone` [R32, OPTIONAL] — a Set of THIS bin's already-pulled slots ('sku:<id>' for pack items, the
// stringified offer index for parody), from the save's pull records. Omission happens AFTER the full
// seeded draw (both paths), so the survivors keep their slots — the R27 zero-collateral law. Absent
// (?game=0 / classic / fresh day) ⇒ the bin riffles exactly as it always did.
function open(opts = {}) { function open(opts = {}) {
active = true; active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null; onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
onWantCb = opts.onWant || null; isWantedCb = opts.isWanted || null; // [R33] wantlist seams
emitters = opts.emitters || null; emitters = opts.emitters || null;
const count = opts.count || 16; const count = opts.count || 16;
const gone = opts.gone instanceof Set && opts.gone.size ? opts.gone : null;
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves. // riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve'); const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count); offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count, gone)
: proceduralSleeves(opts.seed ?? 1, count).filter((o) => !(gone && gone.has(String(o.i))));
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : ''); curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block'); [hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers); buildStack(offers);
@ -411,7 +456,16 @@ export function createDig(THREE, renderer) {
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove()); [css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
} }
return { scene, camera, open, close, update, dispose, get active() { return active; } }; // [R32 — the honest-gate surface, R24 precedent] What is in THIS open bin, BY IDENTITY: original
// offer index, asking, band, sku (pack items). Read-only snapshots; null when closed. F's bible/
// scarcity gate asserts on these instead of trusting "the crate looked right".
function currentOffers() {
if (!active) return null;
return recs.map((r) => ({ i: r.offer.i ?? null, price: r.offer.price, band: r.offer.band || null,
sku: r.offer.item ? String(r.offer.item.id) : null }));
}
return { scene, camera, open, close, update, dispose, currentOffers, get active() { return active; } };
} }
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time. // A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.

View File

@ -172,6 +172,10 @@ export function buildInterior(shop, THREE, opts) {
const norm = normalizeShop(shop); const norm = normalizeShop(shop);
const recipe = getRecipe(norm.type); const recipe = getRecipe(norm.type);
const ctx = new Ctx(THREE, norm.seed); 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 // room shape → dims → shell
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype); 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) { for (const p of placed) {
if (p.removed) continue; if (p.removed) continue;
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group); 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 || [])); places.push(...(p.fitting.places || []));
} }

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

@ -0,0 +1,166 @@
// 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.
import { sellBasis, guideText } from './bible.js'; // [R32] THE BIBLE — the §9.1 beta basis + the card's guide line
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). [R32 — the pre-ratified beta swap, executed] Basis = bandLow when the entry carries
// a band the BIBLE knows (sellBasis, bible.js), else the alpha basis (pricePaid) — so alpha-era finds
// sell exactly as they always did. THE CLAMP SHAPE IS UNTOUCHED: the offer sits strictly below the
// BASIS for every basis ≥ 1, which with bandLow as basis is the no-pump law's beta form — "every sell
// price < the buy-side guide band, structurally" (charter law #2). A gem (bought UNDER band) nets its
// finder margin against the band, never against the keeper; scarcity (the save's pulls) makes that
// margin a finite find, not a loop. p=1 → $0 (the SELL button disables).
export function sellOffer(basis) {
const p = Math.floor(+basis || 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(sellBasis(it)); // [R32] bandLow basis when banded, pricePaid else
const head = labelOf(it) || titleOf(it);
const ttl = labelOf(it) ? titleOf(it) : '';
const paid = it.pricePaid != null ? it.pricePaid : it.price;
const guide = guideText(it.type, it.band); // [R32] '' when unbanded → no stray separator (§7.2c)
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) : ''}${guide ? ' · ' + esc(guide) : ''}${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(); notify();
return true; 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(), inventory: () => inv.slice(),
count: () => inv.length, count: () => inv.length,
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, 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). // 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))]; 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(); const latch = new Map();
function buildLatches() {
latch.clear();
for (const id of venueIds) { for (const id of venueIds) {
const tonight = gigs.find((g) => g.venueShopId === id && g.night === 0) || null; const tonight = gigs.find((g) => g.venueShopId === id && g.night === weekNight) || null;
latch.set(id, createVenueLatch(tonight, lighting)); latch.set(id, createVenueLatch(tonight, lighting));
} }
}
buildLatches();
// ONE listener drives every latch — the listens-not-polls law, now fanned out across the district. // 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); }; 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; const L = (id) => latch.get(id) || null;
return { 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) ────────────────────────────────────────────────────────────── // ── per-venue API (R13 district) ──────────────────────────────────────────────────────────────
get venueShopIds() { return venueIds.slice(); }, get venueShopIds() { return venueIds.slice(); },
stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; }, stateOf(id) { const l = L(id); return l ? l.state : 'quiet'; },

View File

@ -21,12 +21,26 @@ const CSS = `
#pc-dbg b{color:#ffd75e;font-weight:600} #pc-dbg b{color:#ffd75e;font-weight:600}
#pc-dbg .warn{color:#ff9a6a} #pc-dbg .warn{color:#ff9a6a}
#pc-clock{font-size:15px;margin-bottom:3px} #pc-clock{font-size:15px;margin-bottom:3px}
#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000} #pc-toast{position:fixed;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000;z-index:31;max-width:80vw;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;pointer-events:none}
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85} #pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
#pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s} #pc-townpick{position:absolute;left:10px;top:10px;pointer-events:auto;opacity:.8;transition:opacity .12s}
#pc-townpick:hover{opacity:1} #pc-townpick:hover{opacity:1}
#pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer} #pc-townpick select{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:5px 9px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;max-width:230px;cursor:pointer}
#pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)} #pc-townpick select:focus{outline:1px solid rgba(255,215,94,.6)}
#pc-game{position:fixed;right:10px;bottom:12px;z-index:30;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
#pc-gamebar{background:rgba(20,16,10,.72);padding:6px 10px;border-radius:10px;display:flex;gap:10px;align-items:center;pointer-events:auto;text-shadow:0 1px 2px #000}
#pc-gamebar b{color:#ffd75e;font-weight:600}
#pc-gamebar button{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);border-radius:8px;padding:4px 10px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer}
#pc-gamebar button:hover{border-color:rgba(255,215,94,.7);color:#ffd75e}
#pc-crate{width:344px;max-height:min(58vh,470px);overflow-y:auto;background:rgba(20,16,10,.93);border:1px solid rgba(255,215,94,.35);border-radius:12px;padding:10px 12px;pointer-events:auto;display:none}
#pc-crate h3{font-size:13px;color:#ffd75e;margin:0 0 6px;font-weight:600;letter-spacing:.04em}
.pc-crate-row{display:flex;gap:9px;align-items:center;padding:5px 0;border-top:1px solid rgba(255,244,230,.08)}
.pc-thumb{width:44px;height:44px;flex:none;border-radius:4px;background-color:#3a3128;background-repeat:no-repeat;display:flex;align-items:center;justify-content:center;color:rgba(255,244,230,.55);font-size:17px;box-shadow:0 1px 3px rgba(0,0,0,.5)}
.pc-crate-main{min-width:0}
.pc-crate-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pc-crate-artist{opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pc-crate-meta{font-size:11px;opacity:.62}
.pc-crate-empty{opacity:.7;padding:4px 0 2px}
`; `;
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) { export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
@ -53,6 +67,9 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
const $ = (id) => root.querySelector('#' + id); const $ = (id) => root.querySelector('#' + id);
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'), const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast'); elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
// [R35] toasts surface in EVERY mode (beats fire inside shops; a SLEEP taken at a counter must
// still show its morning paper) — same body-mount rule as #pc-game, same dispose duty.
document.body.appendChild(elToast);
$('pc-town').textContent = plan.name || '—'; $('pc-town').textContent = plan.name || '—';
$('pc-seed').textContent = plan.citySeed; $('pc-seed').textContent = plan.citySeed;
@ -98,6 +115,189 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
.catch(() => {}); // index missing/blocked → fallback stands, zero errors .catch(() => {}); // index missing/blocked → fallback stands, zero errors
})(); })();
// ── v7 THE GAME (R30, ledger #4): the collection panel + the SLEEP surface + day/cash readout.
// Built LAZILY on the first update() frame that sees a truthy `window.PROCITY.game` — the shell
// constructs the game layer AFTER the HUD, and under ?classic=1 / ?game=0 it is null forever, so
// NONE of this DOM (bar, panel, KeyC listener, help-line suffix) ever exists on those boots: the
// R19 selector's construction-gating, applied to the game (the classic-pure law, ledger #5's gate
// measures it). Consumes ONLY the LANE_F_NOTES §30.1 contract: day / cash / collection (read-only
// — this UI never mutates it) / sleep(). Cover thumbs resolve the entry's sku against its pack
// index (per-shop godverse base when godverseShopId is set, else the town-wide base) — fail-soft:
// missing pack / unknown sku / remote atlas / ?noassets ⇒ the type-glyph placeholder stands,
// zero errors (the R21 towns-index discipline). Parody finds (slotId, no sku) have no thumb by
// design (§30.4). ──
let gameUI = null; // built once; null forever when the game layer is absent
let onGameKey = null; // KeyC crate toggle — registered only when the game UI exists
const shopById = new Map((plan.shops || []).map((s) => [s.id, s]));
const GAME_NOASSETS = (() => { try { const v = new URLSearchParams(location.search).get('noassets'); return v != null && v !== '0'; } catch { return false; } })();
const TYPE_GLYPH = { record: '♪', book: '¶', toy: '✦' };
const _packIdx = new Map(); // `${base}|${type}` → Promise<index.json|null> — B's own read-only cache
function packIndexFor(entry) {
if (GAME_NOASSETS || !entry || !entry.sku || !entry.type) return Promise.resolve(null);
const base = entry.godverseShopId != null ? `assets/stock_godverse/${entry.godverseShopId}/` : 'assets/models/';
const key = base + '|' + entry.type;
if (!_packIdx.has(key))
_packIdx.set(key, fetch(`${base}stock_${entry.type}_index.json`).then((r) => (r.ok ? r.json() : null)).catch(() => null));
return _packIdx.get(key).then((idx) => (idx && Array.isArray(idx.items) ? { idx, base } : null));
}
function applyThumb(el, entry) {
packIndexFor(entry).then((p) => {
if (!p || !el.isConnected) return;
const it = p.idx.items.find((i) => i && String(i.id) === entry.sku);
if (!it || !it.atlas || !Array.isArray(it.uv) || /^depot:|^https?:/.test(it.atlas)) return;
const [u0, v0, u1, v1] = it.uv, du = u1 - u0, dv = v1 - v0;
if (!(du > 0) || !(dv > 0)) return;
el.textContent = ''; // atlas crop via CSS background — uv origin is top-left (image-natural), same as CSS
el.style.backgroundImage = `url("${p.base + it.atlas}")`;
el.style.backgroundSize = `${100 / du}% ${100 / dv}%`;
el.style.backgroundPosition = `${du < 1 ? (u0 / (1 - du)) * 100 : 0}% ${dv < 1 ? (v0 / (1 - dv)) * 100 : 0}%`;
});
}
// where the find happened: this boot's town → the shop's name off the plan; another town's find →
// the town part of its townKey (`plansrc/town@seed`) — honest, never guessed.
function foundAt(entry, game) {
if (entry.townKey === game.townKey) { const s = shopById.get(entry.shopId); if (s && s.name) return s.name; }
const m = /^[^/]+\/([^@]+)@/.exec(entry.townKey || '');
return m ? m[1].replace(/_/g, ' ') : '?';
}
function buildGameUI(game) {
const wrap = document.createElement('div'); wrap.id = 'pc-game';
const panel = document.createElement('div'); panel.id = 'pc-crate';
const bar = document.createElement('div'); bar.id = 'pc-gamebar';
bar.innerHTML = `<span>day <b id="pc-day">—</b> · $<b id="pc-cash">—</b></span>
<button id="pc-crate-btn" title="The collection (C toggles; Esc frees the pointer first)">CRATE 0</button>
<button id="pc-sleep-btn" title="Sleep — save, wake tomorrow"> SLEEP</button>`;
// [R32 §31.6-2 — the crate at the counter] The game surface rides document.body, NOT the street-HUD
// root: the street root hides in interior mode (by design), but your day/cash/crate are YOURS in a
// shop too — the playtest's "why can't I check the crate at the counter". It yields the screen to
// the dig/sell cards and the map (visibility below); ?classic=1 / ?game=0 never build any of this.
wrap.append(panel, bar); document.body.appendChild(wrap);
const elDay = bar.querySelector('#pc-day'), elCash = bar.querySelector('#pc-cash'),
btnCrate = bar.querySelector('#pc-crate-btn'), btnSleep = bar.querySelector('#pc-sleep-btn');
let shownDay = null, shownCash = null, shownCount = null, shownWants = null, open = false;
function rebuildList() {
const col = game.collection || [];
panel.textContent = '';
const h = document.createElement('h3');
const spent = col.reduce((a, e) => a + (e.pricePaid || 0), 0);
h.textContent = `THE CRATE — ${col.length} find${col.length === 1 ? '' : 's'} · $${spent} spent`;
panel.appendChild(h);
if (!col.length) {
const d = document.createElement('div'); d.className = 'pc-crate-empty';
d.textContent = 'Empty — go dig.'; panel.appendChild(d);
}
for (let i = col.length - 1; i >= 0; i--) { // newest find first
const e = col[i];
const row = document.createElement('div'); row.className = 'pc-crate-row';
const th = document.createElement('div'); th.className = 'pc-thumb';
th.textContent = TYPE_GLYPH[e.type] || '♦';
applyThumb(th, e);
const main = document.createElement('div'); main.className = 'pc-crate-main';
const t = document.createElement('div'); t.className = 'pc-crate-title';
t.textContent = e.title || e.sku || e.slotId || 'unknown'; // C's fallback rule (§30.1)
const a = document.createElement('div'); a.className = 'pc-crate-artist';
a.textContent = e.artist || '';
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
main.append(t, a, meta);
row.append(th, main);
// [R34 GODBAY] ⚖ LIST — mail-in consignment; the entry leaves the crate, hammers tomorrow.
if (typeof game.listFind === 'function') {
const lb = document.createElement('div');
lb.textContent = '⚖'; lb.title = 'LIST at GodBay — the auction resolves tomorrow';
lb.style.cssText = 'cursor:pointer;opacity:.65;padding:0 4px;flex:none;font-size:15px';
lb.onclick = () => { if (game.listFind(e)) rebuildList(); };
row.appendChild(lb);
}
panel.appendChild(row);
}
// [R34] AT AUCTION — consigned finds, hammering overnight. Non-empty only (no header over nothing).
const listings = game.listings || [];
if (listings.length) {
const lh = document.createElement('h3');
lh.style.marginTop = '10px';
lh.textContent = `AT AUCTION — ${listings.length} consigned · hammers tomorrow`;
panel.appendChild(lh);
for (const l of listings) {
const row = document.createElement('div'); row.className = 'pc-crate-row';
const main = document.createElement('div'); main.className = 'pc-crate-main';
const t = document.createElement('div'); t.className = 'pc-crate-title';
t.textContent = l.title || l.sku || l.slotId || 'item';
const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
meta.textContent = `paid $${l.pricePaid} · listed day ${l.listDay}`;
main.append(t, meta);
row.append(main); panel.appendChild(row);
}
}
// [R33] THE WANTLIST — what you're hunting (☆ on a pull card adds; ✕ here removes; buying one
// clears it automatically). Section exists only when non-empty — no stray header over nothing.
const wants = game.wants || [];
if (wants.length) {
const wh = document.createElement('h3');
wh.style.marginTop = '10px';
wh.textContent = `THE WANTLIST — ${wants.length} hunted`;
panel.appendChild(wh);
for (const w of wants) {
const row = document.createElement('div'); row.className = 'pc-crate-row';
const main = document.createElement('div'); main.className = 'pc-crate-main';
const t = document.createElement('div'); t.className = 'pc-crate-title';
t.textContent = w.artist || w.title || w.sku;
const a = document.createElement('div'); a.className = 'pc-crate-artist';
a.textContent = w.artist && w.title ? `${w.title}` : '';
main.append(t, a);
const x = document.createElement('div');
x.textContent = '✕'; x.title = 'stop hunting this';
x.style.cssText = 'cursor:pointer;opacity:.6;padding:0 4px;flex:none';
x.onclick = () => { game.removeWant(w); rebuildList(); };
row.append(main, x); panel.appendChild(row);
}
}
}
function setOpen(v) {
open = v; panel.style.display = v ? 'block' : 'none';
if (v) { shownCount = (game.collection || []).length; rebuildList(); }
}
btnCrate.onclick = () => setOpen(!open);
// SLEEP — enabled any time (alpha; home-base gating is beta). The shell's onDay hook owns the
// wake toast; the HUD just refreshes its readouts off the contract getters.
btnSleep.onclick = () => {
try { game.sleep(); } catch (err) { console.warn('[hud] sleep failed:', err); }
refresh();
};
function refresh() {
const col = game.collection || [];
if (game.day !== shownDay) { shownDay = game.day; elDay.textContent = shownDay; }
if (game.cash !== shownCash) { shownCash = game.cash; elCash.textContent = shownCash; }
if (col.length !== shownCount) {
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
if (open) rebuildList();
}
const nw = (game.wants || []).length + (game.listings || []).length * 100; // [R33/R34] wants OR
if (nw !== shownWants) { shownWants = nw; if (open) rebuildList(); } // listings change → rebuild
// [R32] the surface yields to the map and to the dig/sell cards (they own the screen), and shows
// everywhere else — street AND interior. Cheap: a style write only when the answer changes.
const P = window.PROCITY, im = P && P.interiorMode;
const yield_ = (P && P.mode === 'map') || !!(im && (im.digActive || im.sellActive));
const want = yield_ ? 'none' : 'flex';
if (wrap.style.display !== want) wrap.style.display = want;
}
refresh();
onGameKey = (e) => { // [R32 §31.6-2] C toggles the crate on the street AND in a shop (not over a card)
if (e.code !== 'KeyC') return;
const P = window.PROCITY, im = P && P.interiorMode;
if (P && P.mode === 'map') return;
if (im && (im.digActive || im.sellActive)) return; // the cards own the keys while open
setOpen(!open);
};
addEventListener('keydown', onGameKey);
const help = root.querySelector('#pc-help');
if (help) help.textContent += ' · C crate';
return { refresh, wrap }; // wrap rides document.body → dispose() must remove it itself
}
const ray = new THREE.Raycaster(); const ray = new THREE.Raycaster();
ray.far = 6; ray.far = 6;
const centre = new THREE.Vector2(0, 0); const centre = new THREE.Vector2(0, 0);
@ -136,6 +336,12 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
frame++; frame++;
if (frame % 6 === 0) raycastDoor(); if (frame % 6 === 0) raycastDoor();
// v7 game surface — lazy construction + refresh both live in tickGame() (R33 fix: the build used
// to happen ONLY here in street frames, so a door entered fast after boot had no crate/bar inside
// at all — the interior branch could refresh a surface that never got built). Throttled here; the
// interior/map branches call tickGame() per frame (cheap: change-guarded property reads).
if (frame % 6 === 3) tickGame();
// tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5) // tooltip — shows CLOSED (with opening hour) when the shop is shut at the current hour (§3.5)
if (hovered) { if (hovered) {
const h = hovered.hours; const h = hovered.hours;
@ -174,9 +380,24 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
function dispose() { function dispose() {
renderer.domElement.removeEventListener('click', onClick); renderer.domElement.removeEventListener('click', onClick);
if (onGameKey) removeEventListener('keydown', onGameKey);
if (gameUI && gameUI.wrap) gameUI.wrap.remove(); // [R32] the game surface lives on document.body now
elToast.remove(); // [R35] so does the toast
root.remove(); root.remove();
} }
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered, // [R32/R33] the game surface ticks in EVERY mode (the street path reaches it via update(); interior/
// map call this directly) — and BUILDS here too, so the surface exists no matter which mode first
// sees a truthy game. day/cash/crate stay live while you shop; the card-yield check runs here.
function tickGame() {
if (!gameUI) {
const g = window.PROCITY && window.PROCITY.game;
if (g) gameUI = buildGameUI(g);
return;
}
gameUI.refresh();
}
return { update, tickToast, showToast, setVisible, tickGame, dispose, getHovered: () => hovered,
getFps: () => fpsShown }; getFps: () => fpsShown };
} }

View File

@ -19,7 +19,9 @@ import { buildInterior, getStockPack, makeStockAdapter } from '../interiors/inte
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1) import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1) import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
import { mulberry32, xmur3 } from '../core/prng.js'; // [R33] the rumor roll (seeded, runtime-only)
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes) import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4)
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it) const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
const RADIUS = 0.30; // player collision radius inside the room const RADIUS = 0.30; // player collision radius inside the room
@ -36,6 +38,11 @@ const EXIT_DIST = 1.0; // …then coming back within this distance leaves to th
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12. // lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on' const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
// [R33 — THE RUMOR] where a keeper's mutter can point: the repo's real cached towns (display names).
// Flavor + a travel nudge, not a promise — the 90s had no stock lookups, and the town-wide packs mean
// every town CAN surface any hunted record eventually. Full persona lines are D's when the clip work lands.
const RUMOR_TOWNS = ['Katoomba', 'Newtown', 'Fremantle', 'Bendigo', 'Castlemaine'];
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop // [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's // pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built // lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
@ -98,7 +105,7 @@ function applyLive(pack, live) {
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false, export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
dig: digEnabled = false, stockReal = false, wallet = null, dig: digEnabled = false, stockReal = false, wallet = null,
occupancyOf = null, gigState = null, rosterOf = null, occupancyOf = null, gigState = null, rosterOf = null,
liveBase = null, sourcingOf = null }) { liveBase = null, sourcingOf = null, game = null }) {
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07'); scene.background = new THREE.Color('#0e0b07');
@ -114,9 +121,13 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
let current = null; // active Lane C interior handle let current = null; // active Lane C interior handle
let currentShop = null; // the shop record for the active interior (dig per-bin seeding) 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 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 currentRealSourced = false; // [R32] this room's crate is sourcing:'real' — its pulls never expire
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit 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 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) let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
let sellCard = null; // [R30 §9.4] Lane C sell card, created lazily on first counter-E (only when the game is on)
const _fwd = new THREE.Vector3(); const _fwd = new THREE.Vector3();
const _right = new THREE.Vector3(); const _right = new THREE.Vector3();
@ -127,7 +138,12 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// in-game *input/mode* hook (its LANE_C_NOTES contract) — the same consumer pattern as Lane C's // in-game *input/mode* hook (its LANE_C_NOTES contract) — the same consumer pattern as Lane C's
// interior_test.html: aim at a record bin, press E → open the riffle, which owns the frame + cursor // interior_test.html: aim at a record bin, press E → open the riffle, which owns the frame + cursor
// (pointer must be UNLOCKED — the shell handles that on procity:digOpen). Flag off ⇒ none of this runs. // (pointer must be UNLOCKED — the shell handles that on procity:digOpen). Flag off ⇒ none of this runs.
function binUnderAim() { // [R30 §9.4 amendment] strictAim skips the proximity fallback. The fallback ignores aim entirely
// (nearest bin within 2.5 m of the CAMERA), so at a counter that sits near a bin it would swallow
// the E and the sell card could never open there. The routing below therefore asks twice: first
// AIMED-only (the contract's "aimed bin → dig"), then — only after the sell trigger has declined —
// the forgiving nearest-bin pick, so imperfect aim at a bin still opens the riffle away from a sale.
function binUnderAim(strictAim = false) {
if (!current) return null; if (!current) return null;
const bins = []; const bins = [];
current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); }); current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
@ -139,6 +155,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent; while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent;
return o; return o;
} }
if (strictAim) return null;
// Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's // Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's
// world position, so it works even before the bin's GLB mesh has finished loading (async) and is // world position, so it works even before the bin's GLB mesh has finished loading (async) and is
// forgiving of imperfect aim — stand next to a bin, press E. // forgiving of imperfect aim — stand next to a bin, press E.
@ -153,15 +170,53 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (dig.active) return; if (dig.active) return;
const p = new THREE.Vector3(); bin.getWorldPosition(p); 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) 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 : '';
// [R32 scarcity] the PULL key's day tag is broader than the SEED's: a rotating bin is pulled-from
// per (bin, day) even on day 1 (where the seed stays untagged by the day-1 convention), while a
// REAL-sourced crate stays untagged forever — sold means gone, so its pulls never expire. The seed
// input above is UNTOUCHED (A's rotation-boundary law); this tag exists only in the save's ledger.
const pullTag = (game && !currentRealSourced) ? '@d' + game.day : '';
const pullPrefix = `${(currentShop && currentShop.id) ?? '?'}|${key}${pullTag}#`;
dig.open({ 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), shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
shop: currentShop, shop: currentShop,
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody) stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it. // [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). // 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.
// [R32] the find carries its BAND (the sell counter's §9.1 basis) and the pull enters the save's
// ledger — the slot is gone for the rest of the day even if the item is later sold on.
getCash: wallet ? () => wallet.cash() : undefined, getCash: wallet ? () => wallet.cash() : undefined,
onBuy: wallet ? (item) => wallet.buy(item) : undefined, // [R33] the wantlist seams — game-only (undefined ⇒ dig builds zero WANT DOM, classic-pure)
onWant: game ? (o) => game.recordWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined }) : undefined,
isWanted: game ? (o) => !!game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
sku: o && o.item && o.item.id != null ? String(o.item.id) : '' }) : undefined,
onBuy: wallet ? (o) => {
const ok = wallet.buy(o);
if (ok && game) {
const sku = o && o.item && o.item.id != null ? String(o.item.id) : null;
game.recordFind(currentShop, {
type: 'record',
sku: sku ?? undefined,
slotId: sku != 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,
band: (o && o.band) || undefined,
});
game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0)));
// [R33] the hunt ends when you're holding it: buying a wanted item clears its want
const w = game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), sku: sku || '' });
if (w) { game.removeWant(w); flashToast('✓ off the wantlist'); }
}
return ok;
} : undefined,
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')), onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
}); });
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
@ -190,6 +245,16 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (!best) return; if (!best) return;
const item = best.item || {}; const item = best.item || {};
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) { 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,
band: item.price_band || undefined, // [R32] the shelf find carries its band too (§9.1 basis)
});
collapseBuyItem(mesh, best); // zero-area the bought quad in place collapseBuyItem(mesh, best); // zero-area the bought quad in place
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`); flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
} else { } else {
@ -197,11 +262,41 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
} }
} }
// [Lane F R30 — THE SELL ROUTING (C's §9.4, F's held join)] E at the keeper's counter holding items
// this shop trades in → C's sell card. F does both halves of the §9.3 seam, in this order: removal
// via THE GAME API first (identity — removeFind false ⇒ keeper veto, nothing moves, and no credit
// can ever be minted for an item that is not in the collection), then the credit through the SAME
// wallet facade every buy seam uses. C's card never touches game state; this closure is the only
// place a sale moves money or the collection. No game layer ⇒ trySell is a no-op (classic-pure, C §9.5).
function trySell() {
if (!game || !wallet || !current || !currentShop) return false;
if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench
if (!sellCard) sellCard = createSell();
if (sellCard.active) return true;
const opened = sellCard.open({
shopType: currentShop.type,
shopName: (current.recipe && current.recipe.label) || currentShop.name,
items: game.collection, // READ-ONLY — sell.js cards a filtered copy (§9.3)
getCash: () => wallet.cash(),
emitters: current.audio && current.audio.emitters,
onSell: (item, offer) => {
if (!game.removeFind(item)) return false; // not in the collection ⇒ veto: card unchanged, $0 moves
wallet.sell(item, offer); // credit rides the proven seam (mirrors wallet.js sell)
return true;
},
onClose: () => window.dispatchEvent(new CustomEvent('procity:sellClose')),
});
if (opened) window.dispatchEvent(new CustomEvent('procity:sellOpen')); // shell releases pointer-lock (dig precedent)
return opened;
}
if (digEnabled || wallet) { if (digEnabled || wallet) {
addEventListener('keydown', (e) => { addEventListener('keydown', (e) => {
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return; if (e.code !== 'KeyE' || !current || (dig && dig.active) || (sellCard && sellCard.active)) return;
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle if (digEnabled) { const bin = binUnderAim(true); if (bin) { openDig(bin); return; } } // AIMED bin → riffle (§9.4 first branch)
buyShelfUnderAim(); // shelf → buy if (trySell()) return; // [R30 §9.4] near the counter with ≥1 sellable → the sell card
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // forgiving nearest-bin (away from a sale)
buyShelfUnderAim(); // else shelf → buy
}); });
} }
@ -276,7 +371,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced. // (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
const gigOn = !!(gigState && gigState.onOf(shop.id)); const gigOn = !!(gigState && gigState.onOf(shop.id));
const tonight = gigOn ? gigState.gigOf(shop.id) : null; 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');
currentRealSourced = realSourced; // [R32] pull-ledger tagging reads this at dig-open time
currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null;
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter, current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
stockDay: currentStockDay,
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null }); gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
currentShop = shop; currentShop = shop;
scene.add(current.group); scene.add(current.group);
@ -327,9 +430,28 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
exitArmed = false; exitArmed = false;
let hasBins = false; let hasBins = false;
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; }); if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
// [R30 §9.4] the sell hint shows only when the player actually HOLDS something this shop trades in
// (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E.
const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type));
// [R33 — THE RUMOR, charter #6, the keeper finally gossips] Seeded per (citySeed, shop, day): a
// ~45% mutter naming a hunted want + a real town — the reason to ride somewhere tomorrow. Runtime
// stream, freshly keyed: no plan/golden moves (A's boundary). No game / empty wantlist ⇒ silence,
// and the banner line simply doesn't exist (classic-pure by construction).
let rumor = '';
if (game && game.wants && game.wants.length) {
const rr = mulberry32(xmur3(`rumor:${plan.citySeed}:${shop.id}:${game.day}`)() >>> 0);
if (rr() < 0.45) {
const w = game.wants[(rr() * game.wants.length) | 0];
const towns = RUMOR_TOWNS.filter((t) => !(plan.name || '').toLowerCase().includes(t.toLowerCase()));
const town = towns[(rr() * towns.length) | 0] || RUMOR_TOWNS[0];
rumor = ` · 🗣 “${String(w.artist || w.title || w.sku).toUpperCase()}? saw one in ${town} just last week”`;
}
}
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave` banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '') + (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
+ (hasBins ? ' · E: riffle a record bin' : ''); + (hasBins ? ' · E: riffle a record bin' : '')
+ (canSell ? ' · E at the counter: sell' : '')
+ rumor;
banner.style.display = 'block'; banner.style.display = 'block';
return current; return current;
} }
@ -387,12 +509,15 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
if (!current) return; if (!current) return;
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s) window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU) if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
if (sellCard && sellCard.active) sellCard.close(); // [R30] the sell card never outlives the room
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
current.dispose(); current.dispose();
current = null; current = null;
currentShop = null; currentShop = null;
currentAdapter = null; currentAdapter = null;
currentStockDay = null;
currentRealSourced = false;
banner.style.display = 'none'; banner.style.display = 'none';
toast.style.display = 'none'; _toastT = 0; toast.style.display = 'none'; _toastT = 0;
if (doorReturn) { if (doorReturn) {
@ -412,6 +537,10 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered) get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered)
get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card
// [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no
// dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked.
get digOffers() { return dig && dig.active ? dig.currentOffers() : null; },
// [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY // [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY
// IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack // IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack

View File

@ -38,17 +38,29 @@ function gradientEquirect(THREE) {
return tex; return tex;
} }
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) { export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'lighting'; group.name = 'lighting';
scene.add(group); scene.add(group);
// sky dome — seeded photo skin, darkened at night via material colour // sky dome — seeded photo skin, darkened at night via material colour
// [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a
// golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN
// segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override
// (rain/overcast) OWNS the dome outright — the dawn swap never fights it.
const skyName = plan.sky || 'golden-arvo'; const skyName = plan.sky || 'golden-arvo';
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false }); const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after
let shownSky = skyName;
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat); const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
group.add(dome); group.add(dome);
function applySkyFor(segIdx) {
const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName);
if (want === shownSky) return;
shownSky = want;
skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
}
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0); const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
group.add(hemi); group.add(hemi);
@ -76,6 +88,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
function apply(i) { function apply(i) {
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i]; const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments)
sun.intensity = sunI; sun.color.set(sunC); sun.intensity = sunI; sun.color.set(sunC);
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd); hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
_skyTint.set(skyTint); skyMat.color.copy(_skyTint); _skyTint.set(skyTint); skyMat.color.copy(_skyTint);
@ -95,7 +108,11 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky). // Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map. // The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); } // [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on.
function setSky(name) {
externalSky = name; shownSky = name;
skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
}
const _sunOff = new THREE.Vector3(); const _sunOff = new THREE.Vector3();
function update(dt, playerPos) { function update(dt, playerPos) {

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

@ -0,0 +1,388 @@
// 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';
// ── GODBAY v0 — the offline seeded auction (R34, charter system #4; laws in ROUND34_INSTRUCTIONS) ──
import { xmur3, mulberry32 } from '../core/prng.js';
import { BANDS as GODBAY_BANDS } from '../interiors/bible.js'; // C's pure band table — one authority, no mirror
const GODBAY_FEE = 0.12; // the house always eats
const GODBAY_PASS = 0.15; // passed in — no bids, item comes home
const GODBAY_WAR = 0.12; // a bidding war: hammer ×1.52.0
// THE ONE-HAMMER LAW: seeded by ITEM IDENTITY, never by the resolution day — a passed-in item
// re-lists to the same result forever, so there is no retry farm anywhere in the economy.
// → { sold, hammer, net } | { sold:false }. Fixed draw count (4), outcome-independent.
export function godbayHammer(entry, townKey) {
const id = `godbay:${townKey}:${entry.sku || entry.slotId}:${entry.dayFound}:${entry.pricePaid}`;
const r = mulberry32(xmur3(id)());
const passRoll = r(), posRoll = r(), warRoll = r(), warSizeRoll = r();
const bands = GODBAY_BANDS[entry.type] || GODBAY_BANDS.record;
const range = bands[entry.band] || null;
// an unbanded entry (alpha-era find) auctions around what was paid — honest, never invented
const lo = range ? range[0] : Math.max(1, entry.pricePaid);
const hi = range ? (range[1] == null ? lo * 2 : range[1]) : Math.max(1, entry.pricePaid);
if (passRoll < GODBAY_PASS) return { sold: false };
let hammer = lo + Math.floor(posRoll * (hi - lo + 1));
if (warRoll < GODBAY_WAR) hammer = Math.floor(hammer * (1.5 + warSizeRoll * 0.5));
const net = Math.max(1, Math.floor(hammer * (1 - GODBAY_FEE)));
return { sold: true, hammer, net };
}
// ── 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` };
// [R32 scarcity] `pulls` is OPTIONAL (absent on every alpha save — those stay valid forever); when
// present it must be an array of strings (exact-and-loud like everything else). An old loader that
// predates this field simply ignores and drops it on its next save — degraded, never rejected.
if (obj.pulls != null) {
if (!Array.isArray(obj.pulls)) return { ok: false, why: 'pulls not an array' };
for (let i = 0; i < obj.pulls.length; i++)
if (typeof obj.pulls[i] !== 'string' || !obj.pulls[i]) return { ok: false, why: `pulls[${i}] malformed` };
}
// [R34 GODBAY] `listings` is OPTIONAL, same law: collection-shaped entries + an int listDay ≥ 1.
if (obj.listings != null) {
if (!Array.isArray(obj.listings)) return { ok: false, why: 'listings not an array' };
for (let i = 0; i < obj.listings.length; i++) {
const l = obj.listings[i];
if (!validEntry(l) || !Number.isInteger(l.listDay) || l.listDay < 1)
return { ok: false, why: `listings[${i}] malformed` };
}
}
// [R33 wantlist] `wants` is OPTIONAL, same law: array of {artist?|title?|sku?} string fields, at
// least one present per entry — a want with nothing to hunt is malformed, not empty-but-fine.
if (obj.wants != null) {
if (!Array.isArray(obj.wants)) return { ok: false, why: 'wants not an array' };
for (let i = 0; i < obj.wants.length; i++) {
const w = obj.wants[i];
if (!w || typeof w !== 'object' || Array.isArray(w)) return { ok: false, why: `wants[${i}] malformed` };
const f = ['artist', 'title', 'sku'].filter((k) => w[k] != null);
if (!f.length || f.some((k) => typeof w[k] !== 'string' || !w[k])) return { ok: false, why: `wants[${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 = [];
// [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried.
let wants = [];
const WANTS_CAP = 40;
// [R34 GODBAY] consigned finds ({...entry, listDay}) + this boot's transient morning report.
let listings = [];
let auctionNews = []; // NOT saved — the morning paper, not the ledger
// [R33 travel] true when THIS boot adopted a save from a DIFFERENT town — the ride cost a day.
let traveled = false;
// [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER
// (interior_mode: `${shopId}|${binKey}@d<day>#<slot>`; real-sourced crates omit the day tag — real
// stock never rotates, so a real pull is gone for good). The save only stores and prunes them.
let pulls = [];
const PULLS_CAP = 600; // FIFO backstop — a save stays tiny (delta law)
const PULL_DAY_RE = /@d(\d+)#/;
// ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ────────────
function payload() {
const p = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() };
if (pulls.length) p.pulls = pulls; // absent when empty → alpha-era byte shape preserved
if (wants.length) p.wants = wants; // [R33] same convention
if (listings.length) p.listings = listings; // [R34] same convention
return p;
}
// [R34 GODBAY] resolve every listing the day has moved past — sleep, travel, and day-moving imports
// all funnel here. One hammer per item (identity-seeded), so WHEN it resolves never changes WHAT.
function resolveAuctions() {
if (!listings.length) return;
const still = [];
for (const l of listings) {
if (l.listDay >= day) { still.push(l); continue; } // resolves the day AFTER listing
const title = l.title || l.sku || l.slotId || 'the item';
const res = godbayHammer(l, l.townKey || townKey);
if (res.sold) {
cash += res.net;
auctionNews.push(`⚖ SOLD “${title}” — hammer $${res.hammer}, $${res.net} after fees`);
} else {
const { listDay, ...entry } = l; // passed in — comes home unchanged
collection.push(entry);
auctionNews.push(`⚖ passed in — “${title}” came home`);
}
}
listings = still;
}
function save() {
if (!store) return false;
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();
pulls = Array.isArray(state.pulls) ? state.pulls.slice() : [];
wants = Array.isArray(state.wants) ? state.wants.slice() : [];
// [R33 — TRAVEL COSTS A DAY, charter system #6] The adopted save came from ANOTHER town (townKey
// embeds plansrc/town/seed, so a new seed is a new town too): the ride ate a day. Same-town
// adoption (reload, export→import round trip) costs nothing — the save-determinism gate's
// byte-equality survives untouched. Day moves BEFORE prunePulls so yesterday's-town day-tagged
// pulls drop here, and the shell reads game.day/traveled at boot (onDay stays un-fired, §30.1).
if (state.town !== townKey) { day += 1; traveled = true; }
listings = Array.isArray(state.listings) ? state.listings.slice() : [];
resolveAuctions(); // [R34] a day-moving adopt (travel/import) resolves what it passed
prunePulls();
}
// Day-tagged pulls from another day can never match a bin key again (the consumer embeds the
// CURRENT day in rotating bins' keys) — dead weight, dropped. Untagged pulls are real-sourced
// (never rotate) and persist: sold means gone, the tier-2 spirit at tier 1.
function prunePulls() {
pulls = pulls.filter((k) => { const m = PULL_DAY_RE.exec(k); return !m || +m[1] === day; });
}
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;
resolveAuctions(); // [R34] GODBAY hammers overnight — before onDay so the toast carries it
prunePulls(); // [R32] yesterday's day-tagged pulls are unreachable — drop them
if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
save();
return day;
},
export() {
return JSON.stringify(payload());
},
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);
if (info && info.band) e.band = String(info.band); // [R32] §9.1 sell basis — optional, alpha entries lack it
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;
},
// ── the pull seam (R32 scarcity — interior_mode records a bought dig slot; SELLING an item does
// NOT restock the bin, which is why this list is independent of the collection) ──────────────
recordPull(key) {
const k = String(key || '');
if (!k || pulls.includes(k)) return false;
pulls.push(k);
if (pulls.length > PULLS_CAP) pulls.splice(0, pulls.length - PULLS_CAP);
return true;
},
// The consumer's read: every recorded slot for one bin, as the Set dig.open({gone}) takes —
// suffixes only ('sku:<id>' | '<offer index>'), prefix = `${shopId}|${binKey}${tag}#`.
pullsFor(prefix) {
const out = new Set();
for (const k of pulls) if (k.startsWith(prefix)) out.add(k.slice(prefix.length));
return out;
},
// ── the wantlist seams (R33, charter system #6) ────────────────────────────────────────────
get wants() { return wants; }, // LIVE array — READ-ONLY by contract (like collection)
get traveled() { return traveled; }, // this boot's save came from another town (+1 day)
// w: {artist?|title?|sku?} strings. Dedupe is case-insensitive over the fields present.
recordWant(w) {
const e = {};
for (const k of ['artist', 'title', 'sku']) if (w && w[k]) e[k] = String(w[k]);
if (!Object.keys(e).length) return false;
if (game.findWant(e)) return false; // (named binding, not `this` — survives detached calls)
wants.push(e);
if (wants.length > WANTS_CAP) wants.splice(0, wants.length - WANTS_CAP);
notify();
return true;
},
// The matcher both UIs use: a want covers an item when every field the WANT names matches the
// item's (case-insensitive) — sku exact, artist/title by value. Buying a wanted item clears it.
findWant(o) {
const low = (s) => String(s || '').toLowerCase();
return wants.find((w) =>
(!w.sku || w.sku === String(o.sku || '')) &&
(!w.artist || low(w.artist) === low(o.artist)) &&
(!w.title || low(w.title) === low(o.title)) ) || null;
},
removeWant(w) { // identity removal, findWant supplies the identity
const i = wants.indexOf(w);
if (i < 0) return false;
wants.splice(i, 1);
notify();
return true;
},
// ── GODBAY seams (R34) ─────────────────────────────────────────────────────────────────────
get listings() { return listings; }, // LIVE, READ-ONLY by contract
// Consign a collection entry (identity, like removeFind): it leaves the crate for the auction
// house and resolves the NEXT day. false ⇒ not in the collection, nothing moves.
listFind(entry) {
const i = collection.indexOf(entry);
if (i < 0) return false;
collection.splice(i, 1);
listings.push({ ...entry, listDay: day });
notify();
return true;
},
// Drain the morning paper (transient — resolution wrote it, the shell toasts it once).
takeAuctionNews() {
const n = auctionNews;
auctionNews = [];
return n;
},
};
// 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;
}

View File

@ -259,7 +259,13 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
return { return {
group, venues, update, dispose, group, venues, update, dispose,
venueShopId: primaryId || (venues[0] && venues[0].venueShopId) || null, // alpha compat // alpha compat — [Lane B R30, F's §30.4 filing] "tonight" now tracks the game day: a live getter
// keyed to gigs' night = weekNight (0 pre-game and on day 1 ⇒ exact pre-v7 value, gigs[0]'s venue).
get venueShopId() {
const n = (typeof window !== 'undefined' && window.PROCITY?.gigs?.weekNight) || 0;
const g = gigs.find((x) => x.night === n) || (primaryId != null ? gigs[0] : null);
return (g && g.venueShopId) || (venues[0] && venues[0].venueShopId) || null;
},
venueShopIds: venues.map((v) => v.venueShopId), venueShopIds: venues.map((v) => v.venueShopId),
queueZones, queueZones,
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; }, queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.