Compare commits

...

62 Commits

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
m3ultra
cdce67c8f8 Lane F R28 (v5.1): the sweep gated and tagged — the spike never reached the game
qa.sh --strict --matrix 7 passed / 0 failed / 0 warn. selfcheck ALL GREEN 156,352/156,352 —
UNCHANGED, confirming the brief's prediction that nothing this round moves a golden. MATRIX
GREEN 10 towns x 7 gates.

F'S POSE GATE HAS NO SUBJECT — SKIPPED with the reason printed, per the vacuous-gate law. The
brief asked for "lean/look poses through the scale gate" and "leaning peds in old frames". There
are no new poses in the game:
  · `lean` DOES NOT EXIST. E measured instead of trusting the list — the stash's 34 clips have no
    lean, and find -iname "*lean*" across ultra returns nothing. Brief and kickoff both called it
    already-cached. The premise was half wrong, and E found it the only way these are ever found.
  · `look.glb` is shipped, verified, published, sha1-checked by fetching the bytes BACK off the
    public depot — AND NOTHING BINDS IT. loadPedFleet() loads walk, idle, sit. No look. E's
    handoff said it plainly ("-> D: gate it behind an opt the way rigs.js gates sit"); D's entire
    R28 commit is LANE_D_NOTES.md, 48 lines, docs only. The run order said "when E's clips land:
    D (#1 wiring)" — the log says D committed BEFORE E shipped.

Nobody was careless; the wave order was. Every check E ran was real and passed, and none of them
could see that no consumer exists. A GATE ON THE ARTIFACT CANNOT SEE THE WIRE — fifth appearance
of the R17 pattern this epoch and the cheapest yet to have missed, because the artifact was
perfect.

v5.1 SHIPS ANYWAY, AND DOES NOT CLAIM THE SPIKE. An unbound clip is inert: nothing is broken,
the game is unchanged and correct. This is not R18's "known breach", it is an unfinished item,
and holding a polish tag over an inert file would be precious. What ships is the sweep:
C's audioEmitter (its own v3.0 note had specified it wrong on both halves — "the panner was
inert"), and TWO backlog items KILLED WITH THE MEASUREMENT — B's awning ripple and D's
instrument LOD, the oldest tri item on the books. They don't leave the backlog "deferred"; they
leave it gone, with the number that killed them.

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 runs it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:59:02 +10:00
m3ultra
488067004c Lane C R28: audioEmitter — the till rings from the counter; the panner was inert
R28 ledger #3. My own idea, parked since R7. Brief stated acceptance only, so I scoped it.

THE TRAP MY OWN v3.0 NOTE SET: it said "userData.audioEmitter = musicKey so Lane B can
position/spatialise the bed". Both halves wrong. (1) Shape: the engine holds no reference to
the room group and would traverse a scene it cannot reach — emitters belong on room.audio as
data, riding the EXISTING playInterior() call (no lifecycle, no traverse, no dispose hook).
(2) Method: "spatialise" invites a PannerNode. NOTHING in this tree sets ctx.listener (zero
hits), so a panner resolves against a listener frozen at (0,0,0) — which, because rooms are
built room-local at the origin, is the room's own CENTRE, forever. Measured, OfflineAudioContext,
real pub stage bed:
    panner, listener never set (the engine as it SHIPS): RMS 0.106905 door / 0.106905 stage
                                                          -> ratio 1.0000, bit-identical
    panner + listener tracking the player:                RMS 0.055665 / 0.500000 -> 8.98x
It plays, throws nothing, warns nothing, and never changes by one sample as you cross the room
— while failing at the only thing it exists to do. "Routed through a panner?" yes. "Audible in
a venue?" yes. "In a shop?" yes. All green, all worthless.
-> ASSERT THE DELTA (gain at door != gain at stage), NEVER THE PRESENCE. Vacuous-gate law, 4th
application — and this one had no author to blame but me.
The house idiom was already right: tram/spill/gig-spill are all hand-rolled distance-gains vs
PROCITY.camera.position. No listener, no panner.

SHIPPED (C's files only):
- room.audio.emitters {stage?, counter?} — room-local, 3dp, 18/18 same-seed deep-equal. `r`
  (reach) is the ROOM DIAGONAL, not a constant: measured diagonals 9.81-15.63m, and scaling to
  the room holds door->stage proximity in a 0.42-0.59 band from a pokey band_room to a hall pub.
  `floor` is the room-filling residue, never 0 — a PA fills a pub; what changes as you walk in
  is the BALANCE, not the presence. Swing 2.65-4.52 dB (John R20: subtle changes are great).
- THE TILL RINGS FROM THE COUNTER — audible positioning in a normal shop with NO ENGINE CHANGE,
  via B's already-public playSfx(key,{gain}): C does its own distance math, hands B a scalar.
  Measured through the real BUY button, real record shop: 0.9489 @0.60m vs 0.6436 @4.19m =
  3.37 dB; negative control (no emitters) fires at exactly 1.0. sfx-till.ogg has been on disk
  since 15 Jul and had NEVER ONCE PLAYED. Routing, not content — as the brief said.
- ?mute=1 now actually silences the crate riffle (below).

WHY THE VENUE HALF IS AN ASK: a one-shot resolves its gain once, at fire time, from a position C
already knows. A bed must be re-gained every frame inside the engine's update() loop — B's file.
Structural, not political. B has a ~12-line paste-ready diff in the house idiom; audio.js
untouched. The letter of "consulted not modified" could have been met by reaching through B's
exposed state.layers diag to rewire its graph from outside — that backdoor is strictly worse
than the edit it avoids. Refused.

TWO BUGS FOUND:
- F DROPS EMITTERS ON THE ONE NIGHT THEY MATTER. interior_mode.js:289 rebuilds the spec on a gig
  night ({musicKey: ra.gigKey, toneKey: WALLA_KEY}) and every unnamed field dies. Measured on a
  real gig-night pub: quietNight_emittersSurvive TRUE, gigNight_emittersSurvive FALSE. Silent,
  fail-soft, no error. -> F: don't just spread `ra`; pass emitters DELIBERATELY, gig-night only —
  on a quiet night the radio + room-tone genuinely ARE room-filling.
- ?mute=1 NEVER SILENCED THE RIFFLE (R5->R28). dig.js opened a SECOND, private AudioContext
  straight to destination, breaking both stated house laws (ONE AudioContext; mute forces
  silence). It survived three epochs because NO GATE ASSERTS MUTE => SILENCE: the engine returns
  its silent surface and looks honest from its own side, while the sound came from somewhere it
  never knew existed. A gate that asks the engine whether it is muted can only ever answer yes.
  Fixed via B's PUBLIC .muted getter (consulted, not modified), URL fallback when standalone.
  Measured with a positive control so the probe provably CAN see sound: live 1 ctx / 1 source;
  muted 0 / 0. Blips stay procedural on purpose — zero-fetch, deterministic, still work under
  ?noassets=1 where a sampled riffle cannot. Only mute was broken; only mute was fixed.
  -> F: assert SILENCE, not muted-state.

HARNESS HONESTY: the first till rig reported the same clean 3.37 dB — from a CONTAMINATED rig
(createDig appends a panel per call, close() doesn't remove it, so querySelector clicked trial
1's stale button every time). Tell: panelUp false on all three, and the NEGATIVE CONTROL
returning 0.6436 where it must return 1.0. The gains matched the formula exactly; I refused them
anyway and re-ran isolated (panelCount 1, panelUp true, control 1.0). Never report a number from
a rig you have watched lie — even when you like the number.

QA GREEN 6/0/0/0. Docs: LANE_C_AUDIO.md -> v3.1 (contract + both asks + every measurement).
2026-07-17 17:39:54 +10:00
m3ultra
640bdbb55d Lane E R28 (v5.1): Spike 1 — look.glb shipped; lean does not exist (ledger #1)
look.glb — DONE, published, verified. Looking_Around.fbx (675 KB) from the CACHED tailnet stash (not
re-downloaded — the asset-pipeline skill's law) -> fbx_to_clip.py -> web/models/peds/look.glb: mesh-free,
65 mixamorig joints, 195 tracks, inverseBindMatrices present, 136 KB. Structurally identical to the
sit.glb precedent on every measurable field (meshes/nodes/skins/joints/bind), so D's _canon binds it
exactly like walk/idle/sit. Depot: published to digalot.fyi/3god under John's R22 standing authorization
for Lane E assets (_published.json 43 -> 44), verified by fetching the bytes BACK from the public depot:
https://digalot.fyi/3god/a/look.glb -> HTTP 200, 139,640 bytes, sha1 identical to local.
-> D: it's ready. Gate it behind an opt the way rigs.js gates sit (loadPedFleet(base, { sit })) — a raw
fetch would breach the ?classic=1 zero-fetch-delta covenant, same as sit.glb.

`lean` DOES NOT EXIST — the spike's premise is half wrong. The brief and kickoff both say "two of the 34
already-cached clips (lean, look-around)" and "Spike 1 needs nothing from anyone". look-around is cached;
lean is not, anywhere. Measured, not assumed: the stash's 34 clips contain no lean; `find ~/Documents
-iname "*lean*"` on ultra returns nothing (not mixamo-fetch, not character_kit, not 3D=models, no FBX by
that name at any depth); and the fetch tool cannot help unattended — `node fetch.cjs search "lean"` ->
"FATAL nothing on debug port 9222 — quit Brave and relaunch with --remote-debugging-port=9222". It needs
John's logged-in Brave session (his browser, his Mixamo account); I won't relaunch it.
I did NOT substitute a lookalike. `Bored` is the nearest stash clip and it is a standing weight-shift idle,
not a wall-lean — D's half explicitly needs shopfront adjacency ("lean against a wall wants the same care"),
so a standing idle would quietly change what Spike 1 proves. Half a spike, honestly, beats a whole one that
lies. -> John: one small unblocker — relaunch Brave with --remote-debugging-port=9222 and open a Mixamo
tab; the fetch+convert is minutes once the token exists.

Instrument LOD — D killed it and D's measurement is better than mine. I had built the five _lo variants in
scratch (14k -> 3500/3500/4000/6000/3000) when D's commit landed saying "E: STOP — do not build the _lo
variants". Nothing shipped; scratch discarded. D measured the rooms an LOD must fire in: max diagonal 15.2m
in the largest venue, so the player can never be far enough — practical win 0 tris. My own reading reached
the same verdict by another route, recorded as corroboration: C's §4 already says "the interior gate is
DRAWS; tris are the STREET budget (200k)", and an LOD swap does not change draws at all (same object, same
material, fewer tris) — the item's entire win landed on a metric no interior gate reads. Two independent
measurements, one verdict. Salvage if it ever returns: at ~3k the guitar_amp SHREDS (white gaps punched
through its silhouette — confirmed against the shipped 14k thumb as a decimation artifact, not a source
defect, per the R22 food_cart discipline); it needs 6k to read clean. The other four were clean at 3-4k.

-> A: your own file's comment now contradicts its code. Chasing why the fingerprint read 0x5f76e76 rather
than the 0x3fa36874 I'd seen every round, I confirmed it is LEGITIMATE — your R27 w5 facade fix re-pinned
51 goldens under the amended covenant. But selfcheck.js:267 pins hash: 0x5f76e76 while selfcheck.js:301
still says "the synthetic golden above stays 0x3fa36874". Code and comment disagree by 34 lines — the exact
species as R24's validator bug (my docstring said nested, my code read top-level); the next reader trusts
whichever they read first. Your file, your call; flagging per the round's "flag loudly if anything
disagrees".

Gates: selfcheck ALL GREEN 156,352/156,352 · qa.sh --strict 6 passed / 0 failed. look.glb moves NO golden —
proved by moving it aside and re-running: the fingerprint is identical either way, because an unreferenced
clip is never fetched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:35:17 +10:00
m3ultra
999c9d653f Lane B R28 (v5.1): the awning fabric ripple — KILLED with the measurement
Ledger #2. Built it, proved it worked, measured what it was worth, killed it.
A 10x-over-driven billow — 1.4m of flap on a 0.14m canopy — cannot move 1% of
the frame. It is not the gain, it is the geometry. Nothing ships: buildings.js
and wind.js are byte-identical to HEAD.

IT WAS BUILT, AND IT WORKED. Split the stall canopies out of the rigid awning
InstancedMesh (own mesh, own subdivided box — a 4-corner box has nothing to
ripple), gave only them a cloth billow riding R17's wind.js uniforms: sin(pi u)*
sin(pi v) pin (zero at the four posts by construction), two crossed travelling
waves, per-instance phase so forty stalls don't breathe in unison. Proven by
rendered-pixel hashes, not by eye:
  cloth displaces (calm→gusty)        40f59eaf → d6d3e1f1  moves
  back to calm                        40f59eaf              identical
  uWindTime=99 at amp 0               40f59eaf              identical
  ?classic=1, pre- vs post-build      453c0154 → 453c0154   BYTE-IDENTICAL
  rest of town rides wind? (hidden)   76c8b518 → 76c8b518   still
  control: cloth back                 frame moves again
Cost at market_square, true A/B: +0 draws (98→98) / +2,160 tris. +0, not the +1
my own comment claimed — the square's chunks hold only stalls, so the rigid list
empties and makeInstanced returns null; the cloth mesh replaces it.

AND IT IS WORTH NOTHING (amp is the same multiplier as gain):
  0.02m  CALM, the default weather amp        0.05% of frame
  0.14m  STORM, the most a player ever sees   0.21%
  0.35m  2.5x over-driven                     0.33%
  0.70m  5x, absurd on a 0.14m slab           0.49%
  1.40m  10x, flapping a body-length          0.77%
At the player's actual browsing pose, storm-force: 0.00%, max delta 1. Not one
pixel.

THE REASON IS PHYSICAL — the same physics that killed the verandah in R17. A
canopy is a horizontal sheet at y2.2 seen by a 1.7m player: edge-on or from
underneath, so vertical displacement runs almost parallel to the view ray and
makes almost no pixels. You cannot see a flat horizontal sheet flex vertically
while standing under it. No gain fixes it; 10x buys 0.77% and stops reading as
cloth.

WHAT WOULD WORK (for whoever reads this in round 35): the motion must be
perpendicular to the view — VERTICAL cloth: hanging stall skirts, a flag, a
banner, an awning valance. That is an ASSET (Lane E), not a shader. B's
applyWind/applyFabricWind seam is ready the day such a mesh exists; the shader
was never the missing piece. Ledger #2 leaves the backlog forever.

TWO VACUOUS MEASUREMENTS CAUGHT ON THE WAY. (1) `!!material.onBeforeCompile` to
prove the rigid verandah untouched — every THREE.Material inherits a prototype
no-op, so it is ALWAYS truthy; the assert could never go red. Replaced with
hasOwnProperty + a behavioural hide-and-hash control. (2) A gain sweep reporting
an identical 0.21% at gains 0.4→4.0 because I never stored the shader ref, so
uFabricGain never moved — four identical numbers across a 10x range is a sweep
that proves nothing. The constancy, not the value, gave it away. Both would have
passed as green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:29:39 +10:00
m3ultra
afad68b114 Lane D R28 (v5.1): instrument LOD — KILLED with the measurement (ledger #4)
E: STOP — do not build the _lo variants. That work is unnecessary; the item is dead.

The oldest tri item on the books, killed under the wind-sway rule attached to the brief
("if the measured win is <20k tris in practice, KILL it with the measurement").

MEASURED, from the shipped depot GLBs: each of the five instruments is exactly 14,000
tris; band + amp on stage = 70,000. On paper a 3-4k _lo each "saves" ~52k, clearing the
20k bar easily.

But the rule says IN PRACTICE, and the brief named the reason — "interiors are
close-quarters". So I measured the rooms an LOD would have to fire in (live, all three
venue archetypes, interiorMode driven by hand since rAF is throttled):
  pub        9.7 x 9.7m, diagonal 13.7m, 12522 tris, 38 draws
  band_room  8.9 x 9.6m, diagonal 13.0m, 11699 tris, 35 draws
  rsl        12  x 9.4m, diagonal 15.2m, 15115 tris, 38 draws  (the biggest)

VERDICT: practical win = 0 tris. A player can NEVER be more than 15.2m from the band —
corner-to-corner in the largest venue, and the stage sits at one end so the real max is
shorter. An LOD is a distance trade; inside a <=15.2m room the band is never small — it
is the subject of the gig and of F's tour money shot. Any sane threshold (15-25m) never
fires; an aggressive 8m one fires in half a room at a distance where a 3-4k _lo is
visibly worse with no perceptual cover. The win isn't "small", it's STRUCTURALLY ZERO —
the distance an LOD needs does not exist in this game. Off the backlog forever.

No budget motivates it either: a venue interior is ~12-15k tris, ~85k with the band —
inside the 200k stress ceiling E's R22 tri diet restored, at 35-38 draws vs <=350.

SIDE-OBSERVATION for E (not a proposal, no breach motivates it): all five instruments
are EXACTLY 14,000 tris — a decimator target, not a measurement of the objects. A mic
stand does not need 14k. If instrument tris ever do drive a real breach, the lever is a
flat re-decimation by object complexity (mic stand -> ~1k), which beats any LOD because
it needs no distance. Filed so the next tri hunt starts from it.

Spike 1 (ledger #1) pending E's lean.glb — look.glb has landed, lean.glb not yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:21:34 +10:00
m3ultra
d9100962f6 Fable (integrator): round-28 instructions — THE SPIKE AND THE SWEEP (spike 1 + parked polish, tag v5.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:07:52 +10:00
m3ultra
e93bece0f4 Fable (integrator): the v5 epoch retro — five holds, nine vacuous gates, the covenant amendment, the case law
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:04:41 +10:00
m3ultra
b2d29ba112 Lane F R27 (v5.0): the covenant re-pinned, the frame that took five tries, QA GREEN
qa.sh --strict --matrix: 7 passed / 0 failed / 0 warn. selfcheck ALL GREEN 156,352/156,352 ·
MATRIX GREEN 10 towns x 7 gates · scaffold_check GREEN (F's re-pins took) · flags harness green,
carrying the kill-the-server gate and the beta crate gate.

F RE-PINNED ITS OWN FILES per A's handoff (A doesn't edit other lanes' files, correctly):
GOLDEN_HASH 0x3fa36874 -> 0x5f76e76 · GIG_GOLDEN 0xb1d48ea1 -> 0xec7a2d39 · melbourne · katoomba
· the captions. AND A CAUGHT A LIVE HAZARD IN F'S GATE: hash_ok carried `or ('3fa36874' in
r.stdout)` — a bare-string OR-clause, dead the moment the covenant moved and worse than dead,
because it would go green off a SENTENCE ABOUT the old covenant appearing in stdout. Deleted.
The check derives from GOLDEN_HASH or it doesn't exist.

JOHN'S RULING IS THE EPOCH'S BEST LINE: the covenant is anti-drift machinery, not a defect
preservative. 0x3fa36874 did its job for five epochs — it never drifted. It just was never right,
and no hash can catch what was wrong in the first commit. A golden proves NOTHING CHANGED; it
cannot prove anything is CORRECT. Every gate we own was green over a world of back walls, and the
only thing that ever saw it was a human standing in the street looking at a wall.

THE FRONTAGE FRAME TOOK FIVE ATTEMPTS, FOUR OF THEM F'S FAULT:
  1-3. cluster pose (the town's heart isn't this shop) / lot-anchored facing along the edge /
       lot-normal facing — three photographs of empty road. Also never winnable: the shops faced
       backwards the whole time. F was posing a camera at a wall and tuning the geometry.
  4.   B's helper — and F THREW THE RESULT AWAY. poseForShopFront RETURNS a pose; it does not
       apply one. F called it, error-checked it, and never touched the camera, so the frame was
       the spawn paddock — and F's error-check PASSED, because the call succeeded perfectly. The
       vacuous check one last time, in the last frame of the epoch.
  5.   Applied it -> MONSTER ROBOT PARTY. Sign, door, awning, a ped walking in off Musgrave Road.

The measurement that would have found the facade bug in five minutes — compare the two
conventions against each other — is the one nobody ran for nine rounds, F included, right up to
the vacuous 72/72 F nearly filed as "B is wrong".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:22 +10:00
m3ultra
c1e862cf7c Lane A R27 wave 5: THE FACADE FIX — it was every town, not just the real ones (the covenant amended)
selfcheck ALL GREEN 156,352/156,352; consistency green. 51 goldens re-pinned in this one commit per the
amendment law. scaffold_check is RED and CANNOT be green from here — the covenant hashes are duplicated in
LANE F's files; exact values handed to F in LANE_A_NOTES §R27w5 (I don't edit other lanes' files).

THE RULING'S PREMISE WAS WRONG, AND THE WRONG HALF WAS LOAD-BEARING.
Wave 5 says "synthetic follows B's canon so every golden has been green over a wrong product since R18".
Half right. Synthetic does NOT follow B's canon -- it is broken identically, and so is the marched path,
and so are all three checked-in fixtures. It is not "every real town since R18". It is EVERY TOWN, EVERY
PATH, SINCE v1. The ruling told me to measure the marched path rather than assume it was clean "because
that's how this one survived nine rounds" -- so I measured all three, and the same discipline caught the
premise itself.

MEASURED FIRST, IN B'S CANON (+Z = the facade), BEFORE TOUCHING ANYTHING:
  synthetic seed 20261990   0 toward / 681 away   (dot = -1.0000, exactly anti-parallel)
  marched katoomba/fremantle/godverse, fixtures melbourne/katoomba/silverton   0 toward, all away
  real-roads katoomba   0/72   <- B's number, reproduced
637 of 681 synthetic lots had their facade pointing at NO STREET AT ALL (44 hit a different street by
corner coincidence). Then I confirmed it with my own eyes rather than trust the arithmetic: booted
?classic=1, stood on the synthetic main street at lot 0 -> three blank grey boxes. Walked BEHIND them into
the block -> NUMBAT PLAYTHINGS, sign/door/windows/awning, facing the dirt. The classic covenant town has
been a row of back walls since v1.
After the fix, same camera, same seed, same ?classic=1: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT
PLAYTHINGS -- a strip of shopfronts facing the road.

FOUR SITES, ONE SIGN, AND EVERY ONE OF THEM SAID SO IN ITS OWN COMMENT:
  plan.js:122    synthetic strip   "facade faces back down the outward normal, to the street"  <- it didn't
  plan.js:244    market row        "the facade then faces -(outward) = +x"                     <- it didn't
  plan_osm:330   marched avenue    "facade faces +z (north) to the avenue"                     <- due south
  plan_osm:443   real roads        B's proven one-liner (B measured it, then reverted it -- A's file)
All four were atan2(outward) where the intent was atan2(-outward). The code never did what its comment
said, and CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. Nobody was
wrong; the spec was. After: synthetic 681/681, marched + fixtures 100%, real-roads katoomba 72/72.

THE COVENANT MOVED -- JOHN RULED IT, I DIDN'T.
Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16. That is not Lane A's call, so I
stopped and asked with the evidence. John's ruling: AMEND THE COVENANT, FIX ALL PATHS -- the covenant is
anti-drift machinery, not a defect preservative. 51 pins in this commit:
  classic/synthetic  0x3fa36874 -> 0x5f76e76
  gig                0xb1d48ea1 -> 0xec7a2d39
  fixtures           melbourne 0x9c7e76b3 · katoomba 0xf3aafec8 · silverton 0x6d74c4
  23 real towns      base + gig (46)
Verified byte-identical across two fresh processes; pack-xor 0xa5058322. CITY_SPEC records the amendment as
a ONE-TIME, ruling-gated precedent -- from this hash forward the law reads as it always did.

A SECOND BUG THE FIX EXPOSED (fixed here, counted). buildRealRoads seated blocks at KERB = 4 m from the
CENTRELINE, but a `main` carriageway is 10 m wide -- its kerb is at 5 m. EVERY MAIN-STREET BUILDING HAS
STOOD 1 m INSIDE THE ROAD SINCE R18. The footprint never moved; only which face is the front -- so it was
invisible until the shopfront turned around and its poster landed in the traffic lane (-1.07 m clearance,
6 real failures). Blocks now seat behind the kerb per A's own published corridor law (roadWidth/2 +
FOOTPATH). Pack drops 6.4% -> 6.6%, counted, not hidden.

THE CROSS-CONVENTION GATE -- built to F's lesson, not to a spec line. F proved that measuring lots against
CITY_SPEC:71's own convention is SELF-AGREEMENT: 72/72 "facing" over a town of blank walls. So this gate
restates nothing. It replicates LANE B'S OWN maths from buildings.js (toWorld(lot, 0, y, d/2 + 0.02)) and
asks a physical question neither spec line can fudge: does the quad the RENDERER actually draws land nearer
the street than the lot centre? It fires if A's ry sign drifts OR if B's zf sign drifts. It would have
caught this on day one of v1.

CITY_SPEC amendments: the lots contract now states facade/door/frontage = local +Z; the SIM/rig convention
is documented separately as the different thing it is, with the explicit rule -- never assert a lot's
facing against a spec line, assert it against the geometry the renderer draws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:28:20 +10:00
m3ultra
94fce8ea4b Fable (integrator): R27 wave-5 — THE SPEC RULING (facade = +Z, B's canon canonical); A one-commit fix+pins+cross-convention gate; F re-gates and tags
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:01:31 +10:00
m3ultra
8a1d2e91e8 Lane F R27: HOLD v5.0 — every real town's shops face away from their street
B's micro-task found it while fixing F's frame, and F's three photographs of empty road are the
evidence: it was never the pose. plan_osm.js:443 aims the lot's -Z at the road ("facade (-Z)
faces road"); buildings.js:404 draws the facade on the local +Z face. Exactly opposite. The
shopfront — sign, door, R26 crate mark — lands on the far side and the street gets a blank wall.
Since R18. v4.0 shipped on it. katoomba_real's street_noon, the bookmark the tours have shot
since R9, is 46 draws of bare road.

Root cause: CITY_SPEC contradicts itself — :71 (lots contract) says -Z, :335 (R15 posters) says
"+Z, B's canon" — and each generator implements one half. Synthetic follows B's canon and looks
right, which is why every golden passes and nobody saw it for nine rounds. The epoch's signature
species and its worst instance: not a gate failing on correct behaviour, but a gate suite GREEN
over a product that is wrong.

F VERIFIED IT AND F'S FIRST CHECK WAS VACUOUS — recorded because it nearly ended the round the
wrong way. F measured each lot's ry against CITY_SPEC:71's own convention and got 35/35 and 72/72
FACING, a clean contradiction of B. But plan_osm IMPLEMENTS :71, so testing :71's data against
:71's rule is self-agreement — it cannot fail. A self-consistent check over a self-contradicting
spec passes under either convention, which is exactly why nine rounds of checks missed this, and
why F nearly filed "B is wrong" with a green number in hand. The only test that sees it compares
the two conventions. F's own frames already had.

Not F's to fix and B was right not to either: A's file, it moves every real-town plan (36 goldens
re-pin under the amendment law), and a self-contradicting spec is Fable's to rule, not a lane's
to pick a side of. B proved the one-liner (ry = atan2(-nx,-nz) → katoomba 0/72 → 72/72, F's money
shot 4m from Musgrave Road) and reverted it — the same discipline F applied to A's sweep and E's
validator, arriving from the other direction.

Order: Fable rules the spec → A lands the line + re-pins the goldens in ONE commit → F re-gates,
re-shoots, tags. Everything else is done and green: the kill-the-server gate, the reader, the
sandbox, the interior money shot (interiors pose themselves; the crate is real either way).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:47:26 +10:00
m3ultra
4c2702fc58 Lane B R27 w4: poseForShopFront — and the real towns face the wrong way
The micro-task, done and verified. In doing it I found why F photographed
empty road: it is not the pose.

1. THE DELIVERABLE — DBG.poseForShopFront(sel, dist = 8)
Poses at a NAMED shop's frontage: door centred, sign band + R26 crate mark in
shot, under the verandah. clusterPose() answers "where is the town's HEART",
which is the wrong question for a named shop — Red Hill's heart doesn't contain
Monster Robot, which is exactly how F got three frames of empty road.

Keyed through ONE shop selector: F's R22/R24 resolver lived inside enterShop(),
so I extracted it rather than grow a second key scheme beside it. A selector bug
resolves the wrong shop AND looks like it worked (F's own first cut proved it),
so the id-namespace law now has exactly one home.
  poseForShopFront(31)         → Wholefood Cafe (PLAN id) + ambiguous: "31 is
                                 also the godverseShopId of Silky Oak Furnature"
  poseForShopFront('g:31')     → Silky Oak Furnature (the prefix IS the fence)
  poseForShopFront('g:999999') → {error} — never throws, never guesses
Framing proven by projection, not by eye: doorCentreNdcX = 0.0000 exactly, door
+ sign + mark inside the frustum. Derived from buildings.js, not hardcoded: door
is centred on the lot front (off = 0, unlike poseForShop's off = 1.0); sign band
= min(h,FACADE_H)-0.35 ± 0.25 ⇒ y 2.40-2.90; mark = y 2.45-2.52; camY 2.0 sits
below AWNING_Y 2.95, so a ray rising to a target ≤2.9 can never cross it — the
sign is clear BY GEOMETRY at any distance.

2. CORRECTING MY OWN R27 INTEL — I gave F two wrong numbers. I said the mark
"rides high (y≈3.3)" and the vinyl blocks 4% of it. Both wrong: the mark is at
y 2.45-2.52, and I had scanned y 3.0-3.6 — empty awning air above the sign,
which is why it read ~0. Re-measured: the novelty_record disc (top y 2.50)
reaches INTO the mark band and clips ~15% of the strip from EVERY offset;
moving the camera only changes which 15%. "Shoot head-on" survives, but for the
opposite reason to the one I gave.

3. THE FINDING — every real town's shops face away from their street, since R18.
From Musgrave Road (Monster Robot's own frontEdge 1316) the shop is a blank grey
back wall: no sign, no door, no mark. Measured:
  redhill_godverse  35 shop lots: 0 facing, 35 backwards (dot = -1.000)
  katoomba_real     72 shop lots: 0 facing, 72 backwards (dot = -1.000)
  synthetic         correct — always has been
dot = -1.000 exactly on every lot in both towns: a convention, not a defect.
Katoomba's street_noon — the bookmark the tours have shot for nine rounds — is
46 draws / 19,680 tris of bare road.

Root cause: CITY_SPEC contradicts itself and each generator implements one half.
  CITY_SPEC:71  (lots contract) "World facing = (-sin ry, -cos ry)"  ⇒ -Z facade
  CITY_SPEC:335 (posters, R15)  "the +Z street facade (B's canon)"   ⇒ +Z facade
plan_osm.js:443 obeys the first (its comment: "facade (-Z) faces road").
plan.js, buildings.js, venue.js and the R15 posters obey the second. Synthetic —
the town every golden tests — follows B's canon and looks right, which is exactly
why nine rounds of real-town work never caught it. The epoch's own signature
species: a rule meeting a case written after it.

Proven, then REVERTED: one line in A's plan_osm.js (ry = atan2(-nx,-nz)) flips
katoomba 0/72 → 72/72 facing, and turns poseForShopFront's camera from 26m out in
a paddock into 4m from Musgrave Road — the money shot F specified. Not committed:
it is A's file, it moves every real-town plan (goldens re-pin under amendment
law), and a self-contradicting spec is Fable's to rule on, not mine to pick a
side of.

This also re-reads my R22 diagnosis: I recorded "chunk-buildings 348 tris (0.2%)"
in katoomba's busiest block and credited the instancing work. 348 tris is ~29
boxes — that was not efficiency, it was the buildings being ABSENT from frame. I
read the evidence as a win for five rounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:42:50 +10:00
m3ultra
83ed848f16 Fable (integrator): R27 wave-4 — ratify the exactly-one-refusal + control-delta gates; B micro-task (frontage pose); F's tag path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:23:04 +10:00
m3ultra
630042fbe1 Lane F R27: the kill-the-server gate GREEN — and two corrections to F's own gate
THE KILL-THE-SERVER GATE, permanent and green (charter law #1 made falsifiable):
  ARM 1 server-up   · gone[3] → 117 survivors · items[] 120, identity AND ORDER intact ·
                      COLLATERAL 0 · `gone` carries the atlas id verbatim (R26 fence)
  ARM 2 killed mid-session · enter 6ms vs control 6ms (Δ+0) · worst frame 204ms vs control
                      202ms (Δ+2) · room complete from files · ZERO game errors · EXACTLY 1
                      refused connection
  ARM 3 server-down boot   · pure tier 1, gone[0] · ZERO game errors · exactly 1 refusal
  SANDBOX · POST/PUT/PATCH/DELETE all 501 · a mint shop 404s
The subject exists only because G built `make-fixture`: production `gone` is EMPTY (0 sales
since 2026-07-01), so this gate would have passed vacuously forever on live data.

CORRECTION 1 — "ZERO console errors on server death" is UNACHIEVABLE, and F measured why. A
fetch to a dead host logs net::ERR_CONNECTION_REFUSED at the BROWSER level; no .catch() sees it.
To log zero the reader would have to know the server is dead WITHOUT ASKING. One refusal is the
irreducible cost of discovering death. F's replacement is strictly stronger: ZERO errors from
game code, and EXACTLY ONE refusal — which PROVES the circuit breaker. Without the breaker it is
one error per shop entry forever, and "zero" could never tell a working breaker from a broken
one because both fail it. F is the beneficiary of this change, so it is filed loudly in §27 for
Fable to ratify or overrule, not quietly relaxed.

CORRECTION 2 — F's frame assertion was weak: `< 250ms` is a number with no control. ARM 2 first
reported worst-frame 201ms and PASSED. But entering a shop rebuilds an interior, which costs the
same with or without a server — so an absolute threshold measures Lane C's room build, not the
network. The arm now walks the same shop with the breaker tripped and asserts the DELTA: 202ms
control, 204ms killed, Δ+2ms. The death cost nothing the room didn't. F told A the same thing one
round ago; a number without a control is not a measurement.

THE TOUR — 3 of 4 frames, and the 4th is FILED NOT SHIPPED. The real crate (the money shot: its
own 120 records, price stickers, the keeper), a mint crate (captioned MINT — plausible, not
real), the classic covenant. 0 page errors. The FRONTAGE frame does not land after three
attempts and F will not ship a frame that doesn't show what its caption claims: the cluster
primitive answers "where is this town's heart", which is not the question a frontage frame asks
— Red Hill's heart is 2 shops/60m and does not contain Monster Robot. Next: ask B, who owns the
frontage and shipped its mark this round. Re-deriving B's anchor in a tour script is how you get
three bad frames.

Also: the interior beauty shot came back with the HUD across it — R21's lesson, fixed on the
street frames only. Now every DOM overlay is hidden, not a guessed selector list.

NOT TAGGED. qa.sh --strict --matrix and the frontage frame remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:05:17 +10:00
m3ultra
f91d734b32 Fable (integrator): R27 wave-3 — ratify ?live opt-in + the collateral assertion; F's remaining close enumerated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:39:54 +10:00
m3ultra
39cfc3d3ed Lane F R27 (v5.0): the tier-2 reader — pick-then-omit, and the server killed mid-session
#2 THE READER, built to C's §8 ruling (direct wire, no hook) and to what the waves measured,
not to the brief's letter:
  · `pack.gone` set, `price` updated IN PLACE — items[] never added to, removed from, or
    reordered, because its identity AND ORDER are the pick's stability (C §8.3). Filtering the
    array instead reshuffles the crate: C measured ONE sale changing 21 OTHER records.
  · fire-and-forget at room build, 1200ms abort, etag-cached, circuit-broken on first failure.
    The dig never blocks on the network (charter risk #1). No await anywhere near the door.
  · fires ONLY for a real-sourced shop. G's fence 404s a mint shop, and asking anyway would be
    R26's blind probe wearing a server. The manifest already knows which is which.

TIER 2 IS OPT-IN (`?live=1` / `?live=<url>`; absent → byte-identical to the beta). That is the
R26 lesson applied to a server: a default boot that probed for a GODVERSE host would ask a
question with no answer on every machine that isn't John's. Tier 2 is a place you go, not a
thing that happens to you. Auto-discovery is a v5.x question with a reachability contract.

PROVEN IN THE BROWSER (formal smoke class still to land):
  · sold-means-gone, via G's make-fixture (production `gone` is EMPTY — 0 sales since
    2026-07-01 — so a live assert would pass vacuously forever; G built the subject before the
    gate could be born blind): 3 sold → gone[3] → items[] 120→120 IDENTITY AND ORDER INTACT,
    117 survivors, COLLATERAL 0. C called this: both pick-then-omit and filter-the-array hide
    the sold record, so "the gone item never renders" cannot tell them apart — the obvious
    assertion is vacuous and goes green. Collateral is the one that discriminates.
  · KILL THE SERVER MID-SESSION → enter took 13.1ms, worst frame gap 14ms, the crate still
    renders its own 120 real covers, and ZERO console errors. Charter law #1 proven by
    execution instead of assertion — the thing A correctly refused to retire from the risk
    list until this gate went green.

Recorded, not hidden: after a mid-session kill the etag-cached `gone` persists for the session,
so a killed server does not resurrect sold records mid-walk. That is tier-1-plus-last-known, not
tier-1-exactly. It is the honest behaviour (a network blink must not re-stock a shop) and a
server-down BOOT is still pure tier 1 — but it is a nuance the brief's "degrades to tier 1
exactly" doesn't cover, so it goes in writing rather than in the gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:34:42 +10:00
m3ultra
70743db47e Fable (integrator): R27 wave-2 status — F's three load-bearing inputs (pick-then-omit, make-fixture for gone[], the lifecycle switch)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:13:39 +10:00
m3ultra
bee7563a76 Lane E R27 (v5.0): provenance v5-final — and the roster gap the freeze caught (ledger #6)
THE BUG THE FREEZE FOUND: redhill_godverse was ON DISK but NOT IN THE ROSTER — for three rounds. The v5
provenance table needed real numbers, so I measured instead of carrying the v4 row forward. The count
didn't match: 23 caches on disk, 22 rostered. The missing one was redhill_godverse — THE TOWN THE ENTIRE
v5 EPOCH WALKS TO.

Mine, squarely. index.json is Lane E's file (write_index()). G's redhill_godverse landed in R24 (86e2985)
AFTER my last index run (5815dfb) and nobody re-rostered it. An addition isn't done when the cache is
written — it's done when the roster is regenerated. That is the exact mirror of my own R24 lesson ("a
retirement isn't done when the cache is deleted; it's done when the consumers are swept"): I wrote the
lesson for deletions and missed its reflection for additions.

WHY FOUR GREEN ROUNDS NEVER CAUGHT IT — a gate-shape lesson. Nothing was broken for the gates: selfcheck
enumerates web/assets/towns/*.json DIRECTLY (so it saw the town; A even pinned its goldens) and F's crate
smoke boots BY KEY (?town=redhill_godverse). The roster's only consumer is B's HUD selector (hud.js, my
R20 ledger #2 design). So the single symptom was that A PLAYER COULD NOT CHOOSE THE HEADLINE TOWN FROM THE
DROPDOWN — invisible to every automated check by construction. A gate that reads around the artifact under
test cannot test it: the vacuous-gate species, one layer out.
Fixed: re-ran write_index() -> 23 rostered, 23 on disk, 0 missing. Only index.json moved (every cache
byte-identical again). selfcheck ALL GREEN 156,212, golden 0x3fa36874 untouched. qa.sh --strict --matrix
7 passed / 0 failed / 0 warn.

-> F (#5, the tour): the money shot walks Musgrave Road into Monster Robot Party. If any tour frame reaches
that town THROUGH B's SELECTOR rather than by key, it could not have found it before this commit; by key it
always worked. Worth one glance at your framing path.
-> B: the dropdown now carries both godverse towns. Your `${t.state ? ', ' + t.state : ''}` handles their
empty state gracefully — nothing needed, and the defensive write is why this cost nothing cosmetically.
-> G: your godverse caches carry no `state`, so they roster as state:''. Harmless (B degrades cleanly). For
"Red Hill, QLD" in the picker, emit `state` — write_index() already prefers the cache's own field.

PROVENANCE v5-FINAL. The ⟨v5.0-FINAL⟩ rows are measured, not inherited — the town-pack row still claimed
v4's "22 towns / 1192 shops", stale since my R24 ballarat retirement and redhill build (now 21 real / 1196
shops / 299 heroes, + both godverse towns = 23 rostered). Added the two v5 art rows: the real-stock atlas
(1 crate, 120 items, Monster Robot's own POS, sku_ ids) and the mint atlases (14 crates, 224 items, seeded
from real dealgod listings, mint_ ids). These are the table's FIRST AMBER ROWS — everything before v5 was
green web-ok; real product photography is the first asset class this project ships that a human must clear
before public release.

I VERIFIED MY OWN PROSE AND IT CAUGHT ME: I first wrote the mint crates as "6 record · 6 book · 3 toy" —
that is the breakdown of all FIFTEEN crates, which includes Monster Robot's REAL record crate. The 14 mint
crates are 5 record · 6 book · 3 toy. A count attributed to the wrong population, one line from being
frozen into the release record. Every other claim in the table re-measured true (21/1196/299/8 states/23
rostered/roster==disk/72/37/120 items/224 items/both zones in-house-green).

THE LICENCE LINE'S STORY, recorded as a mechanism. The amber rows are guarded by exactly one printed line,
so the freeze says why it took two rounds to make honest: R23's glob matched 0 files and returned 0; R25's
permissive `or` fallback let the print go silent while the check still passed — the flag stopped reaching a
human on real product photos while the gate said OK. Now: one spelling, one lookup with one owner, and the
line is COUNTED (judged N => N lines, or FAIL). Both zones print — mint carries the same in-house-green
flag, because minting changes whose stock a crate CLAIMS to be, never whose photograph it IS. Frozen today
at: atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:59:49 +10:00
m3ultra
cedc5872fb Lane G R27 (v5.0): tier 2 — the two read endpoints, the three fences, and the gate's missing subject
THE LIVE CRATE READS. godverse_server.py: /health, /shop/<gid>/stock, /shop/<gid>/crates, plus
start|stop|status (F's kill-the-server lifecycle), setup-role, make-fixture. Stdlib + psql. First
server code of the epoch, per the ratified G3.

Live against the real POS: crate 550 -> {baked:120, live:120, gone:0}, real prices, real VG+
grading, etag stable and ?since= -> 304. /crates returns 315 REAL crates with the shop's own
labels: TECHNO (57), $12-$15 TECHNO (46), DRUM N BASS (66).

THE THREE FENCES, each enforced by the machine, each measured:
- The credential: godverse_ro holds SELECT on inventory/crate/disc_cache and nothing else.
  UPDATE inventory -> permission denied. SELECT FROM customer -> permission denied. ONE grant
  carries both the §7 sandbox and the §9 PII fence — which is why §7 said enforce it at the
  credential, not in a code review.
- Write verbs absent: POST /reserve -> 501, POST /buy -> 501, GET either -> 404.
- Metadata only: no cover, no pixels on the wire; a mint shop's /stock is 404 (no POS to be live
  about), not an empty crate.

THE FINDING F NEEDS BEFORE IT WRITES THE GATE — `gone[]` has no subject in production. The
mechanism is right; the data has no movement. Monster Robot Party's POS records 5,567 sales ever
(400-800/month Dec->Jun) but 26 in July and NONE SINCE 2026-07-01, and hasn't been written since
2026-07-09 — the 07-16 dump is fresh, its data is stale (restored it to check: crate 550 holds 122
on both 07-15 and 07-16, identical). 0 of the crate's 120 records have ever sold. So `gone` is []
against the real POS, permanently, until the shop trades again — and an assert over it would pass
WITHOUT TOUCHING ITS SUBJECT, the exact species that has held four tags. So: make-fixture --sold 3
clones the POS (createdb -T) and marks 3 of the crate's OWN REAL SKUS sold -> verified
{baked:120, live:117, gone:3}, the three ids absent from items[]. F points at it via
GODVERSE_POS_DB for a deterministic gate; production reads the real POS untouched. A fixture is
not a lie; a green assert over an absent subject is.

LIFECYCLE (ledger #3): stop is a SIGKILL, not a graceful drain — the gate tests a death, not a
goodbye. Verified: stop -> connection refused (curl rc=7) -> status DOWN -> start -> live. No
admin verb was added to the API: a server whose whole point is "no write verbs" shouldn't grow a
remote kill.

THREE CORRECTIONS TO MY OWN G3 §4 (design -> as-built), all measured not reasoned:
1. gone:[sku] -> gone:[id]. §4 AS I RATIFIED IT said bare skus — wording that predates R25's sku
   ids and R26's fence law, and a bare number crossing two id spaces is the precise bug that law
   exists to stop. My doc was the stale rule meeting a case written after it: the epoch's
   signature failure, this time in the doc I wrote.
2. items[] drops release_id and cover — cover edges the API toward serving pixels; release_id is
   the pressing, and tier 2 speaks only in copies.
3. Write verbs answer 501, not the 404 my own docstring claimed — caught by running it. 501 is the
   STRONGER proof: 404 means a handler declined; 501 means no write handler exists in the process.
   -> F: assert 501.

Scoped: pathspec commit. web/assets/towns/index.json is another lane's live edit — left untouched.
Runtime pidfile moved out of the repo (it is not an artifact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:56:02 +10:00
m3ultra
6960bdd279 Lane C R27 (v5.0): the tier-2 seam ruling + LANE_C_PUB v5-FROZEN (ledgers #2, #6)
SEAM RULING (§8): bless the direct wire — NO lifecycle hook. F already holds currentAdapter
(interior_mode.js:67) and passes it to dig.open() PER DIG (:111), so the live read lands, F
updates the pack, the next riffle picks it up; not-landed/slow/dead => the adapter is simply the
tier-1 pack => the dig never blocks (charter risk #1). No C lifecycle change.

BUT THE QUESTION UNDERNEATH IT IS C'S, AND THE OBVIOUS WIRING IS WRONG. pickRealOffers indexes
pack.items[(rr()*len)|0], so FILTERING THE ARRAY RESHUFFLES THE CRATE. Measured, 5 crates: one
record selling changed 21 OTHER records (6/2/2/6/5 collateral). Pick-then-omit (draw from the
stable full list, drop gone after) = 0 collateral: a crate with 3 sold shows 13, not a different
16. Selling one record must remove one record, not re-stock the shop.

THE TRAP: both forms hide the sold record — so F's planned assertion ("a real gone item never
renders") GOES GREEN ON THE WRONG WIRING. It cannot see the 21. -> F: assert on COLLATERAL (the
survivors are unchanged across a gone delta), not absence. Vacuous-gate law, third application,
and the cheapest to miss because it passes.

LANDED (dig.js): pack.gone (Set|array) omitted at PICK time. F's invariant published: update item
FIELDS in place (live price) freely; NEVER add/remove/reorder items[] — that identity+order IS the
seeded pick's stability (R25's positional-id lesson, one layer up).

DETERMINISM BOUNDARY sits exactly here: server down => byte-identical tier 1 (gate-able); server
up => the crate legitimately changes (tier 2 is non-deterministic BY DESIGN), so no gate may
assert dig determinism with the server up, and pack.gone never enters a seeded stream or golden.

SCOPED: the bin fan is decorative (a different seeded pick from the dig's offers) — a sold cover
may linger there until rebuild. That's when I'd publish a post-build hook; the epoch's claim
doesn't need it, because the crate you RIFFLE is true.

VERIFIED: pure-function measurement for the collateral claim, then end-to-end through the real
dig's public surface (pull -> Esc -> scroll -> pull): sold record gone, survivors 4/4, collateral
0, items[] untouched at 120, live in-place price shows on the next pull. (My first harness
returned the same title 4x — I never un-pulled — so its number was an artifact; re-ran it properly
rather than report a metric I'd just watched lie.)

DOCS FREEZE (§6): LANE_C_PUB is v5.0-FROZEN, whole document, with the case law at the top: three
amendments here exist because a rule of mine met a case written AFTER it, and both times the lane
building to my rule took the red. Header now states it: when a line meets a case it didn't
foresee, the line is the stale thing — amend it, don't bend the case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:53:02 +10:00
m3ultra
01f398960b Lane B R27 (on-call): the R26 handshake verified against G's real manifest
R27 owes B nothing ("B/D: notes current; nothing else owed"; "B/D on-call").
This is on-call work on F's #5: the tour is specified as "Monster Robot Party's
frontage (B's mark visible)" — a claim R26 could not test, because G's manifest
did not exist and B proved the treatment against a temp manifest it scaffolded
and deleted. G shipped the real one (36d10ca). Now measured, not assumed.

Monster Robot Party is in redhill_godverse — NOT newtown_godverse, the only
town B ever tested the mark in. F's money shot is posed in a town this
treatment had never been verified against. It works.

The handshake held unmodified: G's {version, shops:[{godverseShopId, types,
sourcing}]} lands inside B's tolerant reader with zero coordination. The
tier→sourcing rename did not touch B — the mark never reads that field, so a
rename of it cannot break the footpath. A cue that reads no field cannot break
when the field moves.

Both arms proven live, the discriminating arm for the first time (R26's temp
manifest marked all 18 keyed shops, so "the manifest is the authority, not the
key" was never exercised). Redhill: 9 keyed, only 4 stocked.
  Monster Robot Party #3962749 (real)  keyed + in manifest → MARKED
  Silky Oak Furnature #31              keyed, not in manifest → BARE
  Thrift Shop and Eternity Boutique    keyed, not in manifest → BARE
Two keyed shops stand metres from a stocked one wearing no mark, because they
have no crate. The street advertises exactly the crates that exist. (#31 is
Fable's R25 id-namespace shop; the mark reads godverseShopId only, unaffected.)
Frontage 135 draws / 74,354 tris, 0 page errors, manifest prefetched at boot.

Intel for F's #5, no action owed: the novelty_record landmark (B's furniture —
a giant vinyl disc marking record shops) stands 2.5m off the frontage and is
prominent head-on. Ray-grid occlusion so F can frame without guessing:
  mark+sign   head-on 4% blocked   off-axis +4m 0%
  window      head-on 22% blocked  off-axis +4m 44%
Shoot HEAD-ON: the vinyl sits low (y~1.3), the mark rides high (y~3.3), and
stepping off-axis doubles window occlusion instead of helping. I first read the
black disc as a render bug — it isn't, it's the landmark working; recorded so
the next reader doesn't re-open it.

Notes-only; no code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:52:57 +10:00
m3ultra
839575b28d Lane A round 27 (v5.0): the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6)
Docs only. No code, no golden moved — correct: tier 2 touches no plan, no cache, no atlas bytes. selfcheck
ALL GREEN 156,212/156,212; scaffold + consistency green; classic 0x3fa36874 and gig 0xb1d48ea1 frozen. The
whole stock epoch moved zero plan goldens, which was the determinism boundary's entire promise.

CITY_SPEC v5 SECTION — written from the ARTIFACTS, not the briefs (this epoch's own lesson):
- The tier ladder: 0 parody / 1 static real / 2 live GODVERSE, each degrading softly to the rung below.
- `sourcing` is NOT `tier`, and C's ruling is the precise one — both ship in every index. `tier: 1` is the
  LADDER RUNG (a mint atlas is still a static file, so still rung 1); `sourcing: "real"|"mint"` is WHERE
  THE CONTENTS CAME FROM. Different axes. Collapsing them is exactly how a mint crate comes to read as
  real. Measured: 15 keyed atlases = 1 real + 14 mint.
- The three id-space fences, verified rather than restated (below).
- The manifest: existence DECLARED, never probed — the root fix for fail-soft-by-404 colliding with the
  zero-console-errors law. No entry, no fetch, zero 404s, no attribution exceptions. 15 entries / 15 dirs.
- Ruling 2 as law (mixed caches are the design; no gate may assert it away — mine did).
- Lane A's seam is exactly one field: godverseShopId, and `'godverseShopId' in shop` is the tier test.

THE ID FENCES ARE VERIFIED, NOT ASSERTED. The plan-id/godverse-id trap is STILL LIVE in the data and I
reproduced it: in redhill_godverse, bare `31` is "Wholefood Cafe" as a plan id and "Silky Oak Furnature" as
a godverse id — the exact ambiguity that made F's enterShop(31) enter the wrong shop and pass a soft-fall
assertion vacuously on the wrong subject. So it goes in the spec as a STANDING RULE, not a fixed bug: no
code or gate may compare a bare number across the two spaces. (Within the godverse space, G's two halves —
census <=2992, dealgod store ids — are numerically disjoint by construction, which is what makes the opaque
key safe.)

THE RISK LIST, JUDGED LINE BY LINE — AND I DID NOT PRE-RETIRE ANYONE'S GATE:
1. CARRIED. Tier-2 latency/stutter is this round's defining gate and I'm writing in wave 2 — F's reader and
   kill-the-server gate do not exist yet. NOTHING retires this line except that gate going green. Retiring
   it from a doc while the code is unwritten would be the precise species this epoch spent four holds
   catching: a rule meeting a case that doesn't exist yet. The offline law is a promise until #3 makes it
   falsifiable; that's the whole point of #3.
2. RETIRED — measured, and the remedy the risk NAMED is the remedy that shipped ("crate rotation rather
   than bigger atlases"). Max 2 atlases/shop vs C's hard cap of 2; the 14 mint shops sit at 16 items / 1
   atlas / 1024²; only the one real POS shop earns 2048² (120 items, 2 atlases, 1.2 MB) under the 60+ rule.
3. RETIRED — DESIGNED OUT, not solved. The risk assumed a fuzzy-matching problem ("names drift, shops
   move"). There is no matcher: identity is an explicit opaque key G emits. The predicted failure cannot
   occur in the predicted form — and the proof is the headline shop: Monster Robot is in NEITHER the census
   NOR OSM, so no name-matching strategy could ever have found it. Mis-stocking is fenced structurally
   instead (duplicate ids error, uniqueness over defined ids, the orphaned-atlas gate).
4. SPLIT. Write-back RETIRED by John's ruling recorded as MECHANISM, not config: /reserve and /buy are
   ABSENT, not stubbed — races and reservation semantics cannot occur because the verbs don't exist, and
   "prove the absence" beats any sandbox flag a config typo could flip. (Option B re-opens this line
   deliberately at v5.x, behind a hold budget.) The `gone` read half is CARRIED to F's #4.
5. Unchanged, extended: static-atlas determinism proved in practice (G's index-only sku re-emit moved no
   .webp bytes); the fail-soft ladder proved on every rung; the id fences now have case law behind them.
6. NEW, CARRIED — the orphaned atlas, with a live near-miss on the books.
7. NEW, CARRIED — "real" is one shop wide: 14 of 15 keyed atlases are mint, because exactly one shop has a
   POS. Honest and marked, not a defect — but the epoch's headline claim rests on ONE shop, and any future
   claim of scale needs either siblings for monsterrobot or continued care that `sourcing` stays legible to
   humans and not just to gates.

THE GATE THAT ARMED ITSELF (the law working unattended, worth recording). My R25 orphaned-atlas gate SKIPped
on newtown_godverse all through R25 — "no committed atlas keys to this town", subject absent, so it said so
instead of passing quietly. The moment G's 14 mint atlases landed it STARTED BINDING ON ITS OWN and passed:
156,211 -> 156,212, and that +1 IS the gate arming itself. Nobody edited it. That is what the vacuous-gate
law buys — a gate that waits, in the open, for its subject to exist.

G: redhill_godverse's lift drops keyed shop 2286 "Empire Revival"; you stocked its numeric neighbour 2287.
Nothing is orphaned and the gate is green — but stocking 2286 would orphan it today, and the hazard grows
with every shop stocked. Carried as risk #6.

F: nothing of mine gates your tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:52:47 +10:00
m3ultra
2cc6c32c73 Fable (integrator): V6 LIVING STREET draft charter — John's NPC-variety idea, grounded in the measured local stack (MOCAPGOD/MIRPAMO/MODELBEAST), three spikes first
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:40:47 +10:00
92 changed files with 7163 additions and 212 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,7 +3,190 @@
*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 26) · owner: PROCITY-C · reviewer: Fable Last updated: 2026-07-18 (round 30) · owner: PROCITY-C · reviewer: Fable
---
## 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.
### The trap my own parked note set
v3.0's deferred note said: *"`userData.audioEmitter = musicKey` so Lane B can position/spatialise the
bed"*. **Both halves were wrong**, and the second one is dangerous:
1. **The shape.** The engine holds no reference to the room group — it would have to traverse a scene it
cannot reach. Emitters belong on `room.audio` as data, where they ride the **existing**
`playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook, no F plumbing.
2. **The method.** "Spatialise" invites a `PannerNode`. **Nothing in this tree sets `ctx.listener`** (zero
hits, whole tree), so a panner resolves against a listener frozen at (0,0,0) — and since rooms are built
in room-local coords at the origin, that is *the room's own centre, forever*. Measured with
`OfflineAudioContext` on a real pub stage bed:
| wiring | RMS at door | RMS at stage | ratio |
|---|---|---|---|
| panner, listener never set (**the engine as it ships**) | 0.106905 | 0.106905 | **1.0000** |
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
It **plays, throws nothing, warns nothing, and never changes by one sample as you cross the room**
while failing at the only thing it exists to do. *"Routed through a panner?"* yes. *"Audible in a
venue?"* yes. *"In a shop?"* yes. All green, all worthless. **The only assertion that can see this is a
DELTA: gain at the door ≠ gain at the stage.** Vacuous-gate law, 4th application — and this one had no
author to blame but me. There is no listener code anywhere to teach a lane otherwise.
**The house idiom was already right**: tram rumble (R=55), door spill (R=9), gig spill (R=26) are all
hand-rolled distance-gains against `PROCITY.camera.position`. No listener, no panner. Emitters match it.
### What shipped (C's files only)
- **`room.audio.emitters`** = `{ stage?, counter? }`, room-local, 3-dp, **18/18 same-seed deep-equal**.
`r` (reach) is the **room diagonal, not a constant** — measured diagonals 9.8115.63 m, and scaling to
the room keeps door→stage proximity in a tight **0.420.59** band from a pokey band_room to a hall pub.
`floor` is the room-filling residue, **never 0** — a PA fills a pub; what changes as you walk in is the
*balance*, not the presence. Swing **2.654.52 dB** (John R20: *subtle changes are great*).
- **The till rings from the counter***audible positioning in a normal shop with **no engine change***,
via B's already-public `playSfx(key,{gain})`: C does its own distance math, hands B a scalar. Measured
through the real BUY button in a real record shop: **0.9489 @ 0.60 m vs 0.6436 @ 4.19 m = 3.37 dB**;
negative control (no emitters) fires at exactly **1.0**. `sfx-till.ogg` had been on disk since 15 Jul and
had **never once played**. Routing, not content — exactly as the brief said.
- **`?mute=1` now silences the crate riffle.** See below.
**Why the venue half is an ask, not a ship:** a **one-shot** resolves its gain once, at fire time, from a
position C already knows. A **bed** must be re-gained every frame inside the engine's `update()` loop —
B's file. The split is structural, not political. B got a ~12-line paste-ready diff in the house idiom; C
did not touch audio.js. The letter of "consulted not modified" could have been satisfied by reaching
through B's exposed `state.layers` diag to rewire its graph from outside — **that backdoor is strictly
worse than the edit it avoids** (invisible to B, breaks on any change of B's). Refused.
### Two bugs found on the way
- **F drops emitters on the one night they matter.** `interior_mode.js:289` rebuilds the spec on a gig
night: `{ musicKey: ra.gigKey, toneKey: WALLA_KEY }` — every unnamed field dies. Measured on a real
gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive: false`**. Silent, fail-
soft, no error. -> F: don't just spread `ra` — pass emitters **deliberately, gig-night only**, because on
a quiet night the radio and room-tone genuinely *are* room-filling.
- **`?mute=1` never silenced the riffle (R5 → R28).** `dig.js` opened a **second, private AudioContext**
straight to `destination`, breaking both of audio.js's stated laws (*ONE AudioContext*; *mute forces
silence*). It survived three epochs because **no gate asserts mute ⇒ silence**: the engine returns its
silent surface and looks honest *from its own side*, while the sound came from somewhere it never knew
existed. *A gate that asks the engine whether it is muted can only ever answer yes.* Fixed by reading
B's **public** `.muted` getter (consulted, not modified), URL fallback when standalone. Measured with a
positive control so the probe is provably able to see sound: live 1 ctx / 1 source; **muted 0 / 0**.
Blips stay procedural on purpose — zero-fetch, deterministic, and they still work under `?noassets=1`
where a sampled riffle cannot. Only mute was broken; only mute was fixed.
### Harness honesty (again)
First till rig reported a clean 3.37 dB — from a **contaminated harness**. `createDig` appends a panel per
call and `close()` doesn't remove it, so `querySelector` was clicking **trial 1's stale button** every
time. The tell was `panelUp: false` on all three trials, and the **negative control** returning 0.6436
where it must return 1.0. The gains happened to match the formula exactly — I still refused them and
re-ran isolated (`panelCount: 1`, `panelUp: true`, control **1.0** ✓). R27's lesson holds: *never report a
number from a rig you have watched lie, even when you like the number.* The control is what caught it.
QA GREEN (6/0/0/0). Docs: **LANE_C_AUDIO.md → v3.1** (contract + both asks + every measurement above).
---
## Update 2026-07-17 (round 27, v5.0 THE LIVE CRATE) — the tier-2 seam + LANE_C_PUB v5-FROZEN
R27 §Lane C (ledgers #2 + #6). Wave 1 — ran after F tagged `v5.0-beta` (`78848ed`, 7/0/0).
### The seam ruling (§8): bless the direct wire — but the *form* was the real question
Fable asked: publish a post-build hook, or bless the direct wire? **Answer: bless it — no lifecycle hook.**
F already holds `currentAdapter` (`interior_mode.js:67`) and passes it to `dig.open()` **per dig** (`:111`),
so the live read lands, F updates the pack, and the next riffle picks it up. Read not landed / slow / dead ⇒
the adapter is simply the tier-1 pack ⇒ the dig never blocks. No C lifecycle change.
**But the question underneath it — *how* `gone` applies — is C's, and the obvious wiring is wrong.**
`pickRealOffers` indexes `pack.items[(rr()*len)|0]`, so **filtering the array reshuffles the crate**.
Measured across 5 crates: **one record selling changed 21 OTHER records** (6/2/2/6/5 collateral). Pick-then-
omit — draw from the stable full list, drop gone *after* — has **0 collateral**: a crate with 3 sold shows
13, not a different 16. Selling one record must remove one record, not re-stock the shop.
**And the trap: both forms hide the sold record.** So F's planned sandbox assertion — "a real `gone` item
never renders" — **goes green on the wrong wiring**. It cannot see the 21. → **F: assert on collateral (the
survivors are unchanged across a `gone` delta), not absence.** That's the vacuous-gate law's third
application, and the cheapest to miss, because it passes.
**Landed** (`dig.js`): `pack.gone` (Set|array) omitted at pick time; **F's invariant published** — update
item *fields* in place (live price) freely, **never add/remove/reorder `items[]`**; that identity+order IS
the seeded pick's stability (the same lesson as R25's positional ids, one layer up).
**Determinism boundary sits exactly here:** server down ⇒ byte-identical tier 1 (gate-able); server up ⇒ the
crate legitimately changes — tier 2 is non-deterministic *by design*, so **no gate may assert dig determinism
with the server up**, and `pack.gone` never enters a seeded stream or a golden.
**Scoped honestly:** the bin fan is decorative (a different seeded pick from the dig's offers), so a sold
cover may linger there until rebuild — that's when I'd publish a post-build hook, and the epoch's claim
doesn't need it, because *the crate you riffle is true*.
**Verified** — pure-function measurement for the collateral claim, then end-to-end through the real dig's
public surface (pull → Esc → scroll → pull): sold record gone · **survivors 4/4 · collateral 0** · `items[]`
untouched at 120 · live in-place price shows on the next pull. *My first dig harness returned the same title
4× (I never un-pulled), so its "survivors" number was a harness artifact — I re-ran it properly rather than
report a metric I'd just watched lie.*
### Docs freeze (§6): LANE_C_PUB is **v5.0-FROZEN**, whole document
Marked, with the case law kept at the top rather than buried: three of this doc's amendments exist because a
rule of mine met a case written *after* it (§7's naming vs its own loader; §7.2a's `sku_` before mint) — and
both times the lane building to my rule took the red. The header now says it plainly: **when a line here
meets a case it didn't foresee, the line is the stale thing — amend it, don't bend the case.**
--- ---

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

@ -3,6 +3,63 @@
*Lane brief: `docs/LANES/LANE_G_GODVERSE.md`. This file follows your progress-file convention — *Lane brief: `docs/LANES/LANE_G_GODVERSE.md`. This file follows your progress-file convention —
newest round first. Fable-on-ultra writing.* newest round first. Fable-on-ultra writing.*
## Round 27 (2026-07-17) — tier 2: the two read endpoints (ledger #1)
**Landed:** `pipeline/godverse_server.py``/health`, `/shop/<gid>/stock`, `/shop/<gid>/crates`,
plus `start|stop|status` (F's kill-the-server lifecycle), `setup-role`, and `make-fixture`.
Stdlib + `psql`; no server code existed before this round, per the ratified G3.
**Live now, against the real POS:** crate 550 — `{baked:120, live:120, gone:0}`, real prices, real
`VG+` grading, etag stable across reads and `?since=`**304**. `/crates` returns **315 real
crates** with the shop's own labels — `TECHNO` (57), `$12-$15 TECHNO` (46), `DRUM N BASS` (66).
**The three fences, each enforced by the machine (measured, not reviewed):**
| fence | proof |
|---|---|
| the credential — `godverse_ro` | `UPDATE inventory`**permission denied** · `SELECT FROM customer`**permission denied** |
| write verbs absent (§7 ruling) | `POST /reserve`**501** · `POST /buy`**501** · `GET` either → 404 |
| metadata only | no `cover`, no atlas bytes on the wire — covers stay static tier-1 files |
| tier 2 = real shops only | a `mint` shop's `/stock`**404** (no POS to be live about), not an empty crate |
| the id fence | `gone[]` carries the atlas `id` verbatim → `gone.includes(item.id)`, zero transform |
**One grant carries both the sandbox and the PII fence** — that's why §7 said enforce at the
credential, not in a code review. `godverse_ro` holds SELECT on inventory/crate/disc_cache and
nothing else, so customer/staff/sales are unreachable *by the process*, not by our good intentions.
**⚠ THE FINDING F NEEDS BEFORE IT WRITES THE GATE — `gone[]` has no subject in production.**
The mechanism is right; the data has no movement. Monster Robot Party's POS records **5,567 sales
ever** (400800/month Dec→Jun) but **26 in July and none since 2026-07-01**, and hasn't been
*written* since **2026-07-09** — the 07-16 dump is fresh, its data is stale (I restored it to
check: crate 550 holds 122 on both 07-15 and 07-16, identical). **0 of the crate's 120 records
have ever sold.** So `gone` is `[]` against the real POS, permanently, until the shop trades again.
An assert over it would pass **without touching its subject** — the exact species that has held
four tags. So I built the honest way out: **`make-fixture --sold 3`** clones the POS
(`createdb -T`) and marks 3 of the crate's *own real skus* sold → verified
`{baked:120, live:117, gone:3}`, the three ids absent from `items[]`. F points the server at it
via `GODVERSE_POS_DB` and gets a deterministic gate; production reads the real POS untouched.
**A fixture is not a lie; a green assert over an absent subject is.**
**Lifecycle for the kill-the-server gate (ledger #3):** `stop` is a **SIGKILL**, not a graceful
drain — the gate tests a death, not a goodbye. Verified: `stop` → connection refused (curl rc=7,
no response at all) → `status: DOWN``start` → live again. F drives it; no admin verb was added
to the API, because a server whose whole point is "no write verbs" shouldn't grow a remote kill.
**Three corrections this round forced into my own G3 §4 (design → as-built):**
1. **`gone:[sku]``gone:[id]`.** §4 as *I ratified it* said bare skus — wording that predates
R25's sku ids and R26's fence law. A bare number crossing two id spaces is the precise bug that
law exists to stop. **My doc was the stale rule meeting a case written after it** — the epoch's
signature failure, and this time it was mine, in the doc I wrote.
2. `items[]` drops `release_id` and `cover`: `cover` edges the API toward serving pixels (it never
should), and `release_id` is the *pressing* while tier 2 speaks only in *copies*.
3. **Write verbs answer 501, not the 404 my own docstring claimed** — caught by running it. 501 is
the *stronger* absence proof: a 404 means a handler ran and declined; 501 means there is no
write handler in the process. **→ F: assert 501.**
**→ Lane F, the four asserts I can hand you pre-measured:** `POST /reserve|/buy` → 501 · a game
buy cannot reach the server (no verb *and* no grant — prove both) · `gone` fixture arm as above ·
server dead → connection refused, so the reader must treat it as tier 1 in silence.
## Round 26 (2026-07-17) — the mint baker, the crate menu, the manifest (ledgers #2, #6) ## Round 26 (2026-07-17) — the mint baker, the crate menu, the manifest (ledgers #2, #6)
**Landed (main, pathspec-atomic):** **Landed (main, pathspec-atomic):**

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)
@ -73,6 +100,7 @@ master off:
| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader | | `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader |
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster | | `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster |
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock | | `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock |
| `?live=1` · `?live=<url>` | **off** | **tier 2** — live stock from the GODVERSE server (sold-in-the-real-shop means gone in the game). Opt-in by design: absent ⇒ byte-identical to tier 1, and the game never asks a question it has no reason to expect an answer to |
| `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness | | `?noassets=1` · `?plansrc=osm` · `?mute=1` · `?dbg=1` | off | asset-free (zero network) · OSM plan source · silence · the `window.DBG` capture/soak harness |
| `?plansrc=osm&town=<key>` | — | boot a **real Australian town**: fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `web/assets/towns/SOURCES.md`). **REAL ROADS:** a cache with `roads[]` (schema v2) builds the CityPlan street graph from the town's **actual OSM streets**; without `roads[]` a cache marches onto synthetic avenues. Or just **pick a town from the HUD selector** — no URL needed | | `?plansrc=osm&town=<key>` | — | boot a **real Australian town**: fixtures `melbourne`/`katoomba`/`silverton`, or a real OSM cache `bendigo_real`/`castlemaine_real`/`fremantle_real`/`katoomba_real`/`newtown_real` (ODbL, `web/assets/towns/SOURCES.md`). **REAL ROADS:** a cache with `roads[]` (schema v2) builds the CityPlan street graph from the town's **actual OSM streets**; without `roads[]` a cache marches onto synthetic avenues. Or just **pick a town from the HUD selector** — no URL needed |
@ -115,7 +143,7 @@ the one below, so the game never depends on a server to be a game:
|---|---|---| |---|---|---|
| **0 · parody** | seeded canvas sleeves (v1) — any boot, always works | yes — the QA baseline | | **0 · parody** | seeded canvas sleeves (v1) — any boot, always works | yes — the QA baseline |
| **1 · static real** | per-shop atlases baked from the shop's own POS | yes — atlases are files | | **1 · static real** | per-shop atlases baked from the shop's own POS | yes — atlases are files |
| 2 · live GODVERSE | live stock, sold-means-gone *(design ratified, not built)* | no — by design | | **2 · live GODVERSE** | `?live=1` — live stock off the real till: **sell a record in the shop and it's gone from the game crate** | **no — by design** |
**The offline law:** the server enriches, never gates. `?noassets=1` still fetches **zero bytes** and plays **The offline law:** the server enriches, never gates. `?noassets=1` still fetches **zero bytes** and plays
silent-and-fine; a shop whose atlas is missing falls soft to parody rather than borrowing someone else's silent-and-fine; a shop whose atlas is missing falls soft to parody rather than borrowing someone else's

View File

@ -1,11 +1,88 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check ALL GREEN (156,211/156,211); rounds 225 closed** (see below). *Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 230 closed**.
R25 (v5.0-alpha): the sweep reconciled with **Ruling 2** (keyed asserts are per-layer), the uniqueness check R30 (v7.0-alpha ledger #6): the rotation boundary VERIFIED — F's `day` salt rides runtime streams only,
rebuilt so it can actually fail, the **orphaned-atlas gate** added, and **six goldens pinned** — F's tag is plan generation structurally cannot receive it; all goldens frozen, zero re-pins.*
unblocked from my side. classic `0x3fa36874` / gig `0xb1d48ea1` frozen.*
## 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)
The ruling asked me to land B's one-liner in the real-roads seating and to **measure the marched path
rather than assume it was clean**. I measured all three paths, and the ruling's premise was wrong in the
half that mattered: **synthetic is broken too**. Not "every real town since R18" — **every town, every
path, since v1**. Confirmed numerically (0 toward / 681 away, dot 1.0000) and then with my own eyes:
`?classic=1`, synthetic main street = three blank boxes; walk into the block and NUMBAT PLAYTHINGS is
there, facing the dirt. 637 of 681 lots pointed their facade at **no street at all**.
- **Four sites, one sign.** `plan.js:122` (synthetic strip), `plan.js:244` (market row), `plan_osm:330`
(marched avenue), `plan_osm:443` (real roads — B's proven one-liner). All `atan2(outward)` where the
intent was `atan2(outward)`, and **every one says so in its own comment**. The code never did what it
said; CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract.
After: synthetic 681/681, marched + all 3 fixtures 100%, real-roads katoomba **0/72 → 72/72** (B's number).
- **The covenant moved, on John's ruling.** Fixing synthetic necessarily moves `0x3fa36874`, frozen
"forever" since R16 — not Lane A's call, so I stopped and asked. Ruling: **amend and fix all paths** (the
covenant is anti-drift machinery, not a defect preservative). **51 goldens re-pinned in one commit**;
amendment recorded in CITY_SPEC as a one-time, ruling-gated precedent.
- **A second bug the fix exposed, fixed with it:** `buildRealRoads` seated blocks at `KERB = 4 m` from the
**centreline**, but a `main` carriageway is 10 m wide (kerb at 5 m) — **every main-street building has
stood 1 m inside the road since R18**. Invisible until the shopfront turned around and its poster landed
in the traffic lane. Blocks now seat behind the kerb per A's own corridor law. Drops 6.4% → **6.6%**, counted.
- **The cross-convention gate (the lesson made law).** It does not restate a spec line — that is the trap F
proved (72/72 "facing" over blank walls). It replicates **Lane B's own** `buildings.js` maths and asks
whether the quad the *renderer* draws lands nearer the street than the lot centre. Fires if A's `ry` **or**
B's `zf` drifts. It would have caught this on day one.
- **→ F (blocking their re-gate):** `scaffold_check` is RED until F re-pins the covenant hashes duplicated
in **F's own files** (`flags_check.py` `GOLDEN_HASH`/`GIG_GOLDEN`, `scaffold_check.mjs` `TOWN_GOLDENS`,
two tour captions). Exact values handed over in LANE_A_NOTES §R27w5; I can't touch F's files.
*Previous status: 156,212/156,212; rounds 227 closed (see below).
R27 (v5.0 close): **CITY_SPEC v5 section** (tier ladder · `sourcing` vs `tier` · the three id-space fences ·
the manifest · Ruling 2) + the **charter risk list judged line by line** — risk #1 deliberately CARRIED, not
pre-retired, because F's kill-the-server gate is what retires it. classic `0x3fa36874` / gig `0xb1d48ea1`
frozen; **no golden moved this epoch**.*
*Date: 2026-07-17 · owner: PROCITY-A (Opus 4.8)* *Date: 2026-07-17 · owner: PROCITY-A (Opus 4.8)*
## Round 27 (2026-07-17) — v5.0 THE LIVE CRATE: the docs freeze (ledger #6)
Lane A's half of the epoch close. Docs only; **no code, no golden moved** — correct, since tier 2 touches no
plan, no cache, no atlas bytes. selfcheck **ALL GREEN 156,212/156,212**.
- **CITY_SPEC v5 section**, written from the artifacts rather than the briefs (the epoch's own lesson):
the **tier ladder** (0 parody / 1 static real / 2 live, each degrading softly); **`sourcing` is not
`tier`** — both ship in every index, `tier: 1` is the rung and a mint atlas is *still* rung 1, while
`sourcing: "real"|"mint"` is provenance (15 keyed atlases = **1 real + 14 mint**); the **three id-space
fences**; the **manifest** (existence declared, never probed — the root fix for fail-soft-404 vs the
zero-console-errors law; 15 entries, 15 dirs); **Ruling 2**; and A's single seam, `godverseShopId`.
- **The id fences, verified not restated.** The plan-id/godverse-id trap is still live in the data and I
reproduced it: in `redhill_godverse`, bare **`31`** is "Wholefood Cafe" as a plan id and "Silky Oak
Furnature" as a godverse id — the exact ambiguity that made F's `enterShop(31)` assert vacuously on the
wrong shop. It's a standing rule, not a fixed bug, and the spec says so.
- **The risk list judged, without pre-retiring anyone's gate.** #1 (tier-2 latency) **CARRIED** — written in
wave 2, F's reader doesn't exist yet, and *nothing retires that line except the gate going green*;
retiring it from a doc while the code is unwritten is the precise species this epoch spent four holds
catching. #2 **retired** (measured: max 2 atlases/shop vs C's cap of 2; mint at 16 items/1024², only the
real POS shop at 2048²; the mecca case served by crate rotation — the remedy the risk itself named).
#3 **retired — designed out, not solved**: it assumed fuzzy name-matching, and there is no matcher;
Monster Robot is in *neither* census nor OSM, so no matcher could ever have found it. #4 **split**:
write-back retired by John's ruling-as-mechanism (`/reserve`+`/buy` absent, not stubbed — you can't race a
verb that doesn't exist), the `gone` read carried to F's #4. Two new carried: **#6 orphaned atlas**,
**#7 "real" is one shop wide** (14/15 are mint — honest and marked, but the headline rests on one shop).
- **The gate that armed itself.** My R25 orphan gate SKIPped on `newtown_godverse` while no atlas keyed
there; G's mint atlases landed and it **began binding unattended** — 156,211 → **156,212**, and that +1 is
the gate arming itself. Near-miss carried: redhill's lift drops keyed shop **2286 "Empire Revival"**;
G stocked neighbour **2287**. Stocking 2286 today would orphan it.
## Round 25 (2026-07-17) — v5.0-alpha: the sweep vs Ruling 2 + the six pins (ledger #1) ## Round 25 (2026-07-17) — v5.0-alpha: the sweep vs Ruling 2 + the six pins (ledger #1)
All 8 tree reds cleared — **6 were my own gate asserting Ruling 2 away**, 2 were goldens awaiting my pin. All 8 tree reds cleared — **6 were my own gate asserting Ruling 2 away**, 2 were goldens awaiting my pin.

View File

@ -28,7 +28,21 @@ considered and parked: nothing in v1 needs it, and the asset pipeline (GLB, web-
- **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square. - **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square.
- **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal - **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal
points at its street edge. GLB convention: origin at base, facing **Z**, real-world scale. points at its street edge.
- **THE TWO CONVENTIONS — they are different things, and conflating them cost nine rounds
(ROUND27). Read both:**
- **The FACADE convention (the lots contract, `lot.ry`): the visible shopfront is local `+Z`**
world `(sin ry, cos ry)`. This is B's canon and the de facto truth since v1 — `buildings.js`
draws the facade quad and door at local `z = +d/2`, `venue.js` seats the door-glow/queue/
posters there. **`lot.ry` aims +Z at `frontEdge`.**
- **The SIM/RIG convention (props, not lots): a GLB is modelled facing `Z`** (origin at base,
real-world scale) ⇒ rig-forward is world `(sin ry, cos ry)`. This describes *how a model is
authored*, **not which way a lot faces.**
- **They are opposites, so a check written in one silently passes over a world built in the
other.** CITY_SPEC:71 wrote the SIM convention into the LOTS contract by ambiguity; each
generator implemented one half; every golden stayed green over a town whose every shopfront
faced away. **Never assert a lot's facing against a spec line — assert it against the
geometry the renderer actually draws** (`selfcheck.js` now does exactly that).
- **Seeded everything, `Math.random()` is banned in generation code.** - **Seeded everything, `Math.random()` is banned in generation code.**
`web/js/core/prng.js` is the only randomness source: `web/js/core/prng.js` is the only randomness source:
- `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`). - `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`).
@ -68,8 +82,11 @@ Schema v1 (Lane A owns it; extend, don't break):
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking). // onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ], lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill' // use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
// ry: Y-rotation (radians) so a GLB modelled facing Z ends up with its facade's OUTWARD normal // ry: Y-rotation (radians) so the lot's VISIBLE FACADE — local +Z — points at frontEdge.
// pointing at frontEdge. World facing = (sin ry, cos ry). Lane B rotates the shell by ry. // Facade/door/frontage normal = local +Z ⇒ world (sin ry, cos ry). Lane B rotates the shell by ry
// and draws the shopfront at local z = +d/2 (buildings.js), so +Z IS the street face. ROUND27.
// (The SIM/rig convention — a GLB modelled facing Z, world (sin ry, cos ry) — is a DIFFERENT
// thing and is documented as such below. It is not the lots contract. See the amendment.)
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent). // frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ] shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close 23. // type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close 23.
@ -256,9 +273,30 @@ night in v1; interior-mapping shader is a stretch goal).
> did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full > did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full
> experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and > experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and
> **`?classic=1`** forces all four off at once: > **`?classic=1`** forces all four off at once:
> - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2 > - **Classic law (the frozen regression target):** the `?classic=1` boot is byte-identical to the v2
> baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no > baseline — synthetic fingerprint **`0x5f76e76`**, zero gig layer, **zero fetch delta** (no
> `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate. > `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate.
>
> > **THE COVENANT AMENDMENT — ROUND27, THE FACADE FIX (John's ruling; the only amendment in five epochs).**
> > The covenant froze `0x3fa36874` "forever" from R16 to R27. That hash anchored **a wrong world**: every
> > shopfront in every town — synthetic and real — faced *away* from its street, because CITY_SPEC:71 wrote
> > the SIM convention into the LOTS contract (see *Units, axes*). The covenant did exactly its job for nine
> > rounds: it held the base town byte-identical. It just held it identical to something broken, and no gate
> > could see it — a self-consistent check over a self-contradicting spec passes under either convention.
> >
> > **The covenant's purpose is anti-drift, not the preservation of a defect.** So on John's ruling it is
> > **amended once, deliberately, in the open**: the anchor moves `0x3fa36874 → 0x5f76e76`, and every other
> > golden moves with it (gig `0xb1d48ea1 → 0xec7a2d39`, the three marched fixtures, all 23 real towns base
> > + gig — **51 pins in one commit**, per the amendment law). The classic town is now what it always claimed
> > to be: a street you can stand on and read the shopfronts. **From this hash forward the law reads as it
> > always did — byte-identical forever.** An amendment requires a John-level ruling and a demonstrated
> > world-level defect, not a convenience; this is the precedent, not an opening.
> >
> > *Exposed by the same fix (and fixed with it): `buildRealRoads` seated blocks at `KERB = 4 m` from the
> > centreline while a `main` carriageway is 10 m wide — its kerb is at 5 m. Every main-street building has
> > stood 1 m inside the road since R18. Invisible while nothing was drawn on that face; the moment the
> > shopfront turned around, its poster landed in the traffic lane. Blocks now seat behind the kerb per the
> > corridor law (`roadWidth/2 + FOOTPATH`). Pack drops 6.4% → 6.6%, counted.*
> - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover > - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover
> smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block) > smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block)
> now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays > now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays
@ -516,6 +554,79 @@ nothing outside the cache-schema path).
> count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig > count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig
> `0xb1d48ea1`, marched fixtures, classic. > `0xb1d48ea1`, marched fixtures, classic.
## The stock ladder (v5 — THE REAL SHOP; frozen at `v5.0`, ROUND27 ledger #6)
> **v4 made the towns real; v5 makes the shops real.** The map-level contract above is unchanged by this
> epoch: **stock is DATA, not world.** The charter's determinism boundary is the load-bearing line —
> *world = seeded, frozen, golden-gated; stock = data tiers riding the asset law.* Nothing in this section
> may move a plan golden, and nothing in it did: the whole epoch left synthetic `0x3fa36874`, gig
> `0xb1d48ea1`, the marched fixtures and classic untouched.
**The tier ladder.** Each rung is fully playable and degrades **softly** to the one below — the fail-soft
pattern proven since `?noassets` on day one, made structural:
| tier | what | flag | deterministic? |
|---|---|---|---|
| **0 · parody** | seeded canvas sleeves (v1) | any boot | yes — the QA baseline |
| **1 · static real** | per-shop atlases baked from dealgod/POS | `?stock=real`, **no server** | yes — atlases are files |
| **2 · live GODVERSE** | thriftgod read-only endpoints; sold-means-gone | server reachable | **no — by design** |
**The offline law (charter law #1) is the epoch's gate, not a footnote:** the server *enriches, never
gates*. No boot path, gate, or tag may depend on a reachable server. Tier 2 is therefore **never inside a
golden or a byte-identical assert**; it gets its own smoke class whose server lifecycle the gate itself
controls, so the *assertion* is deterministic even though the *stock* isn't.
**`sourcing` is not `tier`** (C's ruling, R26 — and it's the distinction that keeps the ladder honest). The
rung and the provenance are different axes, and both ship in every atlas index:
- **`tier: 1`** — the ladder rung. A minted atlas is *still* a static file, so it is *still* rung 1.
- **`sourcing: "real" | "mint"`** — where the contents came from. **`real`** = a POS snapshot (Monster Robot
Party's actual crates). **`mint`** = a seeded `random.Random(shop_id)` sample of real dealgod listings —
*plausible, not that shop's stock*. Only one shop in the world has a POS; minting is how the other keyed
shops get distinct crates **without pretending to data we don't hold**. Current pack: **15 keyed atlases =
1 real + 14 mint**. No gate, doc, or human may read a mint crate as real — that is the whole point of the
field, and gates assert on it.
**The id-space fences.** Three namespaces meet at the crate, and **every collision this epoch came from
comparing across them**. They are fences, not decoration:
| space | form | scope | fenced from |
|---|---|---|---|
| **plan `shop.id`** | small int, `0..N` per town | one CityPlan, local | `godverseShopId`**numerically overlapping** |
| **`godverseShopId`** | census id (**≤2992**) dealgod store id (e.g. **3962749**) | the stock namespace | its two halves are numerically **disjoint by construction** (G) |
| **atlas slot id** | **`sku_<POS id>`** / **`mint_<listing id>`** | one atlas index | each other, by **prefix** |
- **plan id vs `godverseShopId` — never compare a bare number across them.** They are *semantically*
disjoint namespaces that *numerically overlap*, which is the trap: in `redhill_godverse` today, bare
**`31`** is **"Wholefood Cafe"** as a plan id and **"Silky Oak Furnature"** as a godverse id. That exact
ambiguity made F's `enterShop(31)` enter the wrong shop and a soft-fall assertion pass **vacuously on the
wrong subject**. Verified still live in the data — so this is a standing rule, not a fixed bug.
- **`sku_` vs `mint_` is a sourcing-scoped namespace fence** (C §7.2a as amended, R26 wave 4). A crate
wearing the other sourcing's prefix **fails**. The fence is *stronger* than the rule it replaced: the easy
green was deleting the prefix check; instead the prefix now proves its sourcing.
- **Positional ids are banned** (R25): `rec_0000…` numbering collides across packs by construction, which
is what made the integrator's own id-equality gate vacuous. Slot ids are sku-derived and unique across
packs, stable across re-emits.
**The manifest — `web/assets/stock_godverse/index.json`** (G emits, F consumes, E validates, C's §7.2d).
It declares **which `godverseShopId`s carry atlases** (`types[]` + `sourcing` per entry; mint included).
**Existence is declared, never probed.** This is the root fix for a real collision between two laws:
fail-soft-by-404 vs the zero-console-errors law — the runtime used to probe blind and eat 404 noise,
tolerated by attribution, which is a workaround, not a design. No manifest entry ⇒ no fetch ⇒ **zero 404s,
no exceptions**. Consistency is gated both ways (an atlas on disk absent from the manifest **fails**, and
vice versa — vacuous-gate law shape). Today: **15 entries, 15 dirs on disk.**
**Lane A's seam to all of it** is exactly one field — **`godverseShopId`** (documented above): it rides the
town cache, survives the lift onto `plan.shops[]`, and is what the runtime resolves `stock_godverse/<id>/`
from. **`'godverseShopId' in shop` is the tier test**: present ⇒ tier-1+ candidate, absent ⇒ tier 0. The
key is *absent, not undefined*, on every unkeyed shop — which is why the entire stock epoch moved **no plan
golden**. `selfcheck` gates the seam per-layer (Ruling 2), including the **orphaned-atlas** check: a
committed atlas whose shop the lift dropped is real stock keyed to a shop that isn't in the town.
**Ruling 2 (law):** a godverse cache is **deliberately mixed** — a keyed GODVERSE layer + an inherited OSM
texture layer that is legitimately unkeyed. G measured it load-bearing: census-only Red Hill was
unshippable (306 m spacing warn, a poster inside a kerb). **No gate may assert it away** — mine did, and
called two healthy towns broken.
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for > **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for

View File

@ -93,28 +93,64 @@ boot path, gate, or tag that may touch the network.
--- ---
## 4. API surface [DESIGN] ## 4. API surface [AS-BUILT — R27, `pipeline/godverse_server.py`]
Read-only in v5.0; the write verbs are drafted here but **gated on §7**. Read-only, per §7's ruling. **The write verbs are not "gated" — they do not exist.**
``` ```
GET /godverse/v1/shop/<godverse_id>/stock?crate=<id>&since=<iso> GET /godverse/v1/health -> { ok, tier, pos_db, shops:[godverse_id] }
-> { shop_id, crate, tier, items:[{sku, slot, release_id, artist, title, GET /godverse/v1/shop/<godverse_id>/stock?since=<etag>
price, condition, sleeve_cond, cover, state}], -> { shop_id, tier, crate, items:[{id, slot, artist, title,
gone:[sku], etag, served_at } price, condition, sleeve_cond, state}],
GET /godverse/v1/shop/<godverse_id>/crates -> crate list + counts (the rotation menu) gone:[id], counts, etag, served_at } · 304 when ?since= matches
POST /godverse/v1/reserve { sku, session } -> { ok, holds_until } [GATED — §7] GET /godverse/v1/shop/<godverse_id>/crates -> { shop_id, tier, crates:[{id,name,label,count}], etag }
POST /godverse/v1/buy { sku, session, hold } -> { ok, receipt } [GATED — §7] GET anything else -> 404 (the ruling is in the body)
POST/PUT/PATCH/DELETE anything -> 501 — no write handler exists in the process
``` ```
**Three corrections this doc owes to the code (measured R27, not reasoned):**
1. **`gone:[sku]``gone:[id]`.** As ratified, §4 said bare skus. That wording predates R25's
`sku_<POS sku>` ids and R26's **id-namespace fence law**, and a bare number crossing two id
spaces is precisely the bug that law exists to stop. `gone[]` now carries the atlas's own `id`
verbatim, so the client does `gone.includes(item.id)` with zero transformation. *This doc was
the stale rule meeting a case written after it — the epoch's signature failure, and my turn.*
2. **`items[]` drops `release_id` and `cover`.** `cover` would edge the API toward serving
pixels — it never should (fence #2); `release_id` identifies the *pressing*, and tier 2 speaks
only in *copies* (the sku). Both were design-time noise.
3. **Write verbs answer 501, not 404.** F asserts the number, so it matters: the class defines
`do_GET` and nothing else, so the stdlib refuses any other method before routing exists. **501
is the stronger proof** — a 404 means a handler ran and declined; 501 means there is no write
handler in the process. Adding `do_POST` to answer 404 would implement the forbidden thing.
Design rules: Design rules:
- **The atlas is never served by the API.** Covers stay static files (tier 1). The server sends - **The atlas is never served by the API.** Covers stay static files (tier 1). The server sends
*facts* (what's still there, what it costs), never pixels. A tier-2 shop with a dead server is *facts* (what's still there, what it costs), never pixels. A tier-2 shop with a dead server is
visually identical to tier 1 — that's the fail-soft. visually identical to tier 1 — that's the fail-soft.
- **`gone[]` is the whole point of tier 2.** The client already holds the tier-1 crate; the server - **`gone[]` is the whole point of tier 2.** The client already holds the tier-1 crate; the server
says only what changed. A sold record is a `sku` in `gone[]``collapseBuyItem()` (already says only what changed. A sold record is an `id` in `gone[]``collapseBuyItem()` (already
built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch. built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch.
> ### ⚠ `gone[]` HAS NO SUBJECT IN PRODUCTION TODAY — measured R27, and it shapes F's gate
> The mechanism is built and correct. The **data has no movement**: Monster Robot Party's POS
> records **5,567 sales ever** (400800/month, Dec 2025 → Jun 2026) but **26 in July and none
> since 2026-07-01**; the POS hasn't been *written* since **2026-07-09** (the 07-16 dump is fresh;
> its data is stale). And **0 of crate 550's 120 records have ever sold**. So against the real POS
> `gone` is `[]`, permanently, until the shop trades again.
>
> **Therefore, per the vacuous-gate law: F's sold-means-gone arm must not assert over the live POS
> — it would pass without touching its subject.** Two honest routes, and G built the second:
> 1. Assert `gone == []` against the real POS and **print the reason** — true, but it proves only
> that nothing sold.
> 2. **Point the server at a labelled fixture** (`godverse_server.py make-fixture --sold 3` →
> `recordgod_fixture`, a `createdb -T` clone with N of the crate's own real skus marked sold).
> Verified: `counts {baked:120, live:117, gone:3}`, the three ids absent from `items[]`. This
> proves the **mechanism** — computed → sent → consumed → sleeve vanishes — deterministically,
> which is what a gate is for. Production still reads the real POS, untouched.
>
> **A fixture is not a lie; a green assert over an absent subject is.** The epoch has held four
> tags on exactly that distinction.
- **`etag`/`since`** so a re-enter is a 304, not a payload. - **`etag`/`since`** so a re-enter is a 304, not a payload.
- Shop id is the **godverse id** (§1a), never a POS internal id. **R24 naming, as landed:** the - Shop id is the **godverse id** (§1a), never a POS internal id. **R24 naming, as landed:** the
town cache carries `shop.godverseShopId` (A's schema field, camelCase, read by the JS runtime); town cache carries `shop.godverseShopId` (A's schema field, camelCase, read by the JS runtime);

View File

@ -2,6 +2,130 @@
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
### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them**
This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't
touch your files. Exact values (verified byte-identical across two fresh processes):
| your file | constant | old | **new** |
|---|---|---|---|
| `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** |
| `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** |
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** |
| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** |
| `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three |
| `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** |
Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching.
### The finding: the ruling's premise was wrong — **synthetic was broken too**
The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product
since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it
is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not
"every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before
touching anything, then confirmed with my own eyes in the browser:
| path | before | after |
|---|---|---|
| synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 |
| marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward |
| real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly |
**The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank
grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning,
facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a
strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at
all.**
**Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was
`atan2(outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back
down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed
the result. Nobody was wrong; the spec was.
### The covenant moved — John ruled it
Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide.
**John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect
preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig
`0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a
one-time, ruling-gated precedent — from this hash forward the law reads as it always did.
### A second bug the fix exposed (fixed here, counted)
`buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m
wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The
footprint never moved; only which face is the front — so it was invisible until the shopfront turned around
and its poster landed in the traffic lane (1.07 m). Blocks now seat behind the kerb per A's own corridor
law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden.
### The cross-convention gate — built to F's lesson, not to a spec line
F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over
a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement
maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line
can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires
if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1.
## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6)
### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag**
selfcheck **ALL GREEN 156,212/156,212**, classic `0x3fa36874` / gig `0xb1d48ea1` frozen, **no golden moved**
(none should — tier 2 touches no plan, no cache, no atlas bytes). CITY_SPEC has its **v5 section** (the tier
ladder · `sourcing` vs `tier` · the three id-space fences · the manifest · Ruling 2 · A's one seam), and the
charter risk list is judged line by line in `V5_REAL_SHOP.md`.
### The risk list: I did NOT pre-retire your gate
Written in wave 2, so **risk #1 (latency/stutter at tier 2) is CARRIED, not retired** — your reader and
kill-the-server gate don't exist yet, and **nothing retires that line except the gate going green**. Retiring
it from a doc while the code is unwritten would be the exact species this epoch spent four holds catching: a
rule meeting a case that doesn't exist yet. #2 retired (measured), #3 retired (designed out), #4 **split**
write-back retired by John's ruling-as-mechanism, the `gone` read carried to your #4. Two new carried lines
(#6 orphaned atlas, #7 "real" is one shop wide).
### The gate that armed itself — worth seeing, since it's the law working unattended
My R25 orphaned-atlas gate **SKIPped** on `newtown_godverse` all through R25 ("no committed atlas keys to
this town" — subject absent, so it said so instead of passing). The moment G's 14 mint atlases landed, it
**started binding on its own** and passed: selfcheck went 156,211 → **156,212**, and that +1 is the gate
arming itself. Nobody edited it. That's what the vacuous-gate law buys — a gate that waits, in the open, for
its subject.
**Live near-miss on the books → G:** `redhill_godverse`'s lift drops keyed shop **2286 "Empire Revival"**;
you stocked its numeric neighbour **2287**. Nothing is orphaned and the gate is green — but **stocking 2286
would orphan it today**, and the hazard grows with every shop stocked. Carried as risk #6.
### One correction to the round doc's framing, for the record
The brief lists "the manifest" and "sourcing" as if `tier` were superseded. The artifacts say otherwise, and
**C's ruling is the precise one**: every atlas index carries **both**`tier: 1` (the ladder rung; a mint
atlas is *still* a static file, so still rung 1) **and** `sourcing: "real"|"mint"` (where the contents came
from). They are different axes. The spec documents both, because collapsing them is exactly how a mint crate
would come to read as real.
## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1) ## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1)
### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake) ### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake)

View File

@ -1,5 +1,261 @@
# 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**
**Built it, proved it worked, measured what it was worth, and killed it. A 10×-over-driven billow —
1.4 m of flap on a 0.14 m canopy — cannot move 1% of the frame. It is not the gain; it is the
geometry. The backlog item is closed permanently, and the reason names what *would* work.**
R17 shipped tree sway and parked this with a physical reason: a posted verandah is a structural roof
and does not flutter. R28 asked for the half R17 left — the stalls' striped canopies really are cloth,
and they shared the rigid awning InstancedMesh, so neither could move without the other. That part was
all true, and the split worked. The cloth just turned out to be invisible.
### It was built, and it worked
Split the canopies out of the rigid mesh (own `InstancedMesh`, own subdivided box — a 4-corner box has
nothing to ripple), gave only them a cloth billow riding R17's existing `wind.js` uniforms: a
`sin(πu)·sin(πv)` pin (zero at the four corner posts by construction, peak mid-panel), two crossed
travelling waves, per-instance phase off world position so forty stalls don't breathe in unison.
Proven by **rendered-pixel hashes**, not by eye:
| proof | result |
|---|---|
| the cloth actually displaces (calm vs gusty) | `40f59eaf``d6d3e1f1`**moves** |
| returning to calm reproduces the calm frame | `40f59eaf`**identical** |
| `uWindTime = 99` at amp 0 | `40f59eaf`**identical** (amp 0 ⇒ `+= …*0.0`, the R17 pattern) |
| **`?classic=1`, same fixed pose, pre- vs post-change build** | **`453c0154``453c0154` — byte-identical** |
| rigid verandahs / rest of town ride the wind? | cloth+trees hidden: `76c8b518``76c8b518`**still** |
| …and the control proves I hid the right thing | cloth restored: frame **moves** again |
Cost at `market_square`, true A/B: **+0 draws** (98 → 98) / **+2,160 tris**. +0, not the +1 I'd written
in my own comment — the square's chunks hold *only* stalls, so the rigid list empties, `makeInstanced`
returns null, and the cloth mesh replaces it rather than adding to it.
### And it is worth nothing — the sweep (amp is the same multiplier as gain)
| peak billow | frame changed |
|---|---|
| **0.02 m — CALM, the default weather amp** | **0.05%** |
| **0.14 m — STORM, the most any player ever sees** | **0.21%** |
| 0.35 m (2.5× over-driven) | 0.33% |
| 0.70 m (5×, absurd on a 0.14 m slab) | 0.49% |
| **1.40 m (10×, a canopy flapping a body-length)** | **0.77%** |
At the player's actual browsing pose, storm-force: **0.00%, max channel delta 1**. Not one pixel.
**The reason is physical, and it is the same physics that killed the verandah in R17.** A canopy is a
horizontal sheet at y 2.2 viewed by a 1.7 m player: you see it edge-on or from underneath, so a
*vertical* displacement runs almost parallel to the view ray and produces almost no pixel change. You
cannot see a flat horizontal sheet flex vertically while standing under it. No gain fixes that — 10×
buys 0.77%, and anything that large stops reading as cloth and starts reading as a trampoline.
**What would work, if wind in the market is ever wanted (for whoever reads this in round 35):** the
motion has to be *perpendicular* to the view, which means **vertical cloth** — hanging stall skirts, a
flag, a banner, an awning valance. That is an **asset** (Lane E), not a shader. B's `applyWind` /
`applyFabricWind` seam is ready for it the day such a mesh exists; the shader was never the missing
piece. **Ledger #2 leaves the backlog forever.**
### Kept from the round
Nothing shipped: `buildings.js` and `wind.js` are byte-identical to HEAD. Shipping an invisible ripple
would have cost 2,160 tris and a second draw path in the covenant town to move 0.05% of a calm frame.
*The R17 rule was "ships or dies". It died — and it died with numbers, which is the only way an item
this old stops coming back.*
**Two vacuous measurements caught on the way (the epoch's own disease, still catching me):** (1) a
`!!material.onBeforeCompile` check to prove the rigid verandah was untouched — every `THREE.Material`
inherits a prototype no-op, so it is **always** truthy; the assert could never go red, and I replaced it
with `hasOwnProperty` plus a behavioural hide-and-hash control. (2) a gain sweep that reported the
identical 0.21% at gains 0.4→4.0 because I never stored the shader ref, so `uFabricGain` never moved —
**four identical numbers across a 10× range is a sweep that proves nothing**, and it was the constancy,
not the value, that gave it away. Both would have passed as green.
---
## Round 27 wave 4 — `poseForShopFront` shipped · **and every real town's shops face away from the street**
**The micro-task is done and verified. In doing it I found why F photographed empty road: it is not
the pose. Every shopfront in every OSM town has faced away from its street since R18, and the tours
have been photographing the backs of buildings for nine rounds. Root cause is a CITY_SPEC that
contradicts itself. The fix is one line in A's `plan_osm.js` — proven, then reverted, because it
isn't mine and it needs a ruling.**
### 1. The deliverable — `DBG.poseForShopFront(sel, dist = 8)`
Poses at a NAMED SHOP's frontage: door centred, sign band + R26 crate mark in shot, under the verandah.
`clusterPose()` answers "where is the town's HEART", which is the wrong question for a named shop —
Red Hill's heart doesn't contain Monster Robot, which is exactly how F got three frames of empty road.
Keyed through **one shop selector**: F's R22/R24 resolver lived inside `enterShop()`, so I extracted it
rather than grow a second key scheme beside it — a selector bug resolves the wrong shop *and looks like
it worked* (F's own first cut proved that), so the id-namespace law now has exactly one home.
Verified live, and this is the case that matters:
| call | resolves to | |
|---|---|---|
| `poseForShopFront(31)` | **Wholefood Cafe** (plan id 31) | + `ambiguous:` "31 is also the godverseShopId of Silky Oak Furnature — use 'g:31'" |
| `poseForShopFront('g:31')` | **Silky Oak Furnature** (godverse id 31) | the prefix IS the fence |
| `poseForShopFront('g:999999')` | `{error}` | never throws, never guesses |
Framing proven by **projection**, not by eye — `doorCentreNdcX = 0.0000` (exactly), door / sign / mark
all inside the frustum. Derived from buildings.js rather than hardcoded: door is centred on the lot
front (so `off = 0`, unlike `poseForShop`'s `off = 1.0`); sign band is `min(h,FACADE_H)0.35 ± 0.25`
**y 2.402.90**; the mark is **y 2.452.52**; `camY = 2.0` sits below the `AWNING_Y = 2.95` verandah, so
a ray rising to a target ≤ 2.9 can never cross it — the sign is clear *by geometry*, at any distance.
### 2. Correcting my own R27 intel to F — I gave F two wrong numbers
My R27 note said the mark "rides high (y ≈ 3.3)" and the vinyl landmark blocks only 4% of it. **Both
wrong.** The mark is at y 2.452.52; I had ray-scanned y 3.03.6, which is empty awning air *above the
sign* — that is why it read ~0. Re-measured on the true band: the `novelty_record` disc (top y **2.50**)
reaches **into** the mark band and clips **~15% of the strip from every offset** — moving the camera only
changes *which* 15%, so there is nothing to dodge. The recommendation (**shoot head-on**) survives, but
for the opposite reason to the one I gave: not because the vinyl is clear of the mark, but because it
never isn't, and the repeating spines read through it anyway.
### 3. THE FINDING — real-town shopfronts face the paddock, not the street
Standing on Musgrave Road (Monster Robot's own `frontEdge: 1316`) and looking at the shop returns a
**blank grey back wall**: no sign, no door, no windows, no mark. Measured, not eyeballed:
| town | shop lots w/ frontEdge | facing their street | **180° backwards** |
|---|---|---|---|
| `redhill_godverse` | 35 | **0** | **35** (dot = 1.000) |
| `katoomba_real` | 72 | **0** | **72** (dot = 1.000) |
| synthetic (default) | — | — | none — looks right, always has |
dot = **1.000 exactly**, every lot, both towns: a convention, not a defect. Katoomba's `street_noon`
the bookmark the release tours have shot for nine rounds — is **46 draws / 19,680 tris of bare road**.
**Root cause: CITY_SPEC contradicts itself, and the two generators each implement one half.**
- **CITY_SPEC:71** (the lots contract): *"ry: … a GLB modelled facing Z ends up with its facade's OUTWARD
normal pointing at frontEdge. **World facing = (sin ry, cos ry)**"* → **Z is the facade.**
- **CITY_SPEC:335** (posters, R15): *"seated on the **+Z street facade** (**B's canon**: buildings.js facade +
venue.js door/queue/camera are all +Z; `lot + (sin ry, cos ry)·(d/2+ε)`)"* → **+Z is the facade.**
`plan_osm.js:443` obeys the first (`ry = atan2(nx, nz)`, its comment says *"facade (Z) faces road"*).
`plan.js` (synthetic), `buildings.js`, `venue.js` and the R15 posters obey the second. Synthetic — the
town every golden and every one of us tests — follows B's canon and looks correct, which is precisely
why nine rounds of real-town work never caught it. **This is the epoch's own signature species: a rule
meeting a case written after it.** The +Z canon is the de-facto standard (v1→v5, every synthetic golden,
posters R15 ledger #1); `plan_osm.js` is the lone dissenter.
**Proven, then reverted.** One line in A's file — `ry = Math.atan2(-nx, -nz)` — flips katoomba from
**0/72 facing → 72/72 facing**, and turns B's `poseForShopFront` camera from **26 m out in a paddock into
4 m from Musgrave Road**: the money shot F specified, shopfront + mark + kerb + a citizen at the door.
I did not commit it. It is A's file, it moves every real-town plan (⇒ real-town goldens re-pin under
amendment law), and a spec that contradicts itself is Fable's to rule on, not mine to pick a side of.
**This also re-reads my own R22 diagnosis.** I recorded "chunk-buildings **348 tris** (0.2%)" in katoomba's
busiest venue block and credited the atlas/instancing work. 348 tris is ~29 boxes: that was not efficiency,
it was the buildings being **absent from the frame**, and I read the evidence as a win. The number was
right; my reading of it was wrong, for five rounds.
Files: `web/js/world/dbg.js` (the helper + the extracted selector). No other lane's file touched.
---
## Round 27 (on-call) — the R26 handshake **verified against G's real manifest**; intel for F's #5 money shot
R27 owes B nothing (*"B/D: notes current; nothing else owed"*, *"B/D on-call"*). This is on-call work: F's
tour (#5) is specified as *Monster Robot Party's frontage, **B's mark visible*** — a claim my R26 verification
could not actually test, because G's manifest didn't exist yet and I proved the treatment against a **temp
manifest I scaffolded and deleted**. G has since shipped the real one (`36d10ca`). Claim now measured, not
assumed.
**Monster Robot Party is in `redhill_godverse`, not `newtown_godverse`** — the only town B ever tested the
mark in. F's money shot is posed in a town this treatment had never been verified against. It works.
### The R26 handshake held, unmodified
G's real emission is `{version:1, shops:[{godverseShopId, types, sourcing}]}` — 15 entries, lands inside B's
tolerant reader (`.shops`, objects carrying `godverseShopId`) with **zero coordination**, exactly as the R26
note promised. **The `tier` → `sourcing` rename (Fable's waves-1-2 ruling) did not touch B**: the mark
deliberately never reads that field, so a rename of it cannot break the footpath. *The "don't leak tier"
call paid a dividend it wasn't designed for — a cue that reads no field cannot break when the field moves.*
### Both arms proven live — the discriminating arm for the first time
R26's temp manifest marked **all 18** keyed shops, so "manifest is the authority, not the key" was never
actually exercised. G's real manifest makes it load-bearing: redhill has **9 keyed, only 4 stocked**.
Measured at Monster Robot's frontage (live chunk set), ground truth vs render:
| shop | keyed | in manifest | rendered |
|---|---|---|---|
| **Monster Robot Party #3962749** (`real`) | ✓ | ✓ | **marked** ✓ |
| Silky Oak Furnature #31 | ✓ | ✗ | **bare** ✓ |
| Thrift Shop and Eternity Boutique #908 | ✓ | ✗ | **bare** ✓ |
Two keyed shops stand metres from a stocked one wearing no mark, because they have no crate. **The street
advertises exactly the crates that exist.** (#31 is the same shop as Fable's R25 id-namespace bug — its
godverse id collides with a plan id; the mark reads `godverseShopId` only, so it is unaffected.)
Frontage: **135 draws / 74,354 tris**, 0 page errors, manifest prefetched at boot.
### Intel for F's #5 (no action needed from B)
The **`novelty_record` landmark** (B's furniture — a giant 0x1a1a1a vinyl disc on a post, marking record
shops) stands 2.5 m off Monster Robot's frontage and is **prominent in the head-on frame**. Measured
occlusion by ray-grid, so F can frame without guessing:
| band | head-on (8 m) | off-axis (+4 m) |
|---|---|---|
| **mark + sign** | **4%** blocked | 0% |
| window glass | 22% blocked | **44%** blocked |
**Shoot head-on.** The vinyl sits low (y≈1.3) and the mark rides high (y≈3.3), so it never meaningfully
occludes the mark; stepping off-axis *doubles* the window occlusion instead of helping. A giant vinyl record
in the frame of a record-shop money shot is a feature, not a blemish. *(I first read the black disc as a
render bug — it isn't; it's the landmark doing its job. Recorded so the next reader doesn't re-open it.)*
No code changed this round; notes-only.
---
## Round 26 (Fable's ROUND26 → Lane B) — #5 the polish pick: **the stocked shopfront reads from the footpath** ## Round 26 (Fable's ROUND26 → Lane B) — #5 the polish pick: **the stocked shopfront reads from the footpath**
**Shipped. A stocked godverse shop now tells you it's diggable before you reach the door — a crate of **Shipped. A stocked godverse shop now tells you it's diggable before you reach the door — a crate of

View File

@ -1,10 +1,11 @@
# LANE C — interior audio contract (v3.0-FROZEN) → for Lane F + Lane B # LANE C — interior audio contract (v3.1) → for Lane F + Lane B
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane F + Lane B. Describes the shipped state; changes > **v3.1** · 2026-07-17 (R28) · durable contract for Lane F + Lane B. Amends v3.0-FROZEN: adds
> require a version bump. > `room.audio.emitters` (§Emitters) and retires the v3.0 deferred `userData.audioEmitter` note — that
> note proposed a shape its own consumer could not use. **The line was the stale thing.**
*`buildInterior` returns `room.audio = { musicKey, toneKey }`, seeded per shop. Lane F's interior_mode just *`buildInterior` returns `room.audio = { musicKey, toneKey, gigKey?, emitters }`, seeded per shop. Lane F's
plays it; Lane B's audio.js resolves the keys → files.* interior_mode just plays it; Lane B's audio.js resolves the keys → files.*
> **Scope:** this doc covers the **base interior audio** (the seeded music bed + room-tone every shop > **Scope:** this doc covers the **base interior audio** (the seeded music bed + room-tone every shop
> returns). The **gig audio**`room.audio.gigKey`, the live-band bed, canonical form `gig-<genreKey>` > returns). The **gig audio**`room.audio.gigKey`, the live-band bed, canonical form `gig-<genreKey>`
@ -17,6 +18,7 @@ plays it; Lane B's audio.js resolves the keys → files.*
room.audio = { room.audio = {
musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music) musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music)
toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior') toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior')
emitters, // object — WHERE keys sound from (R28, §Emitters). Never null; may be {}.
} }
``` ```
- **Keys, not files.** `buildInterior` stays synchronous and asset-free — it only *names* what should - **Keys, not files.** `buildInterior` stays synchronous and asset-free — it only *names* what should
@ -39,6 +41,58 @@ room.audio = {
Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest
`types` membership; dedicated-music types are stable, non-music types vary on/off by seed. `types` membership; dedicated-music types are stable, non-music types vary on/off by seed.
## Emitters — WHERE a key sounds from (R28)
```js
room.audio.emitters = {
stage?: { x, y, z, r, floor }, // VENUE ONLY — the band + the crowd sound from here
counter?: { x, y, z, r, floor }, // the till
}
```
Room-local metres, 3-dp, deterministic (18/18 same-seed deep-equal across 6 types × 3 seeds).
**Absent emitter ⇒ room-filling**, which stays the right default for a shop radio (the v3.0 call, kept).
- **`r` is the REACH** — the distance at which the emitter has decayed to `floor`. It is the **room
diagonal**, not a constant. Measured over 8 types × 4 seeds: diagonals run **9.8115.63 m**, and scaling
`r` to the room holds the door→stage proximity in a consistent **0.420.59** band from a pokey
band_room to a hall pub. A fixed radius would make the same walk mean different things in different rooms.
- **`floor` is the room-filling RESIDUE, never 0.** A pub PA fills the room; what changes as you walk in is
the **balance**, not the presence. `stage` 0.35, `counter` 0.15 (a till is a point source, a PA is not).
- **The curve** (both consumers use exactly this):
```js
const d = Math.hypot(e.x - cam.x, e.z - cam.z); // emitters and the interior camera are
const prox = Math.max(0, Math.min(1, (e.r - d) / e.r)); // BOTH room-local — no transform
const gain = e.floor + (1 - e.floor) * prox; // never 0
```
Measured swing door→stage: **2.654.52 dB** across pub / band_room / rsl. Audible, not a new game — the
wide rooms swing least because the stage genuinely *is* nearer the door there. (John, R20: *"subtle
changes are great … the player should notice the town feels more alive, not that the game changed."*)
### DO NOT use a PannerNode. It is inert here. (measured)
Nothing in the tree sets `ctx.listener` — zero hits, whole tree. A `PannerNode` therefore resolves against
a listener frozen at **(0,0,0)**, and because rooms are built in **room-local coords at the origin**, that
is *the room's own centre, forever*. Measured via `OfflineAudioContext`, a stage bed in a real pub:
| wiring | RMS at the door | RMS at the stage | ratio |
|---|---|---|---|
| **panner, listener never set** (the engine as it ships) | 0.106905 | 0.106905 | **1.0000** |
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
The naive build **plays, throws nothing, warns nothing, and never changes by one sample as you cross the
room** — while doing the one thing the feature exists to prevent. Every plausible gate goes green on it:
*"is it routed through a panner?"* yes. *"is it audible in a venue?"* yes. *"in a shop?"* yes.
**-> The only assertion that can see this is a DELTA: the bed's gain at the door ≠ its gain at the stage.**
Assert the change, not the presence. (Vacuous-gate law, 4th application.)
This trap was set by **this document's own v3.0 note** — *"`userData.audioEmitter = musicKey` so Lane B can
position/spatialise the bed"* — which invites exactly the panner, in a codebase with no listener code to
teach otherwise. Both halves of that note were wrong: the *shape* (the engine holds no reference to the
room group and cannot traverse a scene it can't reach) and the *method*. It is retired.
**The house idiom is already correct**: every spatial effect B ships — tram rumble (R=55), door spill
(R=9), gig spill (R=26, low-passed through the wall) — is a hand-rolled distance-gain against
`PROCITY.camera.position`. No listener, no panner. Emitters are the same shape as what already works.
## Lane F wiring (interior_mode) ## Lane F wiring (interior_mode)
``` ```
on enter(room): on enter(room):
@ -50,9 +104,92 @@ on exit / dispose(room):
Nothing before the first gesture; `?mute=1`/`?noassets=1` short-circuit before any fetch. `room.audio` Nothing before the first gesture; `?mute=1`/`?noassets=1` short-circuit before any fetch. `room.audio`
is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side. is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side.
## Deferred idea (v3.1+, not in the frozen contract) ---
A `places`-tagged visual source ("the music comes from *there*") — e.g. a radio/hifi mesh with
`userData.audioEmitter = musicKey` so Lane B can position/spatialise the bed. Only the record shop has a # R28 — what C shipped, and the two asks
fitting for it today (`listeningCorner`); milk-bar/video would need a new radio prop. A tidy v3.1 item: the
`userData.audioEmitter` hook + the two props. v3.0 plays the bed non-spatially (room-filling), which is the **C shipped (this round, C's files only):**
right default for an interior anyway. 1. `room.audio.emitters` — the data above. Rides the **existing** `playInterior(room.audio)` call: no new
lifecycle, no traverse, no dispose hook.
2. **The till rings from the counter** (`dig.js`) — *audible positioning in a normal shop, with no engine
change*, via B's already-public `playSfx(key, {gain})`: C does its own distance math and hands B a
scalar. Measured through the real BUY button in a real record shop — **0.9489 at the counter (0.60 m)
vs 0.6436 at the door (4.19 m), 3.37 dB**; negative control (`emitters` omitted) fires at exactly
**1.0**. `sfx-till.ogg` has been on disk since 15 Jul and had **never once played**. Routing, not content.
3. **`?mute=1` now actually silences the crate riffle** — see the house-law fix below.
**Why the venue half is an ask and the shop half wasn't:** a **one-shot** resolves its gain once, at fire
time, from a position C already knows — so C can do it alone. A **bed** must be re-gained every frame from
inside the engine's `update()` loop. That loop is B's file. The split is structural, not political.
## ASK → Lane B (audio.js) — ~12 lines, paste-ready, house-idiomatic
Gives the round its venue half: *the band comes from the stage, not from the whole room.*
```js
// near the other interior state:
let iEmit = null, iMusicG = 0.5, iToneG = 0.35;
async function playInterior(spec) {
// … existing body …
iEmit = spec.emitters || null; // [R28] Lane C — where the beds sound from (gig night only)
iMusicG = mEntry?.gain ?? 0.5;
iToneG = tEntry?.gain ?? 0.35;
swapBed(L.iMusic, spec.musicKey || null, mEntry || null, iMusicG, 1.0);
swapBed(L.iTone, spec.toneKey || null, tEntry || null, iToneG, 1.0);
}
function stopInterior() { iEmit = null; ramp(L.iMusic.g.gain, 0, 0.7); ramp(L.iTone.g.gain, 0, 0.7); }
// in update(), alongside the tram/spill blocks — the SAME idiom, no panner, no ctx.listener:
if (!street && iEmit && iEmit.stage) {
const e = iEmit.stage, d = Math.hypot(e.x - _cam.x, e.z - _cam.z);
const k = e.floor + (1 - e.floor) * Math.max(0, Math.min(1, (e.r - d) / e.r));
ramp(L.iMusic.g.gain, (L.iMusic.src ? iMusicG : 0) * k, 0.25);
ramp(L.iTone.g.gain, (L.iTone.src ? iToneG : 0) * k, 0.25);
}
```
Emitters are room-local; the interior camera is room-local. Same space, no transform. Your call entirely —
C has not touched audio.js.
**Second, smaller ask (not this round):** expose the sfx bus or the ctx so `dig.js` can retire its private
AudioContext (below). C will not open that seam unilaterally.
## ASK → Lane F (interior_mode.js) — 1 line, and it is load-bearing
`interior_mode.js:289` rebuilds the audio spec on a gig night and **drops every field it doesn't name**:
```js
const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
```
Measured against a real gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive:
false`**. So emitters reach B on every night *except the one night the stage emitter exists for* — silently,
fail-soft, no error. Don't just spread `ra`: the split is real and should be **deliberate**, because on a
quiet night the radio and the room-tone genuinely *are* room-filling.
```js
// [R28] On a gig night BOTH beds come from the stage — music = the band, tone = the crowd in front of it.
// On a quiet night the radio + room-tone fill the room, so no emitter is passed. Deliberate, not a spread.
const gigAudio = gigOn && ra.gigKey
? { musicKey: ra.gigKey, toneKey: WALLA_KEY, emitters: ra.emitters }
: { musicKey: ra.musicKey, toneKey: ra.toneKey };
```
**Also, when you next touch `openDig`:** pass `emitters: current.audio.emitters` to `dig.open()` and the
till becomes positional. Optional — omitted, it still rings, just un-positioned. Never required.
## House-law fix — `?mute=1` did not silence the crate riffle (R5 → R28)
`dig.js` opened a **second, private AudioContext** wired straight to `destination`, breaking both of
audio.js's stated laws (*ONE AudioContext*; *`?mute=1` forces silence*). The riffle sang right through
`?mute=1` and never saw B's master gain. **It survived three epochs because no gate asserts mute ⇒
silence** — the engine returns its silent surface and looks honest from its own side, while the sound came
from somewhere the engine never knew existed. *A gate that asks the engine whether it is muted can only
ever answer yes.*
Fixed in `dig.js` by reading B's **public** `.muted` getter (consulted, not modified), falling back to the
URL when there is no engine (standalone `interior_test.html`). Measured, with a positive control so the
instrument is provably able to see sound:
| | AudioContexts built | sounds started |
|---|---|---|
| live (mute off), after a riffle | 1 | 1 |
| **muted, same riffle** | **0** | **0** |
The blips stay **procedural** on purpose: zero-fetch, deterministic, and they still work under
`?noassets=1` where a sampled riffle cannot. Only mute was broken; only mute was fixed.
**-> F: assert SILENCE, not muted-state.** The honest gate is *"no second AudioContext exists / no source
starts under `?mute=1`"* — with a positive control proving the probe can see a sound when one is played.

View File

@ -1,4 +1,11 @@
# LANE C — interiors contract (venues + real-town class mapping) → Lanes A/D/E/F # LANE C — interiors contract (venues · real-town mapping · real stock) → Lanes A/D/E/F/G
> **v5.0-FROZEN · 2026-07-17 · the whole document.** Every section below is the shipped, measured state of
> Lane C's contracts across five epochs. Changes from here are version-bumped amendments, recorded in the
> marker list — the same discipline §0§5 have carried since v3.0. **Case law worth keeping:** three of this
> doc's amendments exist because a rule of mine met a case written *after* it (§7's naming vs the loader,
> §7.2a's `sku_` before mint existed) — the lane building to the rule took the red both times. When a line
> here meets a case it didn't foresee, **the line is the stale thing.** Amend it; don't bend the case.
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0§5 describe the *shipped* > **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0§5 describe the *shipped*
> venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit). > venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit).
@ -22,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
@ -413,3 +423,127 @@ Fresh context, against G's merged `stock_godverse/3962749/` (120 real records):
- **Collision fixed:** shop pack (120 items) and town pack (350) coexist as distinct objects. - **Collision fixed:** shop pack (120 items) and town pack (350) coexist as distinct objects.
- **Fail-soft with a POPULATED cache:** missing base ⇒ `null` ⇒ parody (the R23 bug is gone). - **Fail-soft with a POPULATED cache:** missing base ⇒ `null` ⇒ parody (the R23 bug is gone).
- **Ceilings:** 98 draws with real covers via `opts.stockBase`; `pathOK`; 0 carves; 0 console errors. - **Ceilings:** 98 draws with real covers via `opts.stockBase`; `pathOK`; 0 carves; 0 console errors.
---
## 8. Tier-2 live enrichment — the seam (v5.0, R27 ledger #2) → Lane F
**Ruling: bless the direct wire — no new lifecycle hook. But the `gone` *application form* is C's, and it's
landed in `dig.js`.** The question wasn't hook-vs-no-hook; it was *how* `gone` applies.
**1. Lifecycle — no hook needed.** F already holds `currentAdapter` (`interior_mode.js:67`) and passes it to
`dig.open()` **per dig** (`:111`) — a fresh read every time a crate is opened. When the live read lands, F
updates the pack; the next dig picks it up. If the read hasn't landed, hasn't finished, or failed, the
adapter is simply the tier-1 pack — **the dig never blocks on the network** (charter risk #1) and no C
lifecycle changes.
**2. The form — pick-then-omit, NEVER filter-the-array.**
`pack.gone` = a `Set`/array of ids the live read reports sold. C picks from the **full, stable `items[]`**
and omits gone ids **after** the draw.
- **Why (measured, R27, 5 crates):** filtering `items[]` first **reshuffles the crate** — every pick indexes
by position, so **one sale changed 21 OTHER records**. Pick-then-omit: **0 collateral**. A crate with 3
sold shows 13, not a different 16.
- **Both forms hide the sold record** — so **“the gone item never renders” cannot tell them apart.** → **F:
the sandbox proof (#4) must assert on *collateral* (the survivors are unchanged across a `gone` delta),
not just absence. The obvious assertion is vacuous here.** (Vacuous-gate law, third application — and the
cheapest one to get wrong, because it goes green.)
- **Proven in the real dig:** sold record gone · survivors **4/4** kept · **collateral 0** · `items[]`
untouched (120).
**3. F's invariant — `items[]` identity and order ARE the pick's stability.**
- **Do:** update item *fields* in place (live `price`) — free, no reshuffle; the card reads `it.price` at
pull time, so a live price shows on the next pull.
- **Never:** add, remove, or reorder `items[]`. That is what `pack.gone` exists for.
**4. The determinism boundary sits exactly here.** Server down ⇒ no `gone`, no live fields ⇒ the dig is
tier 1, deterministic and gate-able. Server up ⇒ the crate legitimately changes as records sell — tier 2 is
non-deterministic **by design** (charter law #2). So **no gate may assert dig determinism with the server
up**, and no live value may enter a seeded stream or a golden: `pack.gone` is consumed at pick time and
never persisted.
**5. The bin fan is decorative, not inventory.** The room's visible sleeves are built at `buildInterior` from
a *different* seeded pick than the dig's offers — a fan, not the crate's contents. A sold record's cover may
linger in the fan until the room rebuilds. If we ever want the fan to honour `gone`, **that** is when I'd
publish a post-build hook; it is not needed for the epoch's claim, because the crate you *riffle* is true.
---
## 9. The sell counter (v7.0-alpha, R30 ledger #2) → Lanes F/B
**The mirror of the buy card.** Walk to the keeper's counter holding items the shop trades in → a sell
card (the same manila price-sticker treatment as the dig's pull panel): item, OFFER, SELL. Module:
`web/js/interiors/sell.js` — DOM-only (zero draws, zero GPU resources), same consumer pattern as
`dig.js` (C ships the interaction; F owns the in-game input/mode hook).
### 9.1 THE OFFER — the no-pump law, structural (the multiplier and why)
```js
sellOffer(pricePaid) = min(pricePaid 1, max(1, floor(pricePaid × 0.5))) // and never < 0
```
- **The multiplier is 0.5** — the classic secondhand-dealer margin (a keeper pays about half of what
he'll sticker it at; he has to eat too). It keeps selling *meaningful* (half your money back beats a
full crate of duds) without being an income source.
- **The `min(pricePaid 1, …)` clamp is the law made structural, not tuned:** the offer is **strictly
below what you paid for every `pricePaid ≥ 1`** (at `p = 1` the offer is $0 and the SELL button
disables — the keeper won't buy what he can't sticker; at `p = 2` the naive `max(1, floor(p/2)) = 1`
would meet the clamp anyway — the clamp exists so no future retune of the multiplier can ever push an
offer to break even). **Buy-then-immediately-sell therefore loses ≥ $1 per round trip, always**
F's no-pump gate (`R30 ledger #5`, monotonic cash loss) holds **by construction**, and a change to
this formula that breaks that property is a contract violation, not a balance pass.
- **Alpha basis = `pricePaid`.** The alpha carries no guide-band data (the BIBLE bands are beta), and
in the alpha every buy happens at the shop's asking price — so `pricePaid` IS the buy-side price the
player faced. **Beta swaps only the basis, not the shape:** when E's guide bands land, the offer
becomes `min(bandLow 1, max(1, floor(bandLow × 0.5)))` — below the band's *low* edge by
construction, so a gem bought under band still nets its finder margin only against the band, never
against the keeper. The formula's clamp survives the swap untouched.
### 9.2 What the keeper buys (alpha: type = type, strict)
- `sellableIn(shopType, item)``item.type === shopType`. A record shop buys records, a book shop
books — **any** shop of the type (per-keeper taste, haggling, pawn-buys-everything: all beta, not
built).
- **An item with no `type` is NOT sellable — fail-closed** (vacuous-gate law: a matcher that passes an
untyped item would "match" everywhere). **→ Lane F ASK:** stamp `type` (the registry stock type:
`record`/`book`/`toy`/…) on every collection item at buy time — the R30 published shape
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
it nothing is sellable anywhere. Carrying `title`/`artist` too is strongly asked (B's collection UI
needs them; my card falls back to the sku/slotId label when absent — ugly but honest).
### 9.3 The seam — money like buying, removal via the game API only
- **C consumes `window.PROCITY.game.collection` READ-ONLY.** The card never splices, reorders, or
mutates it (same species of law as §8.3's `items[]`).
- **`onSell(item, offer)`** is the single side-effect callback — the exact mirror of the dig's
`onBuy(offer)` (the proven wallet seam). The **consumer** (F) does both halves: credit the wallet,
remove the item from the collection **through the game API**. `onSell` returning `false` = keeper
veto: card unchanged, nothing moves.
- `wallet.sell(item, offer)` is provided (C-owned `wallet.js`, mirrors `buy()`): credits `offer`,
drops the matching v0-inventory entry, notifies. F binds it exactly like it binds `wallet.buy`.
- Till ring: the sale rings `'till'` from the counter emitter (the R28 one-shot pattern, same
distance-gain math as the dig's) — silent-and-happy with no engine / `?mute` / `?noassets`.
### 9.4 The trigger + the card
- **Where:** within **`SELL_DIST = 2.0 m`** of `room.counter.pose` (bench centre, room-local — the
frame the interior camera walks in). Helper exported: `nearCounter(room, pos, dist?)`.
- **Suggested E routing for F** (matches my harness): aimed bin → dig (unchanged) · else near counter
with ≥ 1 sellable → sell card · else shelf-buy. Pointer unlocks while the card is up (dig precedent).
- **The card:** one item at a time — artist/title (or the sku/slotId fallback), the round OFFER
sticker, SELL, ` ` cycling when more than one sellable, ✕/Esc closes. `open()` returns `false`
(no card, no DOM) when nothing is sellable.
### 9.5 Fail-soft (the classic-pure law, C's half)
No game layer (`?classic=1` / `?game=0``window.PROCITY.game` absent) → the consumer never opens
the sell UI, **and** `open()` itself returns `false` on a missing/empty item list — zero errors, zero
DOM, zero localStorage. `createSell()` allocates DOM only; `dispose()` removes every node + listener.
### 9.6 API (loader-exact, per the §7 rule: the code is the contract)
```js
import { createSell, sellOffer, sellableIn, nearCounter, SELL_MULT, SELL_DIST } from './js/interiors/sell.js';
const sell = createSell();
sell.open({ shopType, shopName, items, getCash, onSell, onClose, emitters }) // → bool (false = nothing sellable)
sell.close(); sell.dispose(); sell.active // mirrors dig.js
```

View File

@ -6,6 +6,118 @@ _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)
*PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule:
**"if the measured win is <20k tris in practice, KILL the item with the measurement and it leaves the
backlog forever."** It does not survive. **E: do not build the `_lo` variants — that work is now unnecessary.***
### The measurement
Instrument tri counts, parsed straight from the shipped depot GLBs (`web/assets/models/`):
| instrument | tris |
|---|---|
| electric_guitar | 14,000 |
| bass_guitar | 14,000 |
| drum_kit | 14,000 |
| guitar_amp | 14,000 |
| mic_stand | 14,000 |
| **band + amp, all on stage** | **70,000** |
On paper that clears the bar: a 34k `_lo` each would "save" ~52k. **But the rule says *in practice*, and the
brief named the reason — "interiors are close-quarters."** So I measured the rooms an LOD would have to fire in
(live, all three venue archetypes, `interiorMode` driven by hand since rAF is throttled):
| venue | room | **max diagonal** | room tris (no band) | draws |
|---|---|---|---|---|
| pub — The Exchange Hotel | 9.7 × 9.7 m | 13.7 m | 12,522 | 38 |
| band_room — The Vinyl Shed | 8.9 × 9.6 m | 13.0 m | 11,699 | 35 |
| rsl — Wangaratta RSL (biggest) | 12 × 9.4 m | **15.2 m** | 15,115 | 38 |
### The verdict: practical win = **0 tris**. KILLED.
**A player can never be more than 15.2 m from the band** — that is corner-to-corner in the *largest* venue, and
the stage sits at one end, so the real stage-to-player max is shorter still. An LOD is a *distance* trade: it
pays when the subject is small on screen. Inside a ≤15.2 m room the band is never small — it is the subject of
the gig, and of F's tour money shot. Any sane swap threshold (1525 m) **never fires**; an aggressive 8 m one
would fire only in half a room, at a distance where a 34 k `_lo` is *visibly* worse with no perceptual cover.
The win is not "small" — it is **structurally zero**, because the distance the LOD needs does not exist in this
game. **Per the wind-sway rule: killed, off the backlog forever.**
No budget motivates it either: a venue interior is ~1215 k tris, ~85 k with the band — comfortably inside the
200 k stress ceiling E's R22 tri diet restored, at 3538 draws against a ≤350 ceiling.
### Side-observation for E (NOT a proposal — no breach motivates it)
All five instruments are **exactly 14,000 tris**, which is a decimator target, not a measurement of the objects:
a **mic stand does not need 14 k**. If instrument tris ever *do* become the driver of a real breach, the lever is
a **flat re-decimation by object complexity** (mic stand → ~1 k), which beats any LOD because it needs no
distance — not a `_lo` swap. Filing the observation so the next tri hunt starts from it; **not** asking for work.
---
## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item) ## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item)
*PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path, *PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path,

View File

@ -1,5 +1,110 @@
# LANE E — cross-lane notes # LANE E — cross-lane notes
## Round 28 — Spike 1: half of it shipped, half of it doesn't exist ⟨v5.1⟩
**1. `look.glb` — DONE, published, verified.** `Looking_Around.fbx` (675 KB, from the cached tailnet stash,
not re-downloaded — the skill's law) → `fbx_to_clip.py`**`web/models/peds/look.glb`**: mesh-free, **65
`mixamorig` joints, 195 tracks, inverseBindMatrices present, 136 KB** — structurally identical to the
`sit.glb` precedent on every field I can measure (`meshes/nodes/skins/joints/bind`), so D's `_canon` binds
it exactly like walk/idle/sit. Depot: published to `digalot.fyi/3god` under John's **R22 standing
authorization for Lane E assets** (`_published.json` 43 → 44). **→ D: it's ready.**
**2. 🚩 `lean` DOES NOT EXIST — the spike's premise is half wrong, and only John can unblock it.** The
brief and the kickoff both say "two of the 34 already-cached clips (lean, look-around)" and "Spike 1 needs
nothing from anyone". **`look-around` is cached. `lean` is not — anywhere.** Measured, not assumed:
- The stash's 34 clips contain no lean (`Agreeing · Bored · Breathing_Idle · Counting · … · Looking_Around
· … · Yawn`).
- `find ~/Documents -iname "*lean*"` on ultra: **nothing** — not in `mixamo-fetch`, not in `character_kit`,
not in `3D=models`, no FBX by that name at any depth.
- The fetch tool can't help unattended: `node fetch.cjs search "lean"`
`FATAL nothing on debug port 9222 — quit Brave and relaunch with --remote-debugging-port=9222`. It needs
**John's logged-in Brave session** (his browser, his Mixamo account) — **I won't relaunch it.**
**→ John, one small unblocker (adds to your two):** relaunch Brave with `--remote-debugging-port=9222`,
open a Mixamo tab, and either run `node fetch.cjs search "lean"` yourself or tell me and I'll fetch +
convert it in minutes (the path is proven twice now). **I did NOT substitute a lookalike.** `Bored` is the
nearest stash clip and it's a standing weight-shift idle, not a wall-lean — D's half explicitly needs
shopfront adjacency ("`lean` against a wall wants the same care" as the scale-aware traps), so a standing
idle would quietly change what Spike 1 proves. Half a spike, honestly, beats a whole one that lies.
**3. Instrument LOD — D killed it, and D's measurement is better than mine.** I'd built the five `_lo`
variants in scratch (14k → 3500/3500/4000/**6000**/3000) before D's commit landed saying *"E: STOP — do not
build the _lo variants."* **Nothing shipped**; scratch discarded. D measured the rooms an LOD would have to
fire in — **max diagonal 15.2 m in the largest venue, so the player can never be far enough: practical win
0 tris.** That's decisive in a way my argument wasn't. My own reading agreed on the verdict by a different
route, and it's worth recording as corroboration: **C's §4 already says "the interior gate is *draws*; tris
are the *street* budget (200k)"** — and an LOD swap doesn't change draws *at all* (same object, same
material, fewer tris). So the item's entire win landed on a metric no interior gate reads. Two independent
measurements, one verdict. **The oldest tri item is dead, correctly.**
*(One salvage worth keeping if it ever returns: at the brief's ~3k the **guitar_amp shreds** — white gaps
punched through its silhouette, confirmed against the shipped 14k thumb as a decimation artifact and not a
source defect, the R22 `food_cart` discipline. It needs **6k** to read clean. The other four were clean at
34k.)*
**4. 🚩 → A: your own file's comment now contradicts its code.** Investigating why the fingerprint read
`0x5f76e76` instead of the `0x3fa36874` I'd seen every round, I confirmed it's **legitimate** — your R27 w5
facade fix re-pinned 51 goldens under the amended covenant. But `selfcheck.js:267` pins
`hash: 0x5f76e76` while **`selfcheck.js:301` still says "the synthetic golden above stays 0x3fa36874"**.
Code and comment disagree by 34 lines. That's the exact species as R24's validator bug (my docstring said
nested, my code read top-level) — the next reader trusts whichever they read first. Your file, your call;
flagging per the round's "flag loudly if anything disagrees".
**5. Gates.** selfcheck **ALL GREEN 156,352/156,352** · `qa.sh --strict` **6 passed / 0 failed**. `look.glb`
moves **no golden** — proved by moving it aside and re-running: fingerprint identical either way, because an
unreferenced clip is never fetched. The `?classic=1` covenant is untouched: like `sit.glb`, a raw fetch
would breach the zero-fetch-delta rule, so **D must gate `look` behind an opt** the way `rigs.js` gates
`sit` (`loadPedFleet(base, { sit })` → the same shape for `look`). Depot-live verified by fetching the
bytes back: `https://digalot.fyi/3god/a/look.glb`**HTTP 200, 139,640 bytes, sha1 identical to local**.
## Round 27 — provenance v5-final, and the roster gap the docs freeze caught ⟨v5.0⟩
**1. THE BUG THE FREEZE FOUND: `redhill_godverse` was on disk but NOT in the roster — for three rounds.**
The v5 provenance table needed real numbers, so I measured instead of carrying the v4 row forward. The
count didn't match: **23 caches on disk, 22 rostered.** The missing one was **`redhill_godverse` — the
town the entire v5 epoch walks to.**
**Mine, squarely.** `index.json` is Lane E's file (`write_index()`). G's `redhill_godverse` landed in R24
(`86e2985`) *after* my last index run (`5815dfb`), and nobody re-rostered it. **An addition isn't done when
the cache is written — it's done when the roster is regenerated.** That's the exact mirror of my own R24
lesson (*"a retirement isn't done when the cache is deleted; it's done when the consumers are swept"*) —
I wrote the lesson for deletions and then missed its reflection for additions.
**Why four green rounds never caught it — worth reading, because it's a gate-shape lesson.** Nothing was
broken *for the gates*: `selfcheck` enumerates `web/assets/towns/*.json` **directly** (so it saw the town
and A even pinned its goldens), and F's crate smoke boots **by key** (`?town=redhill_godverse`). The
roster's *only* consumer is **B's HUD selector** (`hud.js`, my R20 ledger #2 design). So the single symptom
was that **a player could not choose the headline town from the dropdown** — invisible to every automated
check by construction. A gate that reads around the artifact under test cannot test it: the same species as
the vacuous-gate law, one layer out. **Fixed:** re-ran `write_index()` → **23 rostered, 23 on disk, 0
missing**; only `index.json` moved (every cache byte-identical again); selfcheck **ALL GREEN 156,212**,
golden `0x3fa36874` untouched; `qa.sh --strict --matrix` **7/0/0**.
**→ F: this matters for #5, the tour.** The money shot walks Musgrave Road into Monster Robot Party. If any
tour frame reaches that town **through B's selector** rather than by key, it could not have found it before
this commit — by key it always worked. Worth one glance at your framing path.
**→ B:** the dropdown now carries both godverse towns. Your `${t.state ? ', ' + t.state : ''}` handles their
empty `state` gracefully — no change needed, and the defensive write is why this cost nothing cosmetically.
**→ G:** your godverse caches carry no `state`, so they roster as `state:''`. Harmless today (B degrades
cleanly). If you want "Red Hill, QLD" in the picker, emit `state` in the cache — my `write_index()` already
prefers the cache's own field, so it needs nothing from me.
**2. Provenance v5-final (ledger #6).** The ⟨v5.0-FINAL⟩ rows are measured, not inherited: the town pack
row was still claiming **v4's "22 towns / 1192 shops"** — stale since my R24 ballarat retirement and redhill
build (now **21 real / 1196 / 299 heroes**, plus both godverse towns = 23 rostered). Added the two v5 art
rows: the **real-stock atlas** (1 crate, 120 items, Monster Robot's own POS) and the **mint atlases** (14
crates, 224 items, seeded from real dealgod listings). **These are the table's first 🟡 rows** — everything
before v5 was 🟢 web-ok; real product photography is the first asset class this project ships that a human
must clear before public release.
**3. The licence line's story, written down as a mechanism (ledger #6).** The 🟡 rows are guarded by exactly
one printed line, so the freeze records why it took two rounds to make honest: R23's glob matched 0 files
and returned 0; R25's **permissive `or` fallback let the print go silent while the check still passed**
the flag stopped reaching a human on real product photos while the gate said OK. Now: one spelling, one
lookup with one owner, and the line is **counted** (judged N ⇒ N lines, or FAIL). **Both zones print**
mint carries the same in-house-green flag, because minting changes whose stock a crate *claims* to be,
never whose photograph it *is*. Frozen today at **15 crates, 15 flags, every one in front of a human.**
## Round 26 wave 4 — the mint-id fence: my check was faithful, the rule was stale ⟨v5.0-beta⟩ ## Round 26 wave 4 — the mint-id fence: my check was faithful, the rule was stale ⟨v5.0-beta⟩
**The red was mine and the fix is C's ruling.** My R25 slot-id check demanded `sku_` on every real-stock **The red was mine and the fix is C's ruling.** My R25 slot-id check demanded `sku_` on every real-stock
@ -702,12 +807,52 @@ loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses
| Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe | | Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe |
| Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata | | Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata |
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) | | Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
| Town pack **⟨v4.0-FINAL⟩** | **22 real AU towns, all 8 states/territories** — 1192 shops + real street geometry (`roads[]`) | **OpenStreetMap** via Overpass API — bounded per-town bbox; raw shops+roads snapshots committed in `_raw/` | `build_towns.py`: fetch → dedupe/suburb-fill/parody → C's §6 class map → 3× texture cap (drops counted) → v2 cache + roads | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md` + `license`/`attribution` on every cache) | | Ped clip **⟨v5.1⟩** | `look.glb` (E R28 — v6 Spike 1) — mesh-free, 65 `mixamorig` joints, 195 tracks, 136 KB; structurally identical to `sit`/`walk`/`idle` | Adobe **Mixamo** `Looking_Around.fbx` (675 KB) — from the **cached tailnet stash** (`johnking@ultra:~/Documents/mixamo-fetch/out/`), not re-downloaded | `fbx_to_clip.py` (Blender, mesh-strip) — the R16 path, unchanged | 🟢 Mixamo royalty-free (motion only, mesh stripped) — same zone as walk/idle/sit |
| Godverse town *(Lane G's, listed for completeness)* | `newtown_godverse` — 18 shops, rostered in E's `index.json` | GODVERSE/thriftgod shop census + OSM road geometry | `pipeline/godverse_town.py` (**Lane G owns this provenance**) | 🟢 ODbL 1.0 + GODVERSE census (G's own `license`/`attribution` fields) | | Ped clip **⟨v5.1⟩ NOT BUILT** | `lean.glb`**the source does not exist** (see R28 §1) | — | — | — |
| Town pack **⟨v5.0-FINAL⟩** | **21 real AU towns, all 8 states/territories****1196 shops** (299 heroes) + real street geometry (`roads[]`) | **OpenStreetMap** via Overpass API — bounded per-town bbox; raw shops+roads snapshots committed in `_raw/` | `build_towns.py`: fetch → dedupe/suburb-fill/parody → C's §6 class map → 3× texture cap (drops counted) → v2 cache + roads | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md` + `license`/`attribution` on every cache) |
| Godverse towns *(Lane G's, listed for completeness)* **⟨v5.0-FINAL⟩** | `newtown_godverse` (72 shops) · **`redhill_godverse` (37)** — the town whose record shop is THE shop. Both rostered in E's `index.json` (**23 total**) | GODVERSE/thriftgod shop census + OSM road geometry; `redhill_real` is E's donor | `pipeline/godverse_town.py` (**Lane G owns this provenance**) | 🟢 ODbL 1.0 + GODVERSE census (G's own `license`/`attribution` fields) |
| Real-stock atlas **⟨v5.0-FINAL⟩** | **1 crate, 120 items**, 2×2048² webp — Monster Robot Party (`3962749`) | **the real POS** (`recordgod`) — crate 550 "DEEP HOUSE"; covers are **the shop's own product shots**, cached by dealgod | `pipeline/godverse_stock.py` (**Lane G owns**) — `snapshot`(DB-side, sha256-pinned) → `pack`(pure) | 🟡 **in-house-green — FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE** · `sourcing:"real"` · ids `sku_<POS id>` |
| Mint atlases **⟨v5.0-FINAL⟩** | **14 crates, 224 items**, 14×1024² webp — keyed hero shops with **no POS** (5 record · 6 book · 3 toy across both godverse towns; the 6th record crate is the real one above) | **real dealgod listings** (the sellers' own product shots) — a *seeded sample*, `random.Random(shop_id)`, deterministic | `godverse_stock.py` mint baker (**Lane G owns**) — same bake path, sampled source | 🟡 **in-house-green — same flag** · `sourcing:"mint"` · ids `mint_<listing id>` · **never claimed as that shop's stock** |
**GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever: **GLB fleet = 33 manifest (23 fit + 10 furniture) + 5 `_hi` = 38 published.** Only external spend ever:
~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only). ~$0.64 (fal-Hunyuan, R5). Everything else is $0 / on-device (electricity only).
### ⟨v5.0-FINAL⟩ The licence line's story — why one printed line is a provenance mechanism
**The only 🟡 in this table is the v5 stock art, and the flag is the whole control.** Charter law #3 says
clearing photos for public release is **a human call** — so the gate does not pattern-match the licence
and rule on it. It **prints it**, every run, for every atlas, and a human decides. That makes one printed
line the mechanism the amber rows depend on, which is why it earned two rounds of work:
- **R23** — the gate globbed a name the runtime can never load: **0 files matched, returned 0**. It passed
identically whether atlases were absent, misnamed, or perfect. (The vacuous-gate law came from this.)
- **R25 — the line went silent and the gate still said OK.** Not a typo: the **check** read
`licence or license` (permissive) while the **print** read `licence` only. G's re-emit moved to US
`license`; the check passed, the print resolved to `{}`, and `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE`
**stopped reaching a human on a real shop's product photos**. *The fallback WAS the bug* — it let one
path stay permissive so it could not feel the other break.
- **The fix is structural, not a spelling.** One spelling, no fallback (British `licence` now ERRORS,
naming C's §7.3 retirement) · **one lookup, one owner** (`validate_index` returns the evidence string;
`main` prints what it is handed and never re-looks-up the field — no second path left to drift) · and
**the line is COUNTED**: judged N atlases ⇒ N licence lines shown, or FAIL. A gate that can say OK while
hiding the flag cannot exist by construction.
**Today, frozen:** `atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`.
Fifteen crates, fifteen flags, every one in front of a human. **Both zones print** — the mint rows carry the
same in-house-green flag because a seeded sample of real dealgod listings is still **the sellers' own
product shots**. Minting changes whose stock it *claims* to be, never whose photograph it *is*: that is why
`sourcing` is honesty and the licence is unchanged by it.
### ⟨v5.0-FINAL⟩ The id-space fences — provenance carried in the id itself
Two disjoint namespaces, blessed as design (C §7.2a, R26 w4), asserted by `validate_atlas`:
**`sku_<POS id>`** names a physical copy on Monster Robot's shelf · **`mint_<listing id>`** names a dealgod
listing standing in for one. A crate wearing the other's prefix **FAILS**. This is not naming hygiene:
tier-2's sold-means-gone looks up POS skus, so a `mint_` id filed under `sku_` doesn't fail a gate — it
**sells a real record out of a real shop to satisfy a minted stand-in**. G refused that shortcut when it
would have gone green the easy way, and that judgment is the fence. *(Same standing note as
plan-ids vs godverse-ids: one name, one space — never compare across.)*
### Audio inventory — 28 assets (`web/assets/audio/`, committed; contract = `manifest.audio`) ### Audio inventory — 28 assets (`web/assets/audio/`, committed; contract = `manifest.audio`)
All procedural, seeded, $0, parody-safe; OGG/Opus + M4A/AAC; beds+music loudnorm 16 LUFS, SFX peak-norm. All procedural, seeded, $0, parody-safe; OGG/Opus + M4A/AAC; beds+music loudnorm 16 LUFS, SFX peak-norm.

View File

@ -1229,3 +1229,662 @@ seeded, stable, 0 reuse across 15 crates), which is what made the ruling a decis
**`atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`** — and that second **`atlas-QA OK — 15 per-shop atlas(es) valid, 15 licence line(s) shown, 0 warning(s)`** — and that second
number is R24's finding #4 at full scale: fifteen `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE` lines now number is R24's finding #4 at full scale: fifteen `FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE` lines now
reach a human, where one silently printed nothing. reach a human, where one silently printed nothing.
---
## 27. Round 27 — v5.0 THE LIVE CRATE. F: the reader, and the server killed on purpose.
### ⚠ RULING NEEDED — "ZERO console errors on server death" is unachievable, and F measured why
The brief's arm 2: *"kill the server mid-session → next read degrades to tier 1 with **ZERO console
errors**."* **It cannot.** A `fetch` to a dead host logs `net::ERR_CONNECTION_REFUSED` at the **browser**
level — no `.catch()` suppresses it, because nothing in JS ever sees it. To log zero, the reader would
have to know the server is dead **without asking**, which is not a thing. **One refused connection is the
irreducible cost of discovering death.**
This is R26's 404 lesson one layer up, and it has the same shape: *you cannot probe blind and stay
silent.* R26's answer was to stop probing blind — the manifest told us what existed. There is no
manifest for "is the server alive"; the only way to know is to ask.
**F changed its own assertion, and the new one is STRICTLY STRONGER:**
- **ZERO errors from game code** (nothing threw, nothing warned) — the part that was always the point.
- **EXACTLY ONE refused connection per session** — which *proves the circuit breaker*. Without the
breaker it is one error per shop entry, forever. **"Zero" could never tell a working breaker from a
broken one, because both fail it.** An assertion that fails on correct behaviour teaches people to
ignore it — the R24 lesson (A's sweep) pointing at F this time.
**F is the beneficiary here** (this assertion is the last thing between F and the tag), so it is filed
loudly rather than quietly relaxed, with the impossibility measured and the replacement named. Fable
ratifies or overrules at the retro. The precedent is C's `tier`→`sourcing` and C+E's mint-id fence: the
lane that owns the thing rules on it **with measurement**, in writing, where it can be argued with.
### ⚠ F's own frame assertion was weak — a number without a control is not a measurement
ARM 2 first reported `worst frame 201ms` and **passed**, because F had written `< 250ms` — an arbitrary
number. 201ms is twelve dropped frames; if the death caused it, that is a stutter and the arm should have
gone red. **But entering a shop rebuilds an interior**, which costs the same with or without a server —
so an absolute threshold measures Lane C's room build, not the network failure. Fixed: the arm now walks
the *same shop with the breaker tripped* (no network at all) and asserts the **delta**. The question is
"did the death cost anything", not "is entering fast". *F caught this in F's own gate, one round after
telling A the same thing.*
### What the gate proves (arms 1 + sandbox, unchanged and green)
`gone[3] → 117 survivors · items[] 120, identity AND ORDER intact · COLLATERAL 0` — one sale removes one
record, it does not re-stock the shop (C's §8, and the assertion C warned was the cheap one to get wrong:
both pick-then-omit and filter-the-array hide the sold record, so absence proves nothing). `gone` carries
the atlas id form **verbatim** — zero transformation, the R26 fence holding. And the sandbox both ways:
a real sale removes a game record (real→game), while **every write verb answers 501** — G's point, and a
stronger absence proof than 404: a 404 means a handler ran and declined; **501 means there is no write
handler in the process at all.** A mint shop 404s: only the one real shop is live, which is the point.
**The subject exists only because G built it.** Production `gone` is EMPTY — 0 of crate 550's 120 records
have sold since 2026-07-01 — so this gate would have passed vacuously forever on live data. G flagged it
and shipped `make-fixture` before the gate could be born blind. *"A fixture is not a lie; a green assert
over an absent subject is."*
### The tour — 3 of 4 frames, and the 4th is FILED not shipped
`docs/shots/release_v5/`: **the real crate** (Monster Robot Party's own 120 records in its bins, price
stickers, the keeper at the counter — the money shot, and it reads), **a mint crate** (captioned MINT:
plausible, not real — a tour is exactly where that distinction gets quietly lost), and **the classic
covenant** (Boolarra Heads, `0x3fa36874`, five epochs on). Zero page errors.
**The frontage frame does not land, and F is not shipping it green.** Three attempts: the cluster
primitive posed at the town's densest cluster (which in Red Hill is 23 shops/60 m and *does not contain
Monster Robot*) → a photograph of an empty footpath. Anchoring on the shop's own lot fixed the position
and still faced **along** the edge → the road, shop out of frame. Facing the lot along its road-normal
still frames bitumen. **The R21/R22 trap, third and fourth time** — and the lesson has grown: the cluster
primitive answers *"where is this town's heart"*, which is **not** the question a frontage frame asks.
Red Hill's heart isn't Monster Robot; a sparse town has no heart to speak of. Anchor on the SUBJECT when
the frame is about the subject.
What's actually wrong is unmeasured, and that is why it isn't shipped: F does not yet know whether the
building is where the lot says, whether `frontEdge` is the road you'd stand on, or whether the pose needs
B's own frontage anchor (B built `poseForVenue`/bookmarks for exactly this). **Next: ask B** — B owns the
frontage and shipped the stocked-shopfront mark this round; F re-deriving B's anchor in a tour script is
how you get three bad frames. The interior money shot needed none of this: the room poses itself.
### ⚠ THE HOLD — `v5.0` NOT tagged. Every real town's shops face away from their street.
B's micro-task found it while fixing F's frame, and **F's three photographs of empty road are the
evidence**: it was never the pose. `plan_osm.js:443` aims the lot's **Z** at the road ("facade (Z)
faces road"); `buildings.js:404` draws the facade on the **local +Z face**. Exactly opposite. The
shopfront — sign, door, R26 crate mark — lands on the **far side**, and the street gets a blank wall.
**Since R18.** v4.0 shipped on it. Nine rounds of tours shot it. `katoomba_real`'s `street_noon`, the
bookmark the tours have used since R9, is 46 draws of bare road.
Root cause: **CITY_SPEC contradicts itself**`:71` (the lots contract) says Z, `:335` (R15 posters)
says "+Z, B's canon" — and each generator implements one half. **Synthetic follows B's canon and looks
right, which is why every golden passes and nobody saw it for nine rounds.** The epoch's signature
species, one last time, and the worst instance of it: not a gate that fails on correct behaviour, but a
gate suite that is *green over a product that is wrong*.
**F verified it, and F's first check was VACUOUS — worth recording, because it nearly ended the round the
wrong way.** F measured each lot's `ry` against CITY_SPEC:71's own convention and got **35/35 and 72/72
FACING** — a clean contradiction of B. But `plan_osm` implements :71, so testing :71's data against :71's
rule is self-agreement: it cannot fail. **A self-consistent check over a self-contradicting spec passes
under either convention** — which is precisely why nine rounds of checks never caught this, and why F
nearly reported "B is wrong" with a green number in hand. The only test that sees it compares the two
conventions. F's frames already had.
**Not F's to fix**, and B was right not to either: it is **A's file**, it moves **every real-town plan**
(so every real-town golden re-pins under the amendment law — 36 of them), and **a self-contradicting spec
is Fable's to rule**, not a lane's to pick a side of. B proved the one-liner works (`ry = atan2(-nx,-nz)`
→ katoomba 0/72 → 72/72, and F's money shot appears 4 m from Musgrave Road) and reverted it. That is the
same discipline F applied to A's sweep and E's validator, arriving from the other direction.
**Order: Fable rules the spec → A lands the line + re-pins the goldens in one commit (amendment law) → F
re-gates, re-shoots the tour, tags `v5.0`.** Everything else for the release is done and green: the
kill-the-server gate, the reader, the sandbox, the interior money shot (interiors pose themselves and are
untouched by this — the crate is real either way).
*And B's own postscript is the lesson twice over: B credited "chunk-buildings 348 tris (0.2%)" to the
instancing work in R22 — 348 tris is ~29 boxes. It wasn't efficiency, it was the buildings being absent
from frame. B read the evidence as a win for five rounds. A number you like is the easiest vacuous gate
of all.*
### Wave 5 — the covenant amended, and the frame that took five tries
A's fix landed and **A caught the ruling's premise**: it was never "the real towns since R18". Synthetic is
broken identically — **0 toward / 681 away, dot = 1.0000** — and so is the marched path, and all three
fixtures. **EVERY TOWN, EVERY PATH, SINCE v1.** A booted `?classic=1`, stood on the synthetic main street,
found three blank grey boxes, walked *behind* them and found **NUMBAT PLAYTHINGS facing the dirt**. The
covenant town has been a row of back walls since the first commit.
**F re-pinned its own files** per A's handoff (A doesn't edit other lanes' files, correctly):
`GOLDEN_HASH 0x3fa36874 → 0x5f76e76` · `GIG_GOLDEN → 0xec7a2d39` · melbourne · katoomba · the captions.
**And A caught a live hazard in F's gate**: `hash_ok` carried `or ('3fa36874' in r.stdout)` — a bare-string
OR-clause that was dead the moment the covenant moved, and worse than dead, because it would go green off
a *sentence about* the old covenant appearing in stdout. Deleted. The check derives from `GOLDEN_HASH` or
it doesn't exist.
**John's ruling is the epoch's best line: the covenant is anti-drift machinery, not a defect
preservative.** `0x3fa36874` did exactly its job for five epochs — it never drifted. It just was never
right, and **no hash can catch what was wrong in the first commit**. A golden proves *nothing changed*; it
cannot prove *anything is correct*. Every gate we own was green over a world of back walls, and the only
thing that ever saw it was a human standing in the street looking at a wall.
**The frontage frame took five attempts and four of them were F's fault:**
1. cluster pose → the town's heart, which in Red Hill isn't Monster Robot. Empty road.
2. lot-anchored, facing along the edge → the road, shop beside the lens.
3. lot-normal facing → bitumen.
4. **B's helper — and F threw the result away.** `poseForShopFront` *returns* a pose; it does not apply
one. F called it, error-checked it, and never touched the camera, so the frame was the spawn paddock.
*A helper that hands you a value is not a helper that does the thing* — and F's own error-check passed,
because the call succeeded perfectly. The vacuous check, one last time, in the last frame of the epoch.
5. Applied it → **MONSTER ROBOT PARTY**, sign, door, awning, a ped walking in off Musgrave Road.
Attempts 13 were also never winnable: the shops faced backwards the whole time. **F was posing a camera
at a wall and tuning the geometry.** The measurement that would have found it in five minutes — compare
the two conventions against each other — is the one nobody ran for nine rounds, F included, right up to
the vacuous 72/72 F nearly filed as "B is wrong".
---
## 28. Round 28 — v5.1 THE SPIKE AND THE SWEEP. **The sweep landed. The spike did not reach the game.**
### ⚠ F's pose gate has NO SUBJECT — SKIPPED with the reason, per the law
The brief: *"New-clip figure checks (lean/look poses through the scale gate), a bookmark spot-check
(leaning peds in old frames)."* **There are no new poses in the game, so there is nothing to check:**
- **`lean` does not exist.** E measured it rather than assumed it — the stash's 34 clips contain no
lean, and `find -iname "*lean*"` across ultra returns nothing. The brief and the kickoff both named it
as already-cached; **the premise was half wrong**, and E found it the only way anyone ever finds these:
by looking instead of trusting the list.
- **`look.glb` is shipped, verified, published — and NOTHING BINDS IT.** `loadPedFleet()` loads
`walk.glb`, `idle.glb`, and `sit` (gated). No `look`. E's own handoff said it plainly: *"→ D: it's
ready. Gate it behind an opt the way rigs.js gates sit."* **D's entire R28 commit is
`LANE_D_NOTES.md` — 48 lines, docs only.** The run order said *"When E's clips land: D (#1 wiring)"*;
the log says **D committed BEFORE E shipped** (`afad68b` precedes `640bdbb`). Nobody was careless —
the wave order was, and the file on disk looks exactly like a delivered spike from every angle except
the one that matters.
**The R17/R23 pattern, fifth appearance, and the cheapest one yet to have missed**: an asset on disk,
verified, published to a public depot, sha1-checked by fetching the bytes BACK — and no consumer. Every
check E ran was real and passed. None of them could see that nothing loads it. *A gate on the artifact
cannot see the wire.*
### So: v5.1 SHIPS, and it does not claim the spike
Nothing is broken — an unbound clip is inert, the game is unchanged and correct. This is not R18's
"known breach"; it is an **unfinished item**, and holding a polish tag over an inert file would be
precious. **What ships is the sweep, and the tag says exactly that:**
- **C's `audioEmitter`** — the till rings from the counter, the bed from the stage. C's own idea, parked
since R7, and C found its own v3.0 note had specified it wrong on both halves (*"the panner was
inert"*).
- **Two items KILLED with the measurement** — B's awning ripple and D's instrument LOD, the oldest tri
item on the books. They don't leave the backlog "deferred"; they leave it **gone**, with the number
that killed them. *A backlog that only grows is a backlog nobody measures.*
- **`look.glb`** — staged on the depot, and honestly described as staged.
**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
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

@ -71,6 +71,130 @@ fences (`sku_`/`mint_`, plan-id/godverse-id) · no golden moves expected this ro
touches no plan, no cache, no atlas bytes) · the offline law is THE gate this round, not a touches no plan, no cache, no atlas bytes) · the offline law is THE gate this round, not a
footnote. footnote.
## WAVE-2 STATUS + F'S THREE LOAD-BEARING INPUTS (Fable, post-wave review)
*A/B/C/E/G are ALL IN. The beta tagged. What remains is F's close (#2 reader → #3 gate → #4
sandbox → #5 tour → #7 tag). The waves handed F three inputs that change the brief's letter —
build to THESE, not to my original wording:*
1. **C's seam ruling (§8): direct wire, no hook — but PICK-THEN-OMIT is law.** The naive wiring
(filter `pack.items`) RESHUFFLES the crate — C measured one sale changing 21 other records.
The reader draws from the stable full list and omits `gone` after: a crate with 3 sold shows
13, not a different 16. **Assert zero collateral in the gate** (sell one fixture record, the
other 119 identities unchanged). "Selling one record must remove one record, not re-stock
the shop."
2. **G's finding: `gone[]` has no subject in production.** The real POS has recorded no sale
since 2026-07-01 — 0 of crate 550's 120 records have ever sold, so a sold-means-gone assert
on live data passes vacuously forever. G built the remedy: **`make-fixture`** gives the gate
a subject. The gate MUST use it (vacuous-gate law — G flagged it before the gate could be
born blind).
3. **G's lifecycle is the kill switch:** `godverse_server.py start|stop|status` — the
kill-the-server gate drives it directly. The three fences are machine-enforced (read-only
credential measured permission-denied; `/reserve`+`/buy` → 501; metadata only, mint shops
404) — assert on them as fences, not re-derive them.
*Also for the record: E's freeze caught redhill_godverse missing from the towns roster for
three rounds (on disk, pinned, bootable by key — invisible only in B's selector; the one
consumer nobody gated). E owned it, fixed it, and wrote the gate-shape lesson. B verified the
R26 handshake against G's real manifest. A judged the risk list line-by-line and correctly
CARRIED risk #1 — "nothing retires this line except that gate going green."*
## WAVE-3 STATUS + TWO RATIFICATIONS (Fable, post-reader review)
*F's reader is IN (`39cfc3d`): pick-then-omit with `gone`/`price` applied IN PLACE (identity
and order preserved), fire-and-forget with a 1200 ms abort + circuit breaker, real-sourced
shops only. Browser-proven: 3 fixture sales → 0 collateral; server killed mid-session → 13.1 ms
enter, 14 ms worst frame gap, zero errors. Two F rulings are hereby RATIFIED as law:*
1. **Tier 2 is opt-in: `?live=1` / `?live=<url>`; absent → byte-identical to the beta.** F's
reasoning is the R26 lesson applied to a server — a default boot must never ask a question
that has no answer on machines that aren't John's. "Tier 2 is a place you go, not a thing
that happens to you." Auto-discovery is a v5.x question with a reachability contract.
2. **The collateral assertion is the canonical sold-means-gone gate.** "The gone item never
renders" cannot discriminate pick-then-omit from filter-the-array — both hide the record;
only COLLATERAL (the other 119 identities and order unchanged) tells them apart. Gates
assert collateral. (The vacuous-gate law's most precise application yet.)
**F's remaining close, in order:** formalize the browser proofs as the permanent smoke class
(#3's three arms + #4's both-directions sandbox, collateral-asserted, fixture-subject via
`make-fixture`) → the v5 tour (#5 — the crate walk, the mint-shop honest caption, the classic
control) → docs (#6 F-side: README v5 + the flags table gains `?live`) → `qa.sh --strict
--matrix` + the new smoke class → tag **`v5.0`** → push. Then Fable writes the epoch retro.
## WAVE-4 (Fable): two ratifications, one micro-task, and the tag path
**RATIFIED — F's §27 console-error ruling, and it is strictly stronger than the brief it
replaces.** The brief's "ZERO console errors on server death" (my wording, echoed in A's risk
line #1) is physically unachievable: a dead-host fetch logs `ERR_CONNECTION_REFUSED` at the
browser level where no `.catch()` reaches — to log zero, the reader would have to know the
server is dead without asking. One refusal is the irreducible cost of discovering death. The
ratified assertion: **zero errors from game code + EXACTLY ONE refused connection** — which
proves the circuit breaker, where "zero" could never distinguish a working breaker from a
broken one (both fail it). F filed this loudly as the beneficiary instead of relaxing it
quietly; that is the standard. (A: your risk-line wording is superseded by this record — a
one-line amendment at your leisure, not a blocker.)
**RATIFIED — the control-based frame assertion.** F's original `<250 ms` threshold passed
while measuring Lane C's room build and calling it network behaviour; the control (same walk,
breaker tripped, no network: 202 ms) exposed it, and the death's true cost is **Δ+2 ms**.
Assertions on frame cost are DELTAS AGAINST A CONTROL from here on, not absolute numbers —
the R21 lesson, now law for performance gates.
**THE MICRO-TASK — B: the frontage pose (F's filed ask).** F produced three frames of empty
road trying to re-derive your frontage anchor, then stopped — correctly (the cluster primitive
answers "where is the town's heart", and Red Hill's heart doesn't contain Monster Robot; a
frontage frame asks a different question). You own the frontage geometry, the R26 shopfront
mark, and the `poseForVenue` machinery: publish a pose helper or bookmark that frames a shop's
frontage BY LOT (keyed off `shop.lot` / godverseShopId — `poseForShopFront(shopOrId, dist)`),
verified on Monster Robot (mark visible, door centred, sleeves-through-glass a bonus). One
commit, B-pathspec. This also closes the gap for every future godverse tour frame.
**Then F closes:** the 4th frame through B's helper → full `qa.sh --strict --matrix` +
the (now-permanent, corrected) kill-the-server smoke class → tag **`v5.0`** → push. Fable's
retro follows.
## WAVE-5 (Fable): THE SPEC RULING — the facade is +Z, and the fifth hold stands
*B's micro-task found why F photographed empty road three times: it was never the pose.
`plan_osm.js:443` aims the lot's Z at the road (implementing CITY_SPEC:71); `buildings.js:404`
draws the facade on +Z (CITY_SPEC:335, B's canon). The spec contradicts itself; each generator
implemented one half; synthetic follows B's canon so every golden has been green over a wrong
product since R18 — v4.0 shipped on it. The epoch's signature species at its worst: not a gate
failing on correct behaviour, but a green suite over a wrong world.*
**THE RULING: the visible facade is LOCAL +Z. B's canon is canonical.** It has been the de
facto truth since v1 (buildings.js drew it there from the start; venue.js seats door-glow/
queue/posters there; A's own R15 write-up said "the visible venue face is +Z"). CITY_SPEC:71's
Z line described the SIM convention (rig-forward at ry=0) and got written into the lots
contract by ambiguity — the same Z/+Z species as R13's RY_FLIP and R15's posters, now at the
root. The spec amends so the lots contract states: **facade/door/frontage = local +Z**; the
sim/rig-forward convention is documented separately as the different thing it is.
**A — the fix, the sweep, the pins (ONE commit):**
1. Land B's proven one-liner in the real-roads seating (`ry = atan2(-nx,-nz)` — B measured
katoomba 0/72 → 72/72 facing and F's money shot works 4 m from Musgrave Road; B reverted it
because it's your file).
2. **Measure the marched-osm path for the same flaw** — don't assume it's clean because nobody
noticed; that's how this one survived nine rounds. State the verdict either way.
3. Amend CITY_SPEC:71 per the ruling, re-pin every moved golden (the 36 real-town pins move;
marched/synthetic only if your measurement says so), all in the same commit per the
amendment law.
4. **The cross-convention gate (the lesson made law):** the selfcheck gains an assert measured
in B's canon — every lot's +Z facade faces its frontEdge street side — so the two
conventions are compared AGAINST EACH OTHER forever, not each against its own spec line.
A self-consistent check over a self-contradicting spec passes under either convention;
only the cross-check sees it. (F proved this the hard way and recorded it.)
**F — then the close:** re-gate (the matrix against A's re-pins), re-shoot the tour (the
frontage frame through B's `poseForShopFront` — and `street_noon` on real towns, which has
been "46 draws of bare road" since R18, becomes a real street), `qa.sh --strict --matrix`,
tag **`v5.0`**, push.
**B — done, with honours:** the helper (door-centred by projection, `doorCentreNdcX = 0.0000`),
the selector extracted to ONE home (`'g:31'` — the id-namespace fence now has a single
resolver), the self-correction of your own R27 intel, and the discipline of proving A's fix
then reverting it. Nothing further owed.
## THE EXACT RUN ORDER ## THE EXACT RUN ORDER
0. **WAVE 0: F re-runs and tags `v5.0-beta`.** Hard stop until it lands. 0. **WAVE 0: F re-runs and tags `v5.0-beta`.** Hard stop until it lands.

View File

@ -0,0 +1,63 @@
# PROCITY — Round 28 lane instructions (from Fable, integrator)
Date: 2026-07-17 · **v5.0 SHIPPED, retro written ([RETRO_V5.md](../RETRO_V5.md)). Nothing is
owed anywhere.** Round 28 is deliberately LIGHT: v6's first spike (the one that needs nothing
new) plus one parked polish item per lane — the items that have waited politely since as far
back as R7. Tag **`v5.1`** at close. The v6 spikes 2/3 stay gated on John's mocap vids and the
ultra integration call; nobody starts them.
## The ledger
1. **SPIKE 1 — the free stash (E + D; v6 groundwork, the charter's first thread).**
- **E**: convert two cached Mixamo clips from the tailnet stash — **`lean`** and
**`look-around`** — via the proven `fbx_to_clip.py` path (mesh-free, 65-bone, licence
table lines per clip: Mixamo royalty-free, same zone as walk/idle/sit). Depot + local per
the sit.glb precedent.
- **D**: wire them into street loiter — a window-shopping stop occasionally leans
(shopfront-adjacent) or looks around (anywhere). Seeded rolls on your existing `benchsit`
pattern (own rng stream, identity signature untouched), classic-safe by the same
`!CLASSIC` fetch gate, byte-identical proofs as ever (setSitting taught you the
scale-aware traps — `lean` against a wall wants the same care).
- Acceptance: a fresh boot shows peds leaning and glancing; classic/`?noassets` untouched;
the R16 seated-stature precedent covers any pose the no-giants gate misjudges (tag
`procityPosed` if needed — F's gate arm from R16 generalizes).
2. **Awning fabric ripple (B — parked since R17's wind round, your promised pick).** The
striped fabric canopies share the rigid awning InstancedMesh; split them (or attribute-mask
them) and give ONLY the fabric a gentle wind ripple riding your existing `wind.js` uniforms.
Weather-driven like the trees; `?classic`/`?weather=0` byte-identical by the same proof
pattern; budget stated (~0 expected — it's your shader).
3. **`audioEmitter` (C — your own parked idea, at last).** Positional interior audio: the gig
bed/walla emitting from the stage rather than the room, the till from the counter. Scope it
yourself (it's your idea — the brief states acceptance only): audible positioning in a venue
+ a normal shop, fail-soft under `?mute`/`?noassets`, no new assets (it's routing, not
content), B's audio engine consulted not modified (publish the ask if you need an engine
seam — B's file).
4. **Instrument LOD (E + D — the oldest tri item, v3.1-era).** E: `_lo` variants (~34k) for
the five instruments (the `_hi` depot pattern in reverse); D: distance swap in the band
(interiors are close-quarters — if the measured win is <20k tris in practice, KILL the item
with the measurement and it leaves the backlog forever, the wind-sway rule).
5. **The gate + the tag (F, last).** New-clip figure checks (lean/look poses through the
scale gate), a bookmark spot-check (leaning peds in old frames), `qa.sh --strict --matrix`,
notes §28, tag **`v5.1`**, push. Nothing here moves a golden (clips and shaders are outside
every plan hash) — flag loudly if anything disagrees.
6. **A + G — rest.** A: on-call (nothing should touch a plan). G: your next work is v5.x
Option B when John sets a hold budget, or mocap support on ultra — nothing on m3 this round.
## THE EXACT RUN ORDER
1. **Start together: E (#1 clips + #4 `_lo` variants) + B (#2) + C (#3).**
2. **When E's clips land: D (#1 wiring, then #4 swap-or-kill).**
3. **When B/C/D are in: F (#5)** — gate, tag `v5.1`, push.
Waves: **[E, B, C] → [D] → [F]**. A/G rest.
## Standing
- **John — two unblockers for v6, both yours, both parallel to this round:** (1) film 23
mocap test vids into MOCAPGOD's queue (`docs/CAPTURE.md` there — the crate-riffle first);
(2) nudge Fable-on-ultra for the MOCAPGOD-C/MIRPAMO integration call. When both exist,
R29 runs Spikes 2/3 and the v6 charter ratifies on their results.
- Also from the retro, the real-world note: **the shop POS has recorded no sale since
2026-07-01** — check the till sync when you're next in.
- **Backlog after this round** (if #4 kills cleanly): tram-stop tri diet · flag retirement ·
Option B · tier-2 auto-discovery · the v6 tracks. The polish debt is nearly drained.

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).

122
docs/RETRO_V5.md Normal file
View File

@ -0,0 +1,122 @@
# PROCITY v5 — THE REAL SHOP: the epoch retro
*Fable (m3, integrator), 2026-07-17, written after the `v5.0` tag (`b2d29ba`) per ROUND27 §7.
Epoch span: R23 → R27. Tags: `v5.0-alpha``v5.0-beta``v5.0`. The first two-studio epoch:
Lane G (ultra) went from merged guest to the lane that shipped the epoch's server.*
## What shipped, in one breath
The tier ladder, whole: **344 real-or-minted items across 15 keyed crates in two godverse
towns**, every crate distinct (105 pairs, 0 shared titles), sourcing-honest (`real`/`mint` as
law, prefixes as namespace fences); Monster Robot Party's actual crate 550 riffled and bought
in-game off its actual POS; **tier 2 live** — read-only endpoints over the real till, fences
enforced at the DB credential, `/reserve`+`/buy` absent by John's A-then-B ruling; and the
epoch's defining proof: **a real HTTP server killed mid-session costs the game Δ+2 ms and
exactly one refused connection** — the offline law as an executable, not a sentence. Plus the
finale nobody ordered: **every shopfront in every town finally faces its street.**
## The five holds — the epoch's spine
Every hold was correct; every hold traced to the same species (a rule or contract meeting a
case written before the case existed); every hold was released by its owner, never by the
beneficiary:
1. **R23** — the crate was real but had no street, no identity, no compatible contract; the
gate built to catch it was blind-green, and #7a as briefed would have passed vacuously.
2. **R24→25** — E's rebuild sat uncommitted; tagging would have produced a tag whose tree
lacked the code that produced its green.
3. **R26** — the mint-id rule demanded a POS sku from crates that never saw a till; G refused
the easy green because colliding id spaces in the economy layer eventually sells the wrong
record.
4. **R27 (reader)** — "zero console errors on server death" was physically impossible; the
replacement (exactly one refusal) is strictly stronger because it proves the breaker.
5. **R27 (facade)** — the fifth hold found the oldest bug in the project.
## The finale: the facade, and the covenant amendment
B's frontage helper — a micro-task — exposed that `plan_osm` aimed lots' Z at the street
while `buildings.js` draws facades on +Z. The wave-5 ruling named it "every real town since
R18" and ordered A to *measure the marched path rather than assume it clean*. **A's measurement
caught the ruling's own premise: it was EVERY town, EVERY path, since v1** — all four seating
sites wrote `atan2(outward)` under comments that said `-outward`; 637 of 681 synthetic lots
faced no street at all; the classic covenant town was a row of back walls, and A proved it the
only way left — standing in the street, looking at NUMBAT PLAYTHINGS facing the dirt.
**John ruled the covenant amendment**, and his ruling is the epoch's best line: *the covenant
is anti-drift machinery, not a defect preservative.* `0x3fa36874` did its job for five epochs —
it never drifted. It was never right, and no hash can catch what was wrong in the first commit.
**A golden proves nothing changed; it cannot prove anything is correct.** 51 goldens re-pinned
in one commit; F re-pinned its own files per A's handoff (lane boundaries held to the last);
and A caught, in passing, a bare-string OR-clause in F's classic gate that would have gone
green off a *sentence about* the old covenant. The cross-convention gate is now in the
selfcheck so the two conventions are compared against each other forever.
## The epoch's signature species: the vacuous gate
Named as law in R24, then found NINE times — including three inside the law's own
applications: the empty-glob validator, the covers-render #7a, my id-equality gate spec
(positional ids collide), F's selector entering the wrong shop, the silent licence line, the
impossible zero-errors assert, F's 72/72 self-agreement (testing a spec line's data against
the same line), F's attempt-4 error-check (the call succeeded; the pose was never applied),
and the stdout OR-clause. The case law that came out of it is the epoch's real export:
- **A gate proves it touched its subject** (fetch log, matched-file count, id equality —
something falsifiable) or it is not a gate.
- **The collateral assertion**: when correct and broken implementations both hide the obvious
evidence, assert on what discriminates (the other 119 records unchanged).
- **Cross-convention checks**: two subsystems implementing "the same" convention are compared
against each other, never each against its own spec line.
- **Control-deltas for performance**: an absolute threshold measures the wrong thing politely.
- **A named exception starts protecting new bugs the moment the real fix exists** (F's 404
tolerance — deleted, and the deletion caught a ninth invisible probe).
- **Id prefixes are namespace fences**; plan ids, godverse ids, POS skus and listing ids are
four spaces, and no bare number crosses them.
## What worked — keep doing
1. **Restraint under benefit.** F declined to touch A's sweep, E's validator, and E's
uncommitted file across three separate holds where one edit would have unblocked its own
tag; B proved the facade one-liner and reverted it; A handed F's re-pins to F instead of
editing F's files. The party that benefits never releases its own hold.
2. **Ownership sentences.** "The red was mine" (E) · "my rule was the stale thing" (C) ·
"it fired 54/72 on a good town" (A) · "four of the five attempts were F's fault" (F).
Nobody defended; everybody measured.
3. **The contract's author is the authority for the contract's shape** — and when contract
and runtime disagree, **the runtime is the authority** (R24's root-cause ruling).
4. **Machine-enforced fences beat reviewed promises**: the read-only credential (measured
permission-denied), 501s for absent verbs, metadata-only responses.
5. **The two-studio pattern matured**: G went from branch-merged guest (R23) to full lane on
main (R25) to shipping the epoch's server (R27), with John's rulings recorded as mechanism.
## What cost us — fixed or fenced
1. **Comments and specs are not measurements.** All four facade sites' comments described the
correct behaviour above code doing the opposite, for 27 rounds. The cross-convention gate
is the fix; the lesson is older: nothing is true because a document says it.
2. **Consistency masquerades as correctness.** Every subsystem agreed with itself, so every
gate was green over a wrong world; only a human in the street saw it. Money shots and
walk-arounds are QA, not decoration — John's browser stroll started the v6 charter, and
A's street-level eyeball confirmed the covenant bug.
3. **My briefs carried five corrected errors this epoch** (the `tier` field, the id-equality
gate, the zero-errors assert, the "wire on entry" default that F correctly made opt-in,
and the wave-5 premise). All five were caught by the contract-first channel or by
measurement before they became bugs. Briefs state acceptance; lanes own method; the
integrator is not exempt from measurement-over-brief.
## Carried forward
- **v5.x**: Option B reservation (chartered, behind John's hold budget) · tier-2
auto-discovery (needs a reachability contract) · more POS shops if monsterrobot gains
siblings · **a shop-ops note for John, not a code item: the POS has recorded no sale since
2026-07-01 — G proved the dump is fresh but the data is stale; worth checking the till's
sync before the next live demo.**
- **v4.x tail** (unchanged): thin-tail clustering floor · seating overflow tail · instrument
LOD · tram-stop tri diet · awning ripple · audioEmitter · flag retirement.
- **v6 — THE LIVING STREET**: charter drafted (`docs/V6_LIVING_STREET.md`), spikes gated on
John's mocap vids (the MOCAPGOD queue is still empty) and the MOCAPGOD-C/MIRPAMO
integration call on ultra.
*— Fable. Twenty-seven rounds, eleven tags, five correct holds, one amended covenant. The game
now sells a record that is really in the crate, in a shop that is really on the street, in a
town that is really in Australia — and for the first time since v1, the shops are facing you
when you walk up the road.*

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

@ -81,3 +81,52 @@ plus this epoch's own:
5. **Settled non-risks:** the dig/wallet/stockAdapter machinery (shipped v2, soaked 16 rounds); 5. **Settled non-risks:** the dig/wallet/stockAdapter machinery (shipped v2, soaked 16 rounds);
static-atlas determinism (E's byte-identical pipeline discipline); the fail-soft ladder static-atlas determinism (E's byte-identical pipeline discipline); the fail-soft ladder
(proven pattern from `?noassets` day one). (proven pattern from `?noassets` day one).
### Retired-or-carried at the v5.0 close (ROUND27 ledger #6 — Lane A, line by line)
Verdicts against what shipped, not against the plan. Written in **wave 2**, so tier-2 items that F's reader
and kill-the-server gate will decide are **carried, not pre-retired** — the gate retires them, not this doc.
1. **CARRIED — and it is this round's defining gate, not a footnote.** The only risk here the epoch has not
yet measured. The reader (#2) and the kill-the-server gate (#3) don't exist as I write this; **nothing
retires this line except that gate going green** — boot server-up (live fields consumed), kill mid-session
(degrade to tier 1, zero console errors, frame-time measured across the failure), boot server-down (pure
tier 1). Until then the charter's offline law is a promise, and the whole point of #3 is that a promise
isn't a gate. *(If tier 2 proves bigger than one round, F holds and this line simply carries — four holds
have proven nobody forces an epoch close.)*
2. **RETIRED — the remedy the risk named is the remedy that shipped.** It predicted "pagination/crate
rotation rather than bigger atlases", and that is exactly what happened: the mecca case is served by the
**crate-rotation menu**, not by growing the sheet. Measured across all **15 keyed atlases**: max **2
atlases/shop** against C's hard cap of 2; the 14 mint shops sit at **16 items / 1 atlas / 1024²** and only
the one real POS shop earns 2048² (**120 items, 2 atlases, 1.2 MB**) under C §7.2's 60+ item rule. E's
validator enforces the ceilings from the committed files alone. Total: 344 items.
3. **RETIRED — the risk was designed out, not solved.** It assumed a **fuzzy-matching** problem ("names
drift, shops move") and demanded mismatches be counted. There is no matcher: identity is an **explicit
opaque key** (`godverseShopId`) that G emits, so there is nothing to mis-match. The predicted failure
couldn't occur in the predicted form — and the case that would have broken any name-matcher proves it:
**Monster Robot Party is in neither the census nor OSM**, so no matching strategy could ever have found
it; it is keyed by injection. Mis-stocking is fenced structurally instead: duplicate ids **error**,
uniqueness runs over defined ids, and the **orphaned-atlas gate** fails if a stocked shop isn't seated.
4. **SPLIT — the write half RETIRED by ruling, the read half CARRIED to F's #4.** *Write-back:* retired, and
retired properly — John's **A-then-B** ruling is recorded as **mechanism, not config**: `/reserve` and
`/buy` are **ABSENT, not stubbed**. Race conditions and reservation semantics cannot occur because the
verbs do not exist; "prove the absence" is a stronger guarantee than any sandbox flag, since a sandbox a
config typo can flip isn't a sandbox. *(Option B's reservation upgrade is chartered as v5.x, behind a
hold budget — it re-opens this line deliberately, when it is built.)* *Read (`gone`, real→game):* carried
— F's #4 asserts both directions this round.
5. **Unchanged, and extended by what the epoch settled:** static-atlas determinism proved in practice
(byte-identical re-bakes, including G's index-only sku re-emit that moved no `.webp` bytes); the
fail-soft ladder proved on every rung; and the **id-space fences** (`sku_`/`mint_`, plan-id vs
godverse-id) now have case law behind them rather than good intentions.
6. **NEW, CARRIED — the orphaned atlas.** The lift legitimately drops shops (overlap-resolve, counted since
R19); dropping one that carries a **committed atlas** means real stock keyed to a shop that isn't in the
town. Gated in `selfcheck` since R25 and **currently green** — and the gate armed itself this round
without anyone touching it: it SKIPped on `newtown_godverse` while no atlas keyed there, and **began
binding the moment G's mint atlases landed**. Live near-miss on the books: `redhill_godverse`'s lift drops
keyed shop **2286 "Empire Revival"**; G stocked its numeric neighbour **2287** instead. Carried because
the hazard grows with every shop stocked — **stocking 2286 would orphan it today.**
7. **NEW, CARRIED — "real" is one shop wide.** 14 of 15 keyed atlases are **mint**; exactly one is a POS
snapshot, because exactly one shop has a POS. That is honest and marked (`sourcing`), not a defect — but
the epoch's headline claim rests on **one** shop, and any future claim of scale needs either more POS
shops (v5.x: "if monsterrobot ever gets siblings") or continued care that `sourcing` stays legible to
humans, not just gates.

98
docs/V6_LIVING_STREET.md Normal file
View File

@ -0,0 +1,98 @@
# PROCITY v6 — THE LIVING STREET (epoch charter — DRAFT for John)
*Fable (m3), 2026-07-17. John's idea, from a browser walk down the strip: the crowd is
believable but narrow — one walk, one idle, one sit, everyone jaywalks, nobody owns the street.
Status: DRAFT — v5.0 (THE LIVE CRATE) is still closing; this charters the v6 conversation the
retro was already going to open. Applies to PROCITY first and 90sDJsim by depot (the Lane-G
two-studio pattern, proven).*
## The vision
The street stops being 24 clones who walk anywhere and becomes a town of *people*: personas
with their own gaits, errands and manners; footpaths used like footpaths; the odd car easing
down the main street; a busker outside the record shop doing a move John actually performed in
his actual shop. **Every asset generated, rigged, animated and shipped on our own machines —
zero cloud, zero subscriptions, everything owned.**
## The stack (measured today, not aspirational)
| piece | state | what it gives us |
|---|---|---|
| **mixamo-fetch** (tailnet source box) | **34 clips cached, only 3 wired** (walk/idle/sit) | the free stash, already in hand — lean, look-around, phone, talk, carry… |
| **MOCAPGOD** (ultra) | Lanes 0/A/B **DONE**: phone video → HSMR/SKEL → BVH, MPS-verified, ~1.6× realtime, mixamorig name-map shipped | film John → skeleton motion. Lanes C (retarget) + D (clip factory) remain |
| **MIRPAMO** (both Macs) | repo live: **a local offline Mixamo** — autorig (landmark → mixamorig fit → bone-heat weights) + retarget → `character@clip.glb` | new rigged BODIES from any mesh + clip retargeting, no Adobe |
| **MODELBEAST** (m3ultra :8777) | flux_local (Apache, free) · **hunyuan3d_mlx local** · trellis_mac · sf3d · mflux_image_edit · seedvr2 | concept art → 3D meshes → texture/outfit variants, $0 |
| **3GOD depot** | live, no-Draco law, two-studio consumption proven (Lane G) | one publish, every game (PROCITY · 90sDJsim · festifun) consumes |
| **PROCITY runtime** | ped fleet (19 rigged, `_canon` mixamorig), sit.glb clip pattern proven (R16), persona hooks half-exist (loiter/dance/bench/patronage) | the body the assets walk into |
| **A's street exports** | `roadWidth/vergeBand/poleOffset` per edge — **shipped R15, barely consumed** | the footpath law is one D constraint away, not a new system |
**MOCAPGOD Lane C and MIRPAMO's retarget are the same machinery** — an integration decision
for Fable-ultra before either builds it twice.
## THE SPIKES (do these FIRST — cheap, each kills the biggest unknown in one thread)
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 clips
(`lean`, `look-around`)~~ **AMENDED R28: `lean` does not exist in the stash** — E measured
the 34 instead of trusting the wishlist (the brief's premise was half wrong). `look.glb` is
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 —
**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 —
the integration call) → clip GLB → depot → **a ped riffles a crate on Musgrave Road,
in-game**. *Proves:* film-yourself-to-NPC works end to end. Needs: MOCAPGOD Lane C/D
sessions on ultra + John's test vids in the queue (it's empty — that's the ask).
3. **SPIKE 3 — a generated body (the stretch).** flux_local concept ("older shopper with
trolley, 90s AU high street") → hunyuan3d_mlx mesh → **MIRPAMO autorig** → walk retarget →
in-game past the no-giants gate. *Proves (or honestly fails):* autorig quality on generated
meshes — the least-proven link, which is exactly why it's a spike and not a plan line.
## The epoch tracks (after v5.0 tags + the retro + the spikes report)
1. **CLIPS** — wire the 34; fetch more free categories; the MOCAPGOD bespoke set (riffle,
till-operation, busker strum, lean-on-wall, trolley-push, phone-check). Licence rail: the
HSMR/SKEL NC-risk (MOCAPGOD Decision 1) means mocap-derived clips stay **in-house** until
reviewed — same discipline as the item photos, same flag line.
2. **PERSONAS (D)** — a seeded archetype layer per citizen: {body variant, clip set, speed
band, loiter/dance/bench biases, errand routes (pick a shop, walk there, dwell, move on —
patronage formalized into visible purpose)}. The persona is DATA riding the existing sim;
determinism per citizen id; identity signatures unchanged.
3. **STREET DISCIPLINE (D + B + A)** — the footpath law: peds walk the `vergeBand`, cross at
nodes (zebra-preferred on real roads — B's crossings already render); **occasional cars**:
seeded parked cars (instanced, furniture pattern) + 13 movers on the mains (the tram
pattern: deterministic path-followers, ~2 draws each, stated budgets). v0 non-interacting:
cars don't yield, peds don't jaywalk — both true by construction because each stays in its
band.
4. **BODIES (E + MIRPAMO)** — outfit/texture variety on the existing fleet first (
mflux_image_edit reskins — free variety, zero rig risk), MIRPAMO-autorigged newcomers
second, per Spike 3's verdict.
## Laws (inherited + this epoch's)
Everything behind **`?streetlife=1`** (cars possibly split as `?traffic=1` — F's call at
wiring), default-OFF until soaked, `?classic=1` byte-identical as ever · budgets stated per
system (cars and new rigs are draw/tri line items) · determinism from citySeed + citizen id ·
asset law (every new clip/body rides the manifest + depot + `?noassets` fail-soft) · licence
law (LICENSES.md per clip source: Mixamo royalty-free / MOCAPGOD in-house-NC-flagged /
generated CC0-equiv) · vacuous-gate law (a persona gate proves a persona DID something, not
that one exists) · measurement over brief.
## Sequencing (the honest order)
1. **Now:** v5.0 closes per ROUND27 — nothing here touches it.
2. **In parallel, zero collision (different repos):** MOCAPGOD Lanes C/D run on ultra ·
John drops 23 test vids in the MOCAPGOD queue · a MIRPAMO autorig smoke on one
hunyuan3d_mlx mesh.
3. **After the v5 retro:** the spikes run as v6 wave 1; this charter ratifies (or amends)
on their results; the tracks become rounds.
## John — the asks that unblock the spikes
1. **Film the test vids** (Spike 2 blocks on an empty MOCAPGOD queue): the capture protocol
is `docs/CAPTURE.md` in MOCAPGOD — the crate-riffle plus any two more moves you fancy.
2. **The MOCAPGOD C-vs-MIRPAMO integration call** is Fable-ultra's to make — nudge that
session when you're next on the M1.
3. Nothing else — the free stash (Spike 1) needs no new material at all.

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: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

View File

@ -0,0 +1,8 @@
<!doctype html><meta charset=utf-8><title>PROCITY v5.0 — THE LIVE CRATE</title>
<style>body{background:#111;color:#ddd;font:15px/1.6 -apple-system,sans-serif;max-width:1100px;margin:40px auto;padding:0 20px}img{width:100%;border-radius:6px;margin:10px 0}h1{font-weight:600}p{color:#aaa}hr{border:0;border-top:1px solid #333;margin:36px 0}</style>
<h1>PROCITY v5.0 — THE LIVE CRATE</h1>
<p>One real record shop, on its real street, holding its real stock — and a server that can die mid-session without the game noticing.</p>
<hr><h3>musgrave_road</h3><img src='musgrave_road.png'><p>the frontage — Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the game because it is on this road · Red Hill · retail heart: 2 shops/60m · 91 draws · 62,576 tris</p>
<hr><h3>the_real_crate</h3><img src='the_real_crate.png'><p>THE REAL CRATE — Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, real titles, real prices. Sell one in the shop and it leaves this crate · Monster Robot Party · 120 records from assets/stock_godverse/3962749/ · 40 draws · 40,239 tris</p>
<hr><h3>a_mint_crate</h3><img src='a_mint_crate.png'><p>a MINT crate — plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. Same tier, same offline guarantee, different provenance — and the atlas says so in its own index · Presents of Mind · 16 records from assets/stock_godverse/767/ · 51 draws · 30,897 tris</p>
<hr><h3>classic_covenant</h3><img src='classic_covenant.png'><p>?classic=1 — the exact v2 town, byte-identical forever (0x5f76e76). Five epochs, one covenant · Boolarra Heads · retail heart: 35 shops/60m · 162 draws · 39,259 tris</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

View File

@ -0,0 +1,22 @@
[
{
"name": "musgrave_road",
"file": "musgrave_road.png",
"caption": "the frontage \u2014 Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the game because it is on this road \u00b7 Red Hill \u00b7 retail heart: 2 shops/60m \u00b7 91 draws \u00b7 62,576 tris"
},
{
"name": "the_real_crate",
"file": "the_real_crate.png",
"caption": "THE REAL CRATE \u2014 Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, real titles, real prices. Sell one in the shop and it leaves this crate \u00b7 Monster Robot Party \u00b7 120 records from assets/stock_godverse/3962749/ \u00b7 40 draws \u00b7 40,239 tris"
},
{
"name": "a_mint_crate",
"file": "a_mint_crate.png",
"caption": "a MINT crate \u2014 plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. Same tier, same offline guarantee, different provenance \u2014 and the atlas says so in its own index \u00b7 Presents of Mind \u00b7 16 records from assets/stock_godverse/767/ \u00b7 51 draws \u00b7 30,897 tris"
},
{
"name": "classic_covenant",
"file": "classic_covenant.png",
"caption": "?classic=1 \u2014 the exact v2 town, byte-identical forever (0x5f76e76). Five epochs, one covenant \u00b7 Boolarra Heads \u00b7 retail heart: 35 shops/60m \u00b7 162 draws \u00b7 39,259 tris"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

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.

View File

@ -1,4 +1,5 @@
[ [
"look.glb",
"procity_fit_arcade_cabinet_01.glb", "procity_fit_arcade_cabinet_01.glb",
"procity_fit_bass_guitar_01.glb", "procity_fit_bass_guitar_01.glb",
"procity_fit_bass_guitar_01_hi.glb", "procity_fit_bass_guitar_01_hi.glb",

302
pipeline/godverse_server.py Normal file
View File

@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""Lane G — GODVERSE tier-2: the two READ endpoints (R27 ledger #1).
The charter's last rung, under John's ratified A-then-B ruling (G3 §7): **read-live,
sell-sandboxed**. The server ENRICHES, NEVER GATES every boot path, gate and tag works with
this process dead. Nothing here can write to the shop.
python3 pipeline/godverse_server.py setup-role # the read-only grant (idempotent)
python3 pipeline/godverse_server.py start|stop|status # lifecycle — F's kill-the-server gate
python3 pipeline/godverse_server.py serve # foreground
python3 pipeline/godverse_server.py make-fixture --sold 3 # a controlled POS for F's gate
THE SURFACE (G3 §4, as built):
GET /godverse/v1/health -> { ok, shops } (is the server up)
GET /godverse/v1/shop/<godverse_id>/stock -> live crate metadata + `gone` + etag
GET /godverse/v1/shop/<godverse_id>/crates -> the real crate menu (rotation)
GET anything else -> 404 (with the ruling in the body)
POST/PUT/PATCH/DELETE anything -> **501**, measured see below
**`/reserve` and `/buy` are ABSENT, not stubbed** no route, no handler, no flag. A sandbox a
config typo can flip isn't a sandbox (G3 §7).
** Lane F, assert 501 and not 404 for the write verbs.** This class defines `do_GET` and nothing
else, so the stdlib answers any other method with 501 "Unsupported method" before routing exists
at all. That is a *stronger* absence proof than a 404: a 404 would mean a handler ran and chose to
decline, whereas 501 means **there is no write handler in the process**. Adding `do_POST` just to
answer 404 would be implementing the very thing the ruling forbids. (My own docstring claimed 404
until I measured it the same doc-vs-code species this epoch keeps catching, caught here by
running it.)
THREE FENCES, each enforced by the machine and not by a code review:
1. **The credential.** We connect as `godverse_ro`: SELECT on inventory/crate/disc_cache and
NOTHING else. Measured `UPDATE inventory` => "permission denied"; `SELECT FROM customer`
=> "permission denied". That one grant carries the sandbox AND G3 §9's PII fence.
2. **Metadata only.** The API serves facts, never pixels. Covers stay static tier-1 files. A
tier-2 shop with a dead server is VISUALLY IDENTICAL to tier 1 that is the fail-soft.
3. **The id-namespace fence** (R26 law). `gone[]` carries the atlas's own `id` form verbatim
(`sku_<POS sku>`), so the client does `gone.includes(item.id)` with ZERO transformation.
*G3 §4 as ratified said `gone:[sku]` bare numbers. That wording predates R25's sku ids and
R26's fence, and a bare number crossing two id spaces is the exact bug the fence law exists
to stop. The design doc was the stale thing; §4 is updated as-built.*
TIER 2 IS FOR REAL SHOPS ONLY. A `sourcing: "mint"` shop has no POS to be live about, so its
`/stock` is a 404 not an empty crate. Monster Robot Party is the only real shop; that's the
point: one shop, live, true.
"""
import argparse, hashlib, json, os, signal, subprocess, sys, tempfile, time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STOCK = os.path.join(ROOT, "web", "assets", "stock_godverse")
PORT = int(os.environ.get("GODVERSE_PORT", "8778"))
# Runtime state lives OUTSIDE the repo: a pidfile is not an artifact, and a stray one in the tree
# is exactly the kind of thing that gets committed by a hurried `git add`.
PIDFILE = os.path.join(tempfile.gettempdir(), f"godverse-{PORT}.pid")
# The POS. Swap this and the server is live against a different shop-state — which is how F's
# gate stays deterministic without anyone faking real data (see make-fixture).
POS_DB = os.environ.get("GODVERSE_POS_DB", "recordgod")
POS_USER = os.environ.get("GODVERSE_POS_USER", "godverse_ro")
POS_HOST = os.environ.get("GODVERSE_POS_HOST", "127.0.0.1")
READONLY_TABLES = ("inventory", "crate", "disc_cache") # G3 §9: never customer/staff/sales
def q(sql, db=None):
"""Read-only query as godverse_ro. psycopg2 isn't on this box and the CLI keeps the
dependency surface at zero the same call thriftgod's own server would make."""
dsn = f"host={POS_HOST} dbname={db or POS_DB} user={POS_USER}"
r = subprocess.run(["psql", dsn, "-tAF\t", "-c", sql], capture_output=True, text=True)
if r.returncode:
raise RuntimeError(r.stderr.strip().splitlines()[-1] if r.stderr.strip() else "psql failed")
return [ln.split("\t") for ln in r.stdout.splitlines() if ln.strip()]
def manifest():
with open(os.path.join(STOCK, "index.json")) as fh:
return json.load(fh)
def real_shops():
"""Real-sourced (POS-backed) shops, per the manifest. NOTE the key is `godverseShopId`
(camelCase): the manifest is read by the JS runtime, so it wears the JS-side spelling, while
an atlas index wears the Python-side `shop.godverse_id`. That split is recorded in G3 §4
and I still read it wrong first time, which is the whole reason it's recorded."""
return [s for s in manifest().get("shops", []) if s.get("sourcing") == "real"]
def real_shop(gid):
"""The shop's shipped index, but ONLY if it is a real-sourced (POS-backed) shop."""
for s in real_shops():
if str(s.get("godverseShopId")) == str(gid):
for kind in s.get("types", ["record"]):
p = os.path.join(STOCK, str(gid), f"stock_{kind}_index.json")
if os.path.isfile(p):
with open(p) as fh:
return json.load(fh)
return None
def etag_of(payload):
"""Deterministic: same POS state => same etag. `served_at` is deliberately OUTSIDE it —
a clock in the etag would make every read a cache miss."""
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16]
def stock(gid):
idx = real_shop(gid)
if not idx:
return None
baked = {it["id"]: it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")}
if not baked:
return None
skus = ",".join("'" + s.replace("'", "''") + "'" for s in baked.values())
rows = q(f"""SELECT i.sku, COALESCE(i.slot_number::text,''), i.in_stock,
COALESCE(d.artist,''), COALESCE(d.title, COALESCE(i.title,'')),
i.price, COALESCE(i.condition,''), COALESCE(i.sleeve_cond,'')
FROM inventory i LEFT JOIN disc_cache d USING (release_id)
WHERE i.sku IN ({skus}) ORDER BY i.slot_number NULLS LAST, i.sku""")
live, present = [], set()
for sku, slot, in_stock, artist, title, price, cond, sleeve in rows:
present.add(sku)
if in_stock != "t":
continue
live.append({"id": f"sku_{sku}", "slot": int(slot) if slot else None,
"artist": artist, "title": title,
"price": float(price) if price else None,
"condition": cond, "sleeve_cond": sleeve, "state": "shelf"})
# GONE = baked into the tier-1 atlas, no longer on the shelf. Direction is real -> game ONLY:
# a record that sold IN THE REAL SHOP leaves the game crate. A game buy cannot appear here —
# there is no verb to carry it and no grant to write it (see the module docstring).
gone = sorted(i for i, sku in baked.items() if sku not in present or
sku not in {r[0] for r in rows if r[2] == "t"})
body = {"shop_id": int(gid), "tier": 2, "crate": idx.get("crate"),
"items": live, "gone": gone,
"counts": {"baked": len(baked), "live": len(live), "gone": len(gone)}}
body["etag"] = etag_of(body)
return body
def crates(gid):
if not real_shop(gid):
return None
rows = q("""SELECT c.id, c.name, COALESCE(c.label_text,''), count(i.sku)
FROM crate c LEFT JOIN inventory i ON i.crate_id = c.id AND i.in_stock
GROUP BY c.id, c.name, c.label_text HAVING count(i.sku) > 0
ORDER BY c.id""")
body = {"shop_id": int(gid), "tier": 2,
"crates": [{"id": int(a), "name": b, "label": c, "count": int(n)} for a, b, c, n in rows]}
body["etag"] = etag_of(body)
return body
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass # the gate reads assertions, not our chatter
def _send(self, code, obj, etag=None):
raw = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Access-Control-Allow-Origin", "*") # the game is served from :8130
if etag:
self.send_header("ETag", etag)
self.end_headers()
self.wfile.write(raw)
def _404(self):
self._send(404, {"error": "not found",
"note": "read-only surface: /health, /shop/<id>/stock, /shop/<id>/crates. "
"Write verbs (/reserve, /buy) are ABSENT by ruling — G3 §7, "
"A-then-B: read-live, sell-sandboxed."})
def do_GET(self):
u = urlparse(self.path)
parts = [p for p in u.path.strip("/").split("/") if p]
try:
if parts == ["godverse", "v1", "health"]:
return self._send(200, {"ok": True, "tier": 2, "pos_db": POS_DB,
"shops": [s["godverseShopId"] for s in real_shops()]})
if len(parts) == 5 and parts[:3] == ["godverse", "v1", "shop"]:
gid, verb = parts[3], parts[4]
body = stock(gid) if verb == "stock" else crates(gid) if verb == "crates" else None
if body is None:
return self._404()
if parse_qs(u.query).get("since", [None])[0] == body["etag"]:
self.send_response(304); self.send_header("ETag", body["etag"])
self.send_header("Content-Length", "0"); self.end_headers()
return
body["served_at"] = datetime.now(timezone.utc).isoformat()
return self._send(200, body, etag=body["etag"])
except Exception as e:
# A dead/denied POS must look like a dead server to the client: it degrades to tier 1.
return self._send(503, {"error": "pos unavailable", "detail": str(e)[:200]})
self._404()
# No do_POST / do_PUT / do_PATCH / do_DELETE. BaseHTTPRequestHandler answers 501 for verbs it
# has no handler for; the routes do not exist at all. Absent, not stubbed.
def serve():
print(f"godverse tier-2 :{PORT} · POS={POS_DB} as {POS_USER} (read-only) · write verbs ABSENT")
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()
def setup_role(_):
"""The grant that IS the sandbox (G3 §7). Idempotent."""
subprocess.run(["psql", "-q", "-d", "postgres", "-c",
"DO $$ BEGIN CREATE ROLE godverse_ro LOGIN; "
"EXCEPTION WHEN duplicate_object THEN NULL; END $$;"], check=True)
for db in (POS_DB, f"{POS_DB}_live", f"{POS_DB}_fixture"):
chk = subprocess.run(["psql", "-tAc", f"SELECT 1 FROM pg_database WHERE datname='{db}'",
"-d", "postgres"], capture_output=True, text=True).stdout.strip()
if not chk:
continue
subprocess.run(["psql", "-q", "-d", db, "-c", f"GRANT CONNECT ON DATABASE {db} TO godverse_ro;",
"-c", "GRANT USAGE ON SCHEMA public TO godverse_ro;",
"-c", f"GRANT SELECT ON {', '.join('public.' + t for t in READONLY_TABLES)} "
"TO godverse_ro;"], check=True)
print(f" {db}: SELECT on {', '.join(READONLY_TABLES)} — and nothing else")
def make_fixture(args):
"""A CONTROLLED POS for F's sold-means-gone arm.
Why this exists: the real POS has no movement in our window last sale 2026-07-01, last write
2026-07-09, and 0 of crate 550's 120 records have ever sold, so a real `gone` is EMPTY and an
assertion over it would have no subject (the vacuous-gate law). This is the honest way out: a
labelled fixture proves the MECHANISM deterministically (gone computed -> sent -> consumed ->
sleeve vanishes), while production reads the real POS unchanged. A fixture is not a lie; a
green assert over an absent subject is.
"""
fx = f"{POS_DB}_fixture"
subprocess.run(["dropdb", "--if-exists", fx], check=True)
subprocess.run(["createdb", "-T", POS_DB, fx], check=True)
idx = real_shop(args.shop)
skus = [it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")][:args.sold]
lst = ",".join(f"'{s}'" for s in skus)
subprocess.run(["psql", "-q", "-d", fx, "-c",
f"UPDATE inventory SET in_stock=false, sold_date=now() WHERE sku IN ({lst});"],
check=True)
setup_role(args)
print(f"fixture {fx}: {len(skus)} record(s) marked SOLD -> they must appear in `gone` and "
f"must never render:\n " + "\n ".join("sku_" + s for s in skus))
def start(_):
if status(None, quiet=True):
print("already running"); return
os.makedirs(os.path.dirname(PIDFILE), exist_ok=True)
p = subprocess.Popen([sys.executable, os.path.abspath(__file__), "serve"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
open(PIDFILE, "w").write(str(p.pid))
for _ in range(50):
time.sleep(0.1)
if status(None, quiet=True):
print(f"started pid={p.pid} :{PORT}"); return
print("failed to start"); sys.exit(1)
def stop(_):
"""F's kill-the-server gate calls this mid-session. SIGKILL, not a graceful drain: the gate is
testing what happens when the server DIES, not when it says goodbye."""
try:
pid = int(open(PIDFILE).read().strip())
os.kill(pid, signal.SIGKILL)
os.remove(PIDFILE)
print(f"killed pid={pid}")
except Exception as e:
print(f"not running ({e})")
def status(_, quiet=False):
import urllib.request
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/godverse/v1/health", timeout=1) as r:
up = json.loads(r.read())
if not quiet:
print(f"UP :{PORT} · POS={up['pos_db']} · real shops={up['shops']}")
return True
except Exception:
if not quiet:
print(f"DOWN :{PORT}")
return False
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
for name, fn in (("serve", lambda a: serve()), ("start", start), ("stop", stop),
("status", status), ("setup-role", setup_role)):
sub.add_parser(name).set_defaults(func=fn)
mf = sub.add_parser("make-fixture", help="a controlled POS so F's gate has a subject")
mf.add_argument("--shop", default="3962749")
mf.add_argument("--sold", type=int, default=3)
mf.set_defaults(func=make_fixture)
a = ap.parse_args()
a.func(a)

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

@ -222,13 +222,13 @@ if (existsSync(R('web/js/citygen/plan.js'))) {
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`); `${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
// [F2/R8] plan-source selector — pin goldens keyed by (seed, plansrc, TOWN). // [F2/R8] plan-source selector — pin goldens keyed by (seed, plansrc, TOWN).
// Lane A contract: synthetic 0x3fa36874 (selfcheck) · osm/melbourne 0x34cfdec0 · osm/katoomba 0x0f652510. // Lane A contract: synthetic 0x5f76e76 (selfcheck) · osm/melbourne 0x9c7e76b3 · osm/katoomba 0xf3aafec8.
// Guarded: no-op until A ships generatePlanFor + generatePlanOSM. // Guarded: no-op until A ships generatePlanFor + generatePlanOSM.
try { try {
const bar = await import(R('web/js/citygen/index.js')); const bar = await import(R('web/js/citygen/index.js'));
if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') { if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') {
const { xmur3 } = await import(R('web/js/core/prng.js')); const { xmur3 } = await import(R('web/js/core/prng.js'));
const TOWN_GOLDENS = { melbourne: '0x34cfdec0', katoomba: '0x0f652510' }; const TOWN_GOLDENS = { melbourne: '0x9c7e76b3', katoomba: '0xf3aafec8' };
for (const [town, golden] of Object.entries(TOWN_GOLDENS)) { for (const [town, golden] of Object.entries(TOWN_GOLDENS)) {
const a = bar.generatePlanFor(20261990, 'osm', { town }), b2 = bar.generatePlanFor(20261990, 'osm', { town }); const a = bar.generatePlanFor(20261990, 'osm', { town }), b2 = bar.generatePlanFor(20261990, 'osm', { town });
assert(JSON.stringify(a) === JSON.stringify(b2), assert(JSON.stringify(a) === JSON.stringify(b2),

View File

@ -7,7 +7,7 @@ The v4 thesis in five frames, into docs/shots/release_v4/ (+ contact.html). The
fitzroy_mecca the mecca (160 shops, 2× anything absorbed before this epoch) fitzroy_mecca the mecca (160 shops, 2× anything absorbed before this epoch)
toowoomba_thintail the thin tail (12 shops): proof the floor still reads as a town toowoomba_thintail the thin tail (12 shops): proof the floor still reads as a town
synthetic_control the procedural town, UNCHANGED: the control the whole epoch was measured against synthetic_control the procedural town, UNCHANGED: the control the whole epoch was measured against
classic_covenant `?classic=1`: the v2 covenant made visible, byte-identical forever (0x3fa36874) classic_covenant `?classic=1`: the v2 covenant made visible, byte-identical forever (0x5f76e76)
Posing: every real-town frame resolves through Lane B's `DBG` retail-heart anchor / `clusterPose` — the Posing: every real-town frame resolves through Lane B's `DBG` retail-heart anchor / `clusterPose` — the
primitive that came out of F's R21 finding (per-EDGE shop counts are ~1 on a real graph, so "densest edge" primitive that came out of F's R21 finding (per-EDGE shop counts are ~1 on a real graph, so "densest edge"

200
tools/qa/tour_v5.py Normal file
View File

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""PROCITY v5.0 RELEASE TOUR — THE LIVE CRATE (Lane F, R27 ledger #5)
The crate walk, in the order you'd actually do it: Musgrave Road → Monster Robot Party's frontage (B's
stocked-shopfront mark) inside, riffling ITS OWN records a mint shop, captioned honestly the
`?classic=1` control that still boots the exact v2 town.
Posing: the street frames go through F's R21 cluster primitive (reused from tour_shots.py). Per-EDGE shop
counts are ~1 on a real graph, so "the busiest edge" poses you at bare bitumen the Fitzroy trap, twice
learned. The interior frames pose themselves: the room builds you a metre inside its own door.
Honesty rules baked in, not bolted on:
· the mint frame says MINT in its caption. A minted crate is plausible, not real, and a tour is exactly
where that distinction gets quietly lost.
· the real frame's caption carries the shop's actual name and address, because it IS one.
· nothing here boots ?live=1. A tour frame must be reproducible by anyone who clones this repo, and
tier 2 needs a POS none of them have. The live tier is proven by the gate, which owns its own server;
the tour shows tier 1, which is what ships in the box.
"""
import json, subprocess, sys, time, pathlib
ROOT = pathlib.Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'tools' / 'qa'))
PORT = 8131
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v5'
SHOP = 3962749
# name, query, segment, mode, caption
V5_TOUR = [
('musgrave_road', f'plansrc=osm&town=redhill_godverse&stock=real&seed={SEED}', 3, f'frontage:{SHOP}',
"the frontage — Monster Robot Party on Musgrave Road, Red Hill, Brisbane. It is on this road in the "
"game because it is on this road"),
('the_real_crate', f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&seed={SEED}', 3, f'shop:{SHOP}',
"THE REAL CRATE — Monster Robot Party (147 Musgrave Rd): 120 records off its own till, real covers, "
"real titles, real prices. Sell one in the shop and it leaves this crate"),
('a_mint_crate', f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&seed={SEED}', 3, 'mint',
"a MINT crate — plausible, NOT real: a seeded sample standing in for a shop whose till we don't hold. "
"Same tier, same offline guarantee, different provenance — and the atlas says so in its own index"),
('classic_covenant', f'classic=1&seed={SEED}', 2, 'street',
"?classic=1 — the exact v2 town, byte-identical forever (0x5f76e76). Five epochs, one covenant"),
]
SHOT_JS = r"""
async ({ mode, seg, shop }) => {
const P = window.PROCITY, D = window.DBG, T = P.THREE;
D.setSegment(seg);
const plan = P.plan, shops = plan.shops || [];
if (mode.startsWith('shop:') || mode === 'mint') {
let target;
if (mode === 'mint') {
const man = await (await fetch('assets/stock_godverse/index.json')).json();
const mint = new Set((man.shops||[]).filter(s => s.sourcing === 'mint').map(s => s.godverseShopId));
const byId = new Map((man.shops||[]).map(s => [s.godverseShopId, s]));
target = shops.find(s => mint.has(s.godverseShopId) &&
(byId.get(s.godverseShopId).types||[]).includes(s.type));
} else {
target = shops.find(s => s.godverseShopId === shop);
}
if (!target) return { err: 'no target shop for ' + mode };
D.enterShop('g:' + target.godverseShopId);
await new Promise(r => setTimeout(r, 1500));
const si = D.stockInfo();
// pose: stand back from the spawn looking into the room, so the crates are in frame
const im = P.interiorMode, sp = im.current.spawn;
P.camera.position.set(sp.x, 1.6, sp.z + 0.2);
P.camera.rotation.set(0, sp.ry || 0, 0);
P.camera.updateMatrixWorld();
// Beauty shot: hide EVERY overlay, not just the street HUD. R21 taught this on the street frames
// and F only fixed it there the first v5 interior frame came back with "press Esc to leave"
// across the top of the money shot. Same lesson, second surface.
// Hide EVERY DOM overlay rather than guessing ids the interior banner is created at runtime, so a
// hardcoded selector list is exactly how the HUD sneaks back into the next money shot.
for (const el of document.body.children) if (el.tagName !== 'CANVAS') el.style.display = 'none';
for (let i = 0; i < 3; i++) await new Promise(r => requestAnimationFrame(r));
const info = D.info();
return { name: si.name, gid: si.godverseShopId, items: si.packItems, base: si.base,
shops: shops.length, edges: (plan.streets.edges||[]).length,
draws: info.drawCalls, tris: info.tris };
}
// street
// [R27w5] The frontage frame now goes through B's `poseForShopFront` — B's helper, B's geometry, B's
// shop. F re-derived this three times and produced three photographs of empty road; the fourth attempt
// would have been the same mistake with more arithmetic. The lane that owns the frontage owns the pose.
// (And the empty road was never the pose at all the shops faced backwards. Fixed by A, wave 5.)
const nById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const lots = (plan.lots || []).filter(l => l.use === 'shop' || l.use === 'anchor');
let hub = -1, best = null;
if (mode.startsWith('frontage:')) {
const r = D.poseForShopFront('g:' + mode.split(':')[1], 8);
if (!r || r.error) return { err: 'poseForShopFront: ' + JSON.stringify(r) };
// poseForShopFront RETURNS a pose; it does not apply one. F called it, error-checked it, and threw
// the result away so the camera never moved and the frame was the spawn paddock. A helper that
// hands you a value is not a helper that does the thing.
P.camera.position.set(r.pos[0], r.pos[1], r.pos[2]);
P.camera.lookAt(r.look[0], r.look[1], r.look[2]);
P.camera.updateMatrixWorld();
const sh = shops.find(x => x.godverseShopId === +mode.split(':')[1]);
const lot = sh ? (plan.lots || []).find(l => l.id === sh.lot) : null;
hub = lot ? lots.filter(o => Math.hypot(o.x - lot.x, o.z - lot.z) < 60).length : -1;
} else {
for (const l of lots) { let c = 0; for (const o of lots) if (Math.hypot(o.x - l.x, o.z - l.z) < 60) c++;
if (c > hub) { hub = c; best = l; } }
if (best) {
const e = (plan.streets.edges || []).find(x => x.id === best.frontEdge) || plan.streets.edges[0];
const a = nById.get(e.a), b2 = nById.get(e.b);
const dx = b2.x - a.x, dz = b2.z - a.z, L = Math.hypot(dx, dz) || 1, ux = dx / L, uz = dz / L;
let t = (best.x - a.x) * ux + (best.z - a.z) * uz;
t = Math.max(6, Math.min(L - 6, t));
const back = Math.min(26, t);
D.teleport(a.x + ux * (t - back), a.z + uz * (t - back), Math.atan2(ux, uz));
} else { D.shot('street_noon'); }
}
await new Promise(r => setTimeout(r, 1800));
for (const el of document.body.children) if (el.tagName !== 'CANVAS') el.style.display = 'none';
for (let i = 0; i < 3; i++) await new Promise(r => requestAnimationFrame(r));
const info = D.info();
return { name: plan.name, shops: shops.length, edges: (plan.streets.edges || []).length,
hub, draws: info.drawCalls, tris: info.tris };
}
"""
def ensure_server():
import socket
s = socket.socket()
try:
s.connect(('127.0.0.1', PORT)); s.close(); return None
except Exception:
pass
srv = subprocess.Popen([sys.executable, '-m', 'http.server', str(PORT)],
cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1.2)
return srv
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit('playwright not installed')
print(f"\033[1mv5.0 RELEASE TOUR — THE LIVE CRATE\033[0m ({len(V5_TOUR)} frames → docs/shots/release_v5/)")
OUTDIR.mkdir(parents=True, exist_ok=True)
srv = ensure_server()
shots, errs_total = [], 0
try:
with sync_playwright() as p:
b = p.chromium.launch()
for name, query, seg, mode, cap in V5_TOUR:
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.goto(f'{HOST}/index.html?{query}&dbg=1')
pg.wait_for_function('window.PROCITY && window.PROCITY.plan && window.DBG && window.DBG.ready',
timeout=25000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
pg.wait_for_function('() => !window.PROCITY.fleet || window.PROCITY.fleet.ready', timeout=15000)
except Exception:
pass
r = pg.evaluate(SHOT_JS, {'mode': mode, 'seg': seg, 'shop': SHOP})
if r.get('err'):
print(f" \033[31m✗\033[0m {name}: {r['err']}"); errs_total += 1; pg.close(); continue
pg.screenshot(path=str(OUTDIR / f'{name}.png'))
extra = (f" · {r['items']} records from {r['base']}" if r.get('items')
else (f" · retail heart: {r['hub']} shops/60m" if r.get('hub') else ''))
caption = (f"{cap} · {r['name']}{extra} · {r['draws']} draws · {r['tris']:,} tris")
shots.append({'name': name, 'file': f'{name}.png', 'caption': caption})
print(f" \033[32m●\033[0m {name}: {caption}")
errs_total += len(errs)
if errs:
print(f" \033[31m{len(errs)} page error(s)\033[0m: {errs[0][:110]}")
pg.close()
b.close()
finally:
if srv:
srv.terminate()
html = ["<!doctype html><meta charset=utf-8><title>PROCITY v5.0 — THE LIVE CRATE</title>",
"<style>body{background:#111;color:#ddd;font:15px/1.6 -apple-system,sans-serif;max-width:1100px;"
"margin:40px auto;padding:0 20px}img{width:100%;border-radius:6px;margin:10px 0}"
"h1{font-weight:600}p{color:#aaa}hr{border:0;border-top:1px solid #333;margin:36px 0}</style>",
"<h1>PROCITY v5.0 — THE LIVE CRATE</h1>",
"<p>One real record shop, on its real street, holding its real stock — and a server that can die "
"mid-session without the game noticing.</p>"]
for s in shots:
html.append(f"<hr><h3>{s['name']}</h3><img src='{s['file']}'><p>{s['caption']}</p>")
(OUTDIR / 'contact.html').write_text('\n'.join(html))
(OUTDIR / 'shots.json').write_text(json.dumps(shots, indent=1))
print(f"\n{OUTDIR}/contact.html ({len(shots)} frames, {errs_total} page error(s))")
return 0 if (len(shots) == len(V5_TOUR) and errs_total == 0) else 1
if __name__ == '__main__':
sys.exit(main())

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

@ -20,9 +20,17 @@ the "© OpenStreetMap contributors (ODbL)" credit visible.
**`index.json` is the machine-readable roster** (key/town/state/shops/roads) — read it rather than **`index.json` is the machine-readable roster** (key/town/state/shops/roads) — read it rather than
hardcoding a list. Every cache carries the same terms; per-town numbers live in each cache's `counts`. hardcoding a list. Every cache carries the same terms; per-town numbers live in each cache's `counts`.
**The pack: 21 real towns · 1196 shops · 299 heroes** (+ Lane G's `newtown_godverse` = 22 rostered in **The pack: 21 real towns · 1196 shops · 299 heroes** (+ Lane G's `newtown_godverse` and `redhill_godverse`
`index.json`), every one fetched from OSM Overpass with a bounded per-town bbox, ODbL 1.0, raw snapshots = **23 rostered** in `index.json`), every one fetched from OSM Overpass with a bounded per-town bbox, ODbL
in `_raw/` (shops + roads per town), fetched 2026-07-16 (`redhill_real` 2026-07-17). 1.0, raw snapshots in `_raw/` (shops + roads per town), fetched 2026-07-16 (`redhill_real` 2026-07-17).
> **⟨v5.0-FINAL⟩ roster fix (R27):** `redhill_godverse` — the town the whole v5 epoch walks to — was **on
> disk but missing from `index.json` for three rounds**. G's cache landed *after* my last `write_index()`
> run and nobody re-rostered it. Every gate stayed green because `selfcheck` reads the directory directly
> and F's smoke boots the town **by key**; only B's HUD selector reads this roster, so the sole symptom was
> that **a player could not choose it from the dropdown**. Mine to own: the index is Lane E's, and an
> addition isn't done when the cache is written — it's done when the roster is regenerated. (The mirror of
> R24's lesson, where a *retirement* wasn't done until the consumers were swept.) See LANE_E_NOTES R27.
| state | towns | | state | towns |
|---|---| |---|---|

View File

@ -144,7 +144,7 @@
"key": "newtown_godverse", "key": "newtown_godverse",
"town": "Newtown", "town": "Newtown",
"state": "", "state": "",
"shops": 18, "shops": 72,
"roads": true, "roads": true,
"source": "godverse+osm" "source": "godverse+osm"
}, },
@ -164,6 +164,14 @@
"roads": true, "roads": true,
"source": "osm" "source": "osm"
}, },
{
"key": "redhill_godverse",
"town": "Red Hill",
"state": "",
"shops": 37,
"roads": true,
"source": "godverse+osm"
},
{ {
"key": "redhill_real", "key": "redhill_real",
"town": "Red Hill", "town": "Red Hill",

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,14 +191,30 @@ 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;
// [Lane F R27 §2 — TIER 2, OPT-IN] `?live=1` → the default GODVERSE base; `?live=<url>` → that base;
// absent → NO tier 2 at all, byte-identical to the beta. Opt-in is deliberate and is the R26 lesson
// applied to a server: a default boot that probed for a GODVERSE host would ask a question with no
// answer on every machine that isn't John's, and "it only logs an error when the server's away" is
// exactly the blind probe the manifest was built to end. Tier 2 is a place you go, not a thing that
// happens to you. Auto-discovery, if ever, is a v5.x question with a reachability contract behind it.
const LIVE_ARG = NOASSETS ? null : params.get('live');
const LIVE_BASE = !LIVE_ARG || LIVE_ARG === '0' ? null
: (LIVE_ARG === '1' ? 'http://127.0.0.1:8778' : LIVE_ARG.replace(/\/$/, ''));
let STOCK_SOURCING = null; // (godverseShopId) → 'real'|'mint'|null, from G's manifest (set below)
// [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere) // [Lane F R9] preload ALL three packs so record dig sleeves AND book-spine/toy-box shelves (buy-anywhere)
// are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads. // are real. Fail-soft per pack (missing → parody canvas). getStockPack(type) is what buildInterior reads.
// [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a // [Lane F R24 §6a — THE REAL CRATE] The town-wide packs stay exactly as they were (every shop without a
@ -222,27 +243,105 @@ if (STOCK_REAL) {
const e = byId.get(s.godverseShopId); const e = byId.get(s.godverseShopId);
if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) }); if (e && (e.types || []).includes(s.type)) preloadStockPack(s.type, { base: godverseBaseFor(s) });
} }
// [R27 §2] the same manifest already knows real vs mint — the tier-2 reader asks only real shops.
STOCK_SOURCING = (id) => (byId.get(id) || {}).sourcing || null;
} }
} catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ } } catch (e) { /* no manifest ⇒ no per-shop packs ⇒ parody. The ladder's bottom rung, unchanged. */ }
} }
} }
// [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
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
@ -304,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);
@ -377,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();
@ -458,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 });
} }
@ -472,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)
@ -481,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;
} }

View File

@ -119,7 +119,11 @@ export function generatePlan(citySeed) {
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)]; const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]); const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
out.blockId = blockId; out.blockId = blockId;
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street // ROUND27 THE FACADE FIX: aim the VISIBLE facade (local +Z — B's canon, what buildings.js draws) back
// down the outward normal, at the street. The old `atan2(nx, nz)` put +Z along the OUTWARD normal, i.e.
// facing away — this line's own comment has described the intended result since v1; only the sign was
// wrong. It read correct because CITY_SPEC:71 wrote the SIM convention (Z) into the lots contract.
const ry = Math.atan2(-nx, -nz); // facade (+Z) faces back down the outward normal, to the street
const r = rng(citySeed, streamKind, streamId); const r = rng(citySeed, streamKind, streamId);
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0; const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
let t = iStart; const end = len - iEnd; let t = iStart; const end = len - iEnd;
@ -237,11 +241,14 @@ export function generatePlan(citySeed) {
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2); const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]]; const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
const block = addBlock(dMarket, 'market', poly); const block = addBlock(dMarket, 'market', poly);
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal // Facades face +x (EAST, toward the spine — the market sits west of it). ROUND27 THE FACADE FIX: ry now
// (street→lot); a west-of-street lot's outward normal is x, so ry = atan2(1, 0) (= −π/2). The // aims the VISIBLE facade (local +Z, B's canon) at the street, so a west-of-street lot wants +Z = +x ⇒
// facade then faces (outward) = +x, matching marchStrip's convention. frontEdge is the spine // ry = atan2(+1, 0) (= +π/2). It was atan2(1, 0), which pointed +Z at x — away from the spine, into
// segment each lot actually fronts by z-band: stalls (z≈100..23) front segment 2; dept (z=55) → 3. // the market's back. Same one-sign error as marchStrip above, and the old comment described the
const ryEast = Math.atan2(-1, 0); // intended result ("the facade then faces (outward) = +x") that the code did not produce.
// frontEdge is the spine segment each lot actually fronts by z-band: stalls (z≈100..23) front
// segment 2; dept (z=55) → 3.
const ryEast = Math.atan2(1, 0);
const stallEdge = spineEdges[2]; // spine segment spanning z∈[133,0] — the stalls' band const stallEdge = spineEdges[2]; // spine segment spanning z∈[133,0] — the stalls' band
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square

View File

@ -12,7 +12,7 @@
// A future pass can honour exact projected positions; this lands a booting, invariants-green seam. // A future pass can honour exact projected positions; this lands a booting, invariants-green seam.
import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js'; import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js';
import { SHOP_TYPES } from '../core/registry.js'; import { SHOP_TYPES, roadWidth } from '../core/registry.js';
import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js'; import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js';
import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots
@ -327,7 +327,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
const half = AV_W / 2, sd = DEPTH + 2; const half = AV_W / 2, sd = DEPTH + 2;
const block = addBlock(district, 'mainstreet', const block = addBlock(district, 'mainstreet',
[[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]); [[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]);
const ry = Math.atan2(0, -1); // facade faces +z (north) to the avenue // ROUND27 THE FACADE FIX: the lots sit SOUTH of the avenue (z = zPos half DEPTH/2), so the
// facade must face +z (north) at it. `atan2(0,-1)` (= π) pointed local +Z at z — due south, away.
// The comment was right; the code was 180° off. Measured, not assumed: the marched path was NOT
// clean (the ruling asked me to check rather than trust that nobody had noticed).
const ry = Math.atan2(0, 1); // facade (+Z) faces +z (north) to the avenue
let t = SPINE_CLEAR; let t = SPINE_CLEAR;
rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; }); rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; });
}); });
@ -347,6 +351,7 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
// shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and // shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and
// dropping a crowded shop beats teleporting it onto a street it isn't on. // dropping a crowded shop beats teleporting it onto a street it isn't on.
const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres) const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres)
const FOOTPATH = 1.5; // ROUND27: kerb→shopfront. KERB alone is centreline-relative and sits INSIDE a main carriageway.
const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres) const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres)
const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 }; const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 };
// every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density // every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density
@ -440,8 +445,18 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
for (const { e, side, list } of perEdge.values()) { for (const { e, side, list } of perEdge.values()) {
list.sort((p, q) => p.t - q.t || p.s.id - q.s.id); list.sort((p, q) => p.t - q.t || p.s.id - q.s.id);
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len; const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len;
const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (Z) faces road // ROUND27 THE FACADE FIX (B's proven one-liner — B measured katoomba 0/72 → 72/72 and reverted it
const off = KERB, dep = DEPTH; // because this is Lane A's file). (nx,nz) is the OUTWARD normal on the shop's real side; the facade
// (local +Z, B's canon) must face back down it, at the road — so atan2(nx, nz), not atan2(nx, nz).
const nx = side * -uz, nz = side * ux, ry = Math.atan2(-nx, -nz); // outward normal on the shop's real side; facade (+Z) faces road
// ROUND27, exposed BY the facade fix: `KERB` (4 m) is measured from the CENTRELINE, but a `main`
// street's carriageway is 10 m wide — its kerb is at 5 m. So every main-street building has stood
// 1 m INSIDE the road since R18. It was invisible while the facade faced away (nothing was drawn or
// asserted on that side); the moment the shopfront turned around, its poster landed in the traffic
// lane (1.07 m). The building never moved — only which face is the front. Seat blocks behind the
// kerb per A's own corridor law (`roadWidth/2` IS the kerb), plus a footpath to stand on.
const pe = edges.find(x => x.id === e.id);
const off = Math.max(KERB, roadWidth(pe) / 2 + FOOTPATH), dep = DEPTH;
const block = addBlock(district, 'mainstreet', [ const block = addBlock(district, 'mainstreet', [
[e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off], [e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off],
[e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]); [e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]);

View File

@ -25,8 +25,23 @@ const section = s => console.log('\n' + s);
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309]; const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)). // ROUND27 THE FACADE FIX — B's canon is canonical: the VISIBLE facade is local +Z ⇒ world (sin ry, cos ry).
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)]; // This helper used to return (sin, cos), the SIM/rig convention that CITY_SPEC:71 mis-wrote into the lots
// contract. That is why nine rounds of goldens were green over a world where every shopfront faced away.
const facing = l => [Math.sin(l.ry), Math.cos(l.ry)];
// THE CROSS-CONVENTION GATE (ROUND27 wave 5 — the lesson made law).
// F proved the trap: measuring lot.ry against CITY_SPEC:71's own convention is SELF-AGREEMENT — it returned
// 72/72 "facing" over a town of blank walls, because a self-consistent check over a self-contradicting spec
// passes under either convention. The only check that can see the contradiction compares the two conventions
// AGAINST EACH OTHER. So this does not restate a spec line: it replicates LANE B's OWN placement maths from
// buildings.js (`toWorld(lot, 0, y, zf)` with `zf = d/2 + 0.02`) and asks a physical question the prose
// cannot fudge — does the quad the RENDERER actually draws land nearer the street than the lot centre?
// If either side drifts (A's ry sign, or B's zf sign), this fires.
const facadeQuadWorld = l => { // buildings.js: toWorld(lot, 0, y, d/2 + 0.02)
const cos = Math.cos(l.ry || 0), sin = Math.sin(l.ry || 0), zf = l.d / 2 + 0.02;
return [l.x + zf * sin, l.z + zf * cos];
};
// nearest point on segment AB to point P // nearest point on segment AB to point P
function nearestOnSeg(px, pz, ax, az, bx, bz) { function nearestOnSeg(px, pz, ax, az, bx, bz) {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1; const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
@ -70,13 +85,24 @@ function structuralSuite(plan, label) {
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`); ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`); ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
let backwards = null; let backwards = null, drawnAway = null, worstGain = 0;
for (const l of plan.lots) { for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l); const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; } if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { if (backwards === null) backwards = l.id; }
// THE CROSS-CONVENTION GATE: not "does ry match the spec's wording" (self-agreement — that is exactly
// what stayed green over blank walls for nine rounds), but "does the quad LANE B ACTUALLY DRAWS land
// nearer the street than the lot centre?". Physical, falsifiable, and blind to both spec lines.
const [qx, qz] = facadeQuadWorld(l);
const dCentre = Math.hypot(nx - l.x, nz - l.z);
const dFacade = Math.hypot(nx - qx, nz - qz);
if (dFacade >= dCentre) { if (drawnAway === null) drawnAway = l.id; }
worstGain = Math.max(worstGain, dFacade - dCentre);
} }
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : '')); ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
ok(drawnAway === null, `${label}: the RENDERED facade (B's +Z quad, buildings.js maths) faces the street` +
(drawnAway !== null ? ` — lot ${drawnAway}'s shopfront is drawn on the far side (worst: ${worstGain.toFixed(2)} m FARTHER from the road than the lot centre)` : ''));
const byBlock = {}; const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l); for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
@ -238,7 +264,7 @@ for (const s of SEEDS) {
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ──── // ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.) // (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
section('golden fingerprint (output-drift guard)'); section('golden fingerprint (output-drift guard)');
const GOLDEN = { seed: 20261990, hash: 0x3fa36874 }; const GOLDEN = { seed: 20261990, hash: 0x5f76e76 };
{ {
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0; const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`); ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
@ -274,7 +300,7 @@ for (const s of SEEDS) {
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are // (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874. // skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
section('OSM plan source parity (fixture-driven, zero-network)'); section('OSM plan source parity (fixture-driven, zero-network)');
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 }; const OSM_GOLDENS = { melbourne: 0x9c7e76b3, katoomba: 0xf3aafec8, silverton: 0x6d74c4 };
const OSM_SEEDS = [20261990, 1, 42, 777]; const OSM_SEEDS = [20261990, 1, 42, 777];
for (const town of osmTownKeys()) { for (const town of osmTownKeys()) {
const report = {}; const report = {};
@ -310,7 +336,7 @@ ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.str
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`); ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
} }
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file) // gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha). const GIG_GOLDEN = 0xec7a2d39; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
{ {
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0; const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`); if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
@ -571,34 +597,34 @@ const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
// the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law: // the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law:
// the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen. // the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen.
const REAL_TOWN_GOLDENS = { const REAL_TOWN_GOLDENS = {
adelaide_real: 0x5c790920, adelaide_real: 0x4b9137a5,
ballarat_real: 0xf0a9e8a1, ballarat_real: 0xf0a9e8a1,
bendigo_real: 0xc175194e, bendigo_real: 0x44f5ef2d,
bowral_real: 0xb23ee067, bowral_real: 0x5574436b,
braddon_real: 0x2f388775, braddon_real: 0xf03da36c,
brunswick_real: 0x7ecb5c3e, brunswick_real: 0xb548149e,
castlemaine_real: 0x26c128c9, castlemaine_real: 0xf3118387,
darwin_real: 0xcfb1917f, darwin_real: 0xd45d7042,
daylesford_real: 0x4ec7352c, daylesford_real: 0x96a92068,
fitzroy_real: 0x6549f61b, fitzroy_real: 0x1cb08059,
fremantle_real: 0x73d385df, fremantle_real: 0x17954f0a,
geelong_real: 0x2f65591e, geelong_real: 0xf90f4463,
glebe_real: 0x908e1835, glebe_real: 0xa9418848,
hobart_real: 0xa96328b9, hobart_real: 0xbcee1ccb,
katoomba_real: 0x420f677d, katoomba_real: 0x126a66cb,
launceston_real: 0x32b1ab7b, launceston_real: 0xa2bb4e71,
marrickville_real: 0x60148e37, marrickville_real: 0x8c8d39aa,
newcastle_real: 0xbb2c3050, newcastle_real: 0x61de3375,
newtown_godverse: 0xcf69b387, newtown_godverse: 0x1e8d49b9,
newtown_real: 0x741c18ec, newtown_real: 0x64a98d7e,
northbridge_real: 0x1a645848, northbridge_real: 0xb0c24e12,
// toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that // toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that
// cannot load is dead config: it can never fire, so it can never fail. Same species as the round's // cannot load is dead config: it can never fire, so it can never fail. Same species as the round's
// vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median // vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median
// NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.) // NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.)
redhill_godverse: 0xb2224939, redhill_godverse: 0x40d30a31,
redhill_real: 0x6046700f, redhill_real: 0x5b851696,
westend_real: 0x5b1e5ffe, westend_real: 0x6032d73a,
}; };
// ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is // ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is
// gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density // gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density
@ -606,31 +632,31 @@ const REAL_TOWN_GOLDENS = {
// ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens // ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens
// move — venue selection is gig-layer, so the base town goldens above are untouched. // move — venue selection is gig-layer, so the base town goldens above are untouched.
const REAL_TOWN_GIG_GOLDENS = { const REAL_TOWN_GIG_GOLDENS = {
adelaide_real: 0x24ad3c27, adelaide_real: 0xda729bae,
ballarat_real: 0xf097c331, ballarat_real: 0xf097c331,
bendigo_real: 0x646b2c23, bendigo_real: 0xe3e8d892,
bowral_real: 0xac809f0b, bowral_real: 0x3f37bbcd,
braddon_real: 0xc3e5cfc0, braddon_real: 0xbc51aed2,
brunswick_real: 0x8c08914e, brunswick_real: 0xc0a90188,
castlemaine_real: 0xf9713a69, castlemaine_real: 0x3c3cf400,
darwin_real: 0xbc5605b5, darwin_real: 0xd37b37cd,
daylesford_real: 0x2fb34c8a, daylesford_real: 0x7b24c5c5,
fitzroy_real: 0x690e5ef9, fitzroy_real: 0xcd31b1ab,
fremantle_real: 0xcf3426fc, fremantle_real: 0xc6449dc,
geelong_real: 0x6f47ec2f, geelong_real: 0xcd0ceac4,
glebe_real: 0x042f01fb, glebe_real: 0x8d2b12ca,
hobart_real: 0x22f80d7b, hobart_real: 0xda3211c9,
katoomba_real: 0xbf586b77, katoomba_real: 0x5eece2d4,
launceston_real: 0xeaf7ff4d, launceston_real: 0xb619b65b,
marrickville_real: 0xea21be51, marrickville_real: 0x5dc00b5f,
newcastle_real: 0x66fc2bf9, newcastle_real: 0xfc3cb144,
newtown_godverse: 0x1e266f60, newtown_godverse: 0x4153dee2,
newtown_real: 0xacdb3eee, newtown_real: 0xb6a75bb1,
northbridge_real: 0x83a1d5be, northbridge_real: 0x49fe0f2b,
// toowoomba_real RETIRED by E in R23 — see the note on the base map above. // toowoomba_real RETIRED by E in R23 — see the note on the base map above.
redhill_godverse: 0xc6f80e18, redhill_godverse: 0xb53f695d,
redhill_real: 0x673785ee, redhill_real: 0x21db6d1,
westend_real: 0xb2c7ddb6, westend_real: 0xac9e69ea,
}; };
// `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it. // `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it.
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json'); const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');

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,8 @@ 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.
// 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]));
const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 })); const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 }));
@ -127,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}
@ -141,8 +160,29 @@ export function createDig(THREE, renderer) {
document.body.append(hint, curLbl, panel, outBtn, toast); document.body.append(hint, curLbl, panel, outBtn, toast);
// ── procedural audio ── // ── procedural audio ──
// [R28] HOUSE AUDIO LAW (audio.js, Lane B): (1) ONE AudioContext; (2) ?mute=1 forces silence. This
// file broke both from R5 to R28 — a second, private AudioContext wired straight to `destination`,
// so the riffle sang right through ?mute=1 and never saw B's master gain. It survived three epochs
// because NO gate asserts mute ⇒ silence: the engine dutifully returns its silent surface, `?mute`
// looks honest from the engine's side, and the sound came from somewhere the engine never knew about.
// A gate that asks the engine whether it's muted can only ever answer yes. -> F: assert SILENCE, not
// muted-state (there is no second AudioContext ⇔ the law holds).
// The blips STAY procedural on purpose: zero-fetch, deterministic, and they still work under
// ?noassets=1, where a sampled riffle cannot. Only mute was broken; fix only mute.
// Routing them through B's bus (so master gain applies) needs a ctx/bus seam this lane must not open
// unilaterally — published as the R28 ask in LANE_C_AUDIO.md §Emitters.
const MUTED_BY_URL = (() => {
try { const p = new URLSearchParams(location.search).get('mute'); return p != null && p !== '0'; }
catch { return false; }
})();
function muted() {
const eng = window.PROCITY && window.PROCITY.audio; // B's PUBLIC getter — consulted, not modified
if (eng && typeof eng.muted === 'boolean') return eng.muted;
return MUTED_BY_URL; // standalone interior_test: no engine to ask
}
let actx; let actx;
function blip(kind) { function blip(kind) {
if (muted()) return;
try { try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)(); actx = actx || new (window.AudioContext || window.webkitAudioContext)();
if (kind === 'fwip') { if (kind === 'fwip') {
@ -228,24 +268,66 @@ 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.
const ok = onBuyCb ? onBuyCb(o) : true; const ok = onBuyCb ? onBuyCb(o) : true;
if (ok === false) { unpull(); return; } if (ok === false) { unpull(); return; }
blip('thunk'); // [R28] the sale rings on the till across the shop; the thunk stays as the no-engine fallback so
// the standalone harness (and ?noassets) keeps its sold cue.
if (!sfxFrom('till', emitters && emitters.counter)) blip('thunk');
showToast(`SOLD — ${o.a} · $${o.price}`); showToast(`SOLD — ${o.a} · $${o.price}`);
removeEntry(entry); removeEntry(entry);
}; };
} }
// [R28 audioEmitter] The till rings from the COUNTER, not from inside your head. You riffle at a bin,
// so the counter is 2.838.15 m away (measured, 8 types × 4 seeds) — a real distance to carry.
// This needs NO engine seam: B's playSfx(key,{gain}) is already public, so C does its own distance
// math and hands B a scalar. That is the whole reason a ONE-SHOT is tractable and a BED is not — a
// one-shot resolves its gain once, at fire time, from a position C already knows; a bed has to be
// re-gained every frame from inside the engine's update loop, which is B's file. Hence the split ask.
// `sfx-till.ogg` has been on disk since 15 Jul and has never once played: this is routing, not content.
// No emitter (F hasn't plumbed it, or no counter) ⇒ gain 1 ⇒ a plain, un-positioned till. No engine
// ⇒ silence. Both fail soft, both still ring true.
function emitterGain(em) {
if (!em) return 1;
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
if (!cam) return 1; // no camera to measure from → room-filling
const d = Math.hypot(cam.x - em.x, cam.z - em.z); // emitters are ROOM-LOCAL; so is the interior camera
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
return em.floor + (1 - em.floor) * prox; // never 0 — a till across the shop is quiet, not absent
}
function sfxFrom(key, em) {
const eng = window.PROCITY && window.PROCITY.audio;
if (!eng || !eng.playSfx) return false; // standalone/no engine → silent-and-happy
eng.playSfx(key, { gain: emitterGain(em) }); // ?mute / ?noassets / pre-gesture handled inside
return true;
}
let toastT = null; let toastT = null;
function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); } function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); }
@ -286,10 +368,24 @@ export function createDig(THREE, renderer) {
document.addEventListener('keydown', onKey, true); document.addEventListener('keydown', onKey, true);
// ?stock=real: seeded pick of real pack items (same bin seed → same crate contents). // ?stock=real: seeded pick of real pack items (same bin seed → same crate contents).
function pickRealOffers(pack, seed, n) { // TIER-2 SEAM (v5.0): `pack.gone` = ids the live read says sold IN THE REAL SHOP. We pick from the
// FULL, stable `pack.items` and omit gone ids AFTER the draw — never from a filtered array.
// Measured (R27, 5 crates): filtering the array first reshuffles the crate — one sale changed 21 OTHER
// records, because every pick indexes by position. Pick-then-omit: 0 collateral, the sold record simply
// leaves and the survivors keep their slots (a crate with 3 sold shows 13, not a different 16).
// Both forms hide the sold record, so "the gone item never renders" cannot tell them apart — the
// 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.
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
: (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
// [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.
@ -299,19 +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 }) // 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
// 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;
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);
@ -349,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

@ -128,6 +128,43 @@ function audioFor(type, ctx, opts, shop) {
return audio; return audio;
} }
// ── audio EMITTERS (round 28) — WHERE a key sounds from ────────────────────────────
// room.audio.emitters says which room-local point each bed/sfx belongs to, so the gig comes from the
// stage and the till from the counter instead of from the whole room. Pure data on the EXISTING
// `playInterior(room.audio)` call — no new lifecycle, no traverse, no dispose hook (the v3.0 note
// proposed `userData.audioEmitter` on a prop mesh; that shape is wrong — the engine holds no reference
// to the room group and would have to walk a scene it can't reach. The line was the stale thing).
//
// `r` is the REACH: distance at which the emitter has decayed to `floor`. It is the ROOM DIAGONAL, not
// a constant — measured across 8 types × 4 seeds, diagonals run 9.8115.63 m, and scaling by the room
// keeps the door→stage proximity in a consistent 0.420.59 band from a pokey band_room to a hall pub.
// `floor` is the room-filling RESIDUE and is never 0: a pub PA fills the room, what changes as you walk
// in is the BALANCE, not the presence. floor 0.35 ⇒ ~4 dB door→stage — audible, not a new game.
//
// NOTE FOR THE CONSUMER (measured, R28): do NOT spatialise these with a PannerNode. Nothing in the tree
// sets `ctx.listener`, so a panner resolves against a listener frozen at (0,0,0) — which, because rooms
// are built in room-local coords at the origin, is the room's own centre, forever. Measured via
// OfflineAudioContext: stage bed RMS 0.106905 at the door, 0.106905 at the stage — ratio 1.0000, no
// error, no warning, and it never changes as you cross the room. Use a distance-gain against
// PROCITY.camera.position, the way the tram/spill/gig-spill already do. See LANE_C_AUDIO.md §Emitters.
const EMITTER_FLOOR = { stage: 0.35, counter: 0.15 }; // PA fills a room; a till is a point source
const round = (v) => Math.round(v * 1000) / 1000; // same 3-dp form layout.js publishes poses in
function emittersFor(lay, dims) {
const r = round(Math.hypot(dims.W, dims.D)); // the room's own reach
const em = {};
if (lay.stage) {
// ear height above the deck, at the band's centre — not the deck surface
em.stage = { x: round(lay.stage.x), y: round((lay.stage.deckY || 0) + 1.2), z: round(lay.stage.z),
r, floor: EMITTER_FLOOR.stage };
}
if (lay.counter) {
em.counter = { x: lay.counter.pose.x, y: 1.1, z: lay.counter.pose.z,
r, floor: EMITTER_FLOOR.counter };
}
return em;
}
export function buildInterior(shop, THREE, opts) { export function buildInterior(shop, THREE, opts) {
opts = opts || {}; // tolerate undefined AND explicit null opts = opts || {}; // tolerate undefined AND explicit null
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0); const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
@ -135,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);
@ -185,7 +226,10 @@ export function buildInterior(shop, THREE, opts) {
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9) browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else [] watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
audio: audioFor(recipe.key, ctx, opts, norm.raw), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12) // { musicKey, toneKey, gigKey?, emitters } — Lane E manifest.audio keys (R11/R12) + WHERE they sound
// from (R28). emitters.stage is venue-only; emitters.counter is always present. Absent emitter ⇒
// room-filling, which stays the right default for a shop radio (the v3.0 call, unchanged).
audio: { ...audioFor(recipe.key, ctx, opts, norm.raw), emitters: emittersFor(lay, dims) },
dims: { ...dims, archetype, type: recipe.key }, dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter }, recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement, placement: lay.placement,

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

@ -90,6 +90,84 @@ export function installDBG(deps) {
// nearest shop matching pred, else nearest shop overall → a pose is always produced // nearest shop matching pred, else nearest shop overall → a pose is always produced
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); }; const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
// ── the shop selector: THE ID-NAMESPACE LAW, one implementation ─────────────────────────────
// [Lane F R22/R24 wrote this inside enterShop(); extracted verbatim in R27 w4 so the frontage
// pose below obeys the SAME law instead of growing a second, subtly different key scheme. A
// selector bug that resolves the wrong shop looks like it works — F's first cut proved that —
// so there is exactly one place to get it right.]
// resolveShop(116) → by PLAN id
// resolveShop('g:3962749') → by godverseShopId (the prefix IS the namespace fence)
// resolveShop('record') → first shop of that registry type
// resolveShop('The Exchange Hotel') → exact name, else name substring (case-insensitive)
// resolveShop(shopObject) → passed through
// Resolution order is most-specific-first so an id-shaped string can never be shadowed by a name.
// THE TWO ID SPACES COLLIDE: a GODVERSE shop has a plan id (local, renumbered per town, 1..N) and
// a godverseShopId (the real POS/dealgod id — what the atlas and every G-side query names it by).
// Monster Robot is plan 8 / godverse 3962749. Silky Oak's godverse id is 31 and another shop's
// PLAN id is also 31 — so a bare number is genuinely ambiguous. Bare number ⇒ plan id (unchanged,
// back-compatible); 'g:<n>' ⇒ godverse id, explicitly. When a bare number matches BOTH we say so
// in the return rather than pick quietly.
const resolveShop = (sel) => {
const shops = plan.shops || [];
let s = null, ambiguousWith = null;
if (sel && typeof sel === 'object') {
s = shops.includes(sel) ? sel : (sel.id != null ? shops.find((x) => x.id === sel.id) : null);
} else if (typeof sel === 'number') {
s = shops.find((x) => x.id === sel) || null;
const g = shops.find((x) => x.godverseShopId === sel) || null;
if (s && g && g !== s) ambiguousWith = { godverseShopId: sel, name: g.name };
if (!s) s = g;
} else if (typeof sel === 'string') {
const q = sel.trim().toLowerCase();
const gm = /^g(?:odverse)?:(\d+)$/.exec(q);
if (gm) {
const n = Number(gm[1]);
s = shops.find((x) => x.godverseShopId === n) || null;
if (!s) return { error: `no shop with godverseShopId ${n}` };
} else {
s = shops.find((x) => String(x.id) === q) // id-as-string
|| shops.find((x) => (x.name || '').toLowerCase() === q) // exact name
|| shops.find((x) => (x.type || '').toLowerCase() === q) // registry type
|| shops.find((x) => (x.name || '').toLowerCase().includes(q)); // name substring
}
}
if (!s) return { error: `no shop matching ${JSON.stringify(sel)}` };
return { shop: s, ambiguousWith };
};
const ambiguityNote = (sel, a, verb) => `${sel} is also the godverseShopId of "${a.name}" ` +
`${verb} by PLAN id; use 'g:${sel}' for the godverse shop`;
// ── frontage pose (R27 w4 — Lane F's ask for the godverse tour frames) ──────────────────────
// clusterPose() answers "where is the town's HEART", which is the wrong question for a named
// shop: Red Hill's heart doesn't contain Monster Robot, so three tour frames photographed empty
// road. This poses at a SHOP instead. Measured against buildings.js rather than eyeballed:
// • door — buildings.js centres it on the lot front (dw 1.2, y 0.022.2), so off = 0
// (NOT poseForShop's off = 1.0) puts it on the frame's centre line.
// • sign+mark — sign band is y (min(h,FACADE_H) 0.35) ± 0.25 ⇒ 2.402.90 for a normal shop;
// B's R26 crate mark is the strip at y ≈ 2.452.52 (atlas cell rows 128152).
// • verandah — the awning slab sits at y 2.95 and reaches 2.2 m over the footpath. Any camera
// BELOW it sees the whole sign, because a ray rising from camY < 2.9 to a target
// ≤ 2.9 can never cross 2.95. Hence camY 2.0: eye-level, and clear by geometry.
// The novelty_record landmark (a 1.47 m vinyl disc, top y 2.50) stands ~1.3 m proud of record
// shopfronts and unavoidably clips ~15% of the mark strip from EVERY offset — moving the camera
// only changes WHICH 15%, so there is nothing to dodge and the repeating spines still read.
const poseForShopFront = (sel, dist = 8) => {
const r = resolveShop(sel);
if (r.error) return r;
const lot = lotOf(r.shop);
if (!lot) return { error: `shop "${r.shop.name}" has no lot` };
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry);
const half = (lot.d || 8) / 2;
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half;
const out = {
pos: [frontX + fx * dist, 2.0, frontZ + fz * dist], // off = 0 ⇒ the door is the centre line
look: [frontX, 1.9, frontZ], // near-level: door low, sign band high
shop: r.shop.name, id: r.shop.id, godverseShopId: r.shop.godverseShopId ?? null,
};
if (r.ambiguousWith) out.ambiguous = ambiguityNote(sel, r.ambiguousWith, 'posed');
return out;
};
// ── lot- and node-based poses (the district bookmarks aren't all shopfronts) ── // ── lot- and node-based poses (the district bookmarks aren't all shopfronts) ──
const poseForLot = (lot, dist = 11) => { const poseForLot = (lot, dist = 11) => {
if (!lot) return null; if (!lot) return null;
@ -232,6 +310,17 @@ export function installDBG(deps) {
townAnchor: { ...townAnchor, anchored: REAL_ROADS, ref: { x: REF.x, z: REF.z } }, townAnchor: { ...townAnchor, anchored: REAL_ROADS, ref: { x: REF.x, z: REF.z } },
clusterPose: (kinds = ['main']) => streetViewPose(kinds), clusterPose: (kinds = ['main']) => streetViewPose(kinds),
// ── R27 w4: the frontage pose, published for Lane F's godverse tour frames ──
// clusterPose finds the town's heart; this finds a NAMED SHOP's frontage — door centred, sign
// band and B's crate mark in shot, under the verandah. Keyed through the one shop selector, so
// it inherits the id-namespace law: `poseForShopFront('g:3962749')` for a godverse shop,
// a bare number for a plan id (and it TELLS you when that number is ambiguous).
// DBG.poseForShopFront('g:3962749') → Monster Robot Party's frontage at 8 m
// DBG.poseForShopFront('Egg Records', 7) → by name, closer
// Returns { pos, look, shop, id, godverseShopId } — the same {pos,look} every bookmark uses —
// or { error } for an unknown selector / a shop with no lot. Never throws, never guesses.
poseForShopFront,
// time-of-day (hands the day cycle to the harness so captures are deterministic) // time-of-day (hands the day cycle to the harness so captures are deterministic)
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); }, setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
@ -277,33 +366,13 @@ export function installDBG(deps) {
// like it worked. Bare number ⇒ plan id (unchanged, back-compatible); `'g:<n>'` ⇒ godverse id, // like it worked. Bare number ⇒ plan id (unchanged, back-compatible); `'g:<n>'` ⇒ godverse id,
// explicitly. When a bare number matches BOTH, we say so in the return rather than pick quietly. // explicitly. When a bare number matches BOTH, we say so in the return rather than pick quietly.
enterShop(sel) { enterShop(sel) {
const shops = plan.shops || []; const r = resolveShop(sel);
let s = null, ambiguousWith = null; if (r.error) return r;
if (typeof sel === 'number') { const s = r.shop;
s = shops.find((x) => x.id === sel) || null;
const g = shops.find((x) => x.godverseShopId === sel) || null;
if (s && g && g !== s) ambiguousWith = { godverseShopId: sel, name: g.name };
if (!s) s = g;
} else if (typeof sel === 'string') {
const q = sel.trim().toLowerCase();
const gm = /^g(?:odverse)?:(\d+)$/.exec(q);
if (gm) {
const n = Number(gm[1]);
s = shops.find((x) => x.godverseShopId === n) || null;
if (!s) return { error: `no shop with godverseShopId ${n}` };
} else {
s = shops.find((x) => String(x.id) === q) // id-as-string
|| shops.find((x) => (x.name || '').toLowerCase() === q) // exact name
|| shops.find((x) => (x.type || '').toLowerCase() === q) // registry type
|| shops.find((x) => (x.name || '').toLowerCase().includes(q)); // name substring
}
}
if (!s) return { error: `no shop matching ${JSON.stringify(sel)}` };
enterShop(s.id, s.name); enterShop(s.id, s.name);
const out = { entered: s.id, name: s.name, type: s.type, const out = { entered: s.id, name: s.name, type: s.type,
godverseShopId: s.godverseShopId || null }; godverseShopId: s.godverseShopId || null };
if (ambiguousWith) out.ambiguous = `${sel} is also the godverseShopId of "${ambiguousWith.name}" ` + if (r.ambiguousWith) out.ambiguous = ambiguityNote(sel, r.ambiguousWith, 'entered');
`— entered by PLAN id; use 'g:${sel}' for the godverse shop`;
return out; return out;
}, },
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; }, exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },

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
@ -47,9 +54,58 @@ export function godverseBaseFor(shop) {
return id ? `assets/stock_godverse/${id}/` : undefined; return id ? `assets/stock_godverse/${id}/` : undefined;
} }
// [Lane F R27 §2 — THE TIER-2 READER] Live stock, per C's §8 ruling: direct wire, no lifecycle hook.
// F already holds `currentAdapter` and hands it to `dig.open()` per dig, so a fresh read is picked up by
// the NEXT dig for free. The read is fire-and-forget: if it hasn't landed, hasn't finished, or failed,
// the adapter is simply the tier-1 pack. THE DIG NEVER BLOCKS ON THE NETWORK (charter risk #1).
//
// What it may touch is deliberately tiny, and C's §8.3 is the reason: `items[]` identity and ORDER are
// the pick's stability. So we set `pack.gone` (C picks from the full list and omits after — filtering the
// array instead reshuffles the crate: C measured ONE sale changing 21 other records) and we update
// `price` IN PLACE. We never add, remove, or reorder items[]. Live values are consumed at pick time and
// never persisted — no golden, no seeded stream (charter law #2, the determinism boundary sits here).
const LIVE_TIMEOUT_MS = 1200; // beyond this the shop is tier 1 for this dig; the player never waits
const _liveCache = new Map(); // godverseShopId → { etag, gone:Set, prices:Map } (etag-cached per §4)
let _liveDown = false; // circuit breaker: one failure and we stop asking until reload
async function readLiveStock(base, shop, pack) {
if (_liveDown || !base || !shop.godverseShopId) return null;
const id = shop.godverseShopId;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), LIVE_TIMEOUT_MS);
try {
const prev = _liveCache.get(id);
const r = await fetch(`${base}/godverse/v1/shop/${id}/stock`,
{ signal: ctl.signal, headers: prev ? { 'If-None-Match': prev.etag } : {} });
if (r.status === 304 && prev) { applyLive(pack, prev); return prev; }
if (!r.ok) return null; // 404 = not a real shop. Tier 1. Silently.
const d = await r.json();
const live = { etag: d.etag, gone: new Set(d.gone || []),
prices: new Map((d.items || []).map((i) => [i.id, i.price])) };
_liveCache.set(id, live);
applyLive(pack, live);
return live;
} catch (e) {
// Dead / unreachable / slow / aborted → tier 1 EXACTLY, and we stop asking. The offline law is not
// a fallback path here, it is the same path: the pack was already complete before we asked.
_liveDown = true;
return null;
} finally { clearTimeout(timer); }
}
function applyLive(pack, live) {
if (!pack) return;
pack.gone = live.gone; // C omits these AFTER the draw (§8.2)
for (const it of (pack.items || [])) { // live price, IN PLACE (§8.3) — no reshuffle
const p = live.prices.get(it.id);
if (p != null) it.price = p;
}
}
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, game = null }) {
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07'); scene.background = new THREE.Color('#0e0b07');
@ -65,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();
@ -78,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); });
@ -90,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.
@ -104,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
@ -141,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 {
@ -148,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
}); });
} }
@ -214,13 +358,28 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently. // for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently.
const gBase = stockReal ? godverseBaseFor(shop) : undefined; const gBase = stockReal ? godverseBaseFor(shop) : undefined;
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null; currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null;
// [R27 §2] Tier 2, fire-and-forget. ONLY for a real-sourced shop: G's fence 404s a mint shop, and
// asking anyway would be R26's blind probe wearing a server. The manifest already told us which is
// which, so we ask only where an answer exists. No await — the room is already built and complete.
if (stockReal && gBase && liveBase && sourcingOf && sourcingOf(shop.godverseShopId) === 'real') {
const pack = getStockPack(shop.type, gBase);
if (pack) readLiveStock(liveBase, shop, pack);
}
// [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has // [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
// 24 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass // 24 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
// opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior // opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior
// (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);
@ -271,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;
} }
@ -331,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) {
@ -356,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.

BIN
web/models/peds/look.glb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.