Compare commits

...

3 Commits

Author SHA1 Message Date
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
10 changed files with 328 additions and 5 deletions

View File

@ -4,6 +4,21 @@
--- ---
## 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) ## 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 ☆ WANT on the pull card → `wants` on procity-save/1 (optional, capped, deduped); buying a

View File

@ -26,6 +26,7 @@ Click **START** to lock the mouse. Then:
| **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 | | **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) | | 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 | | **☆ 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) | | 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) | | walk out the door / **Esc** | leave the shop (Esc also frees the mouse anywhere) |
| **M** | 2D town map (street only) | | **M** | 2D town map (street only) |

View File

@ -1845,3 +1845,28 @@ m3ultra. If a third machine joins the rotation, sync that dir (49 M) or re-run E
Green gates missed it because every gate's boot idles on the street long enough to build. 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 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. 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).

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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

View File

@ -2253,6 +2253,110 @@ def smoke_wantlist_travel(p):
b.close() b.close()
def smoke_godbay(p):
"""R34 gate — GODBAY, the offline seeded auction. Legs: (a) ⚖ LIST through the real crate DOM →
the entry leaves the collection into save-carried listings; (b) sleep resolved: listings empty,
cash+net (bounded [1, 2×high×0.88 + slack]) XOR the item came home, the wake toast carries the
morning paper; (c) THE ONE-HAMMER DETERMINISM: a pre-sleep export imported and slept TWICE lands
byte-identical cash/collection both times; (d) travel resolves too (list ride hammered on
arrival); (e) ?game=0 builds no crate at all (nothing to list from)."""
head('GATE R34: GODBAY — the offline auction (one hammer · bounded · morning paper · controls)')
BANDHI = {'record': {'bargain': 7, 'standard': 24, 'collector': 59, 'grail': 120},
'book': {'bargain': 4, 'standard': 14, 'collector': 39, 'grail': 80},
'toy': {'bargain': 5, 'standard': 19, 'collector': 49, 'grail': 100}}
b, pg, errs = new_page(p)
try:
boot(pg, '')
r0 = _enter_record_shop(pg)
if r0.get('err'): FAIL(f"godbay: {r0['err']}"); return
rich = pg.evaluate("""() => { const g = window.PROCITY.game;
const ex = JSON.parse(g.export()); ex.cash = 5000; ex.savedAt = Date.now();
return g.import(JSON.stringify(ex)); }""")
if not rich: FAIL('godbay: bankroll import failed'); return
pg.evaluate("() => window.DBG.setSegment(2)")
for i in range(2): # two finds: one to list now, one for the travel leg
_at_bin(pg)
rb = _dig_buy(pg)
if rb.get('err') or rb.get('disabled'): FAIL(f"godbay: buy {i + 1} failed — {rb}"); return
# (a) ⚖ LIST through the real crate DOM
rl = pg.evaluate("""() => {
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
const g = window.PROCITY.game, n0 = g.collection.length;
const lb = document.querySelector('#pc-crate .pc-crate-row div[title*="GodBay"]');
if (!lb) return { err: 'no ⚖ LIST control in the crate' };
lb.click();
const ex = JSON.parse(g.export());
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
return { n0, n1: g.collection.length, listings: (ex.listings || []).length,
entry: (ex.listings || [])[0] || null }; }""")
if rl.get('err'): FAIL(f"godbay: {rl['err']}"); return
if rl['n1'] == rl['n0'] - 1 and rl['listings'] == 1 and rl['entry'] and rl['entry'].get('listDay'):
OK(f"godbay: consigned through the crate — collection {rl['n0']}{rl['n1']}, "
f"the save carries the listing (listDay {rl['entry']['listDay']})")
else:
FAIL(f"godbay: listing did not take — {rl}"); return
# (c) THE ONE-HAMMER DETERMINISM: freeze pre-sleep, sleep twice from the same frozen state
pre = pg.evaluate("() => window.PROCITY.game.export()")
outcomes = []
for _ in range(2):
pg.evaluate("(ex) => window.PROCITY.game.import(ex)", pre)
pg.evaluate("() => { document.getElementById('pc-sleep-btn').click(); }")
pg.wait_for_timeout(250)
toast = pg.evaluate("() => (document.getElementById('pc-toast') || {}).textContent || ''")
st = pg.evaluate("""() => { const g = window.PROCITY.game;
const ex = JSON.parse(g.export()); delete ex.savedAt;
return { cash: g.cash, n: g.collection.length, nl: g.listings.length, ex: JSON.stringify(ex) }; }""")
st['toast'] = toast
outcomes.append(st)
a, b2 = outcomes
if a['ex'] == b2['ex'] and a['cash'] == b2['cash']:
OK('godbay: ONE HAMMER — the same frozen state slept twice lands byte-identical both times')
else:
FAIL(f"godbay: hammer not deterministic — {a['cash']} vs {b2['cash']}")
# (b) resolution honesty + bounds + the morning paper
pre_cash = json.loads(pre)['cash']; pre_n = len(json.loads(pre)['collection'])
ent = rl['entry']; hi = BANDHI.get(ent.get('type'), BANDHI['record']).get(ent.get('band'), 120)
sold = a['cash'] > pre_cash; came_home = a['n'] == pre_n + 1
net = a['cash'] - pre_cash
if a['nl'] == 0 and (sold != came_home) and (not sold or 1 <= net <= int(2.0 * hi * 0.88) + 2):
OK(f"godbay: resolved — {'SOLD net $' + str(net) + f' (≤ 2×${hi}×0.88 bound)' if sold else 'passed in, came home'}"
f" · listings drained")
else:
FAIL(f"godbay: resolution dishonest — nl={a['nl']} sold={sold} home={came_home} net={net} hi={hi}")
if '' in a['toast']:
OK(f"godbay: the wake toast carried the morning paper — {a['toast'][:90]}")
else:
FAIL(f"godbay: no ⚖ in the wake toast — {a['toast'][:90]!r}")
# (d) travel resolves too: list the second find, ride to melbourne
r2 = pg.evaluate("""() => {
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
const g = window.PROCITY.game;
const lb = document.querySelector('#pc-crate .pc-crate-row div[title*="GodBay"]');
if (!lb) return { err: 'no ⚖ for the travel leg (collection empty?)' };
lb.click();
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyC' }));
return { cash: g.cash, nl: g.listings.length, n: g.collection.length }; }""")
if r2.get('err'): FAIL(f"godbay/travel: {r2['err']}")
else:
boot(pg, 'plansrc=osm&town=melbourne')
t = pg.evaluate("""() => { const g = window.PROCITY.game;
return { nl: g.listings.length, cash: g.cash, n: g.collection.length, trav: g.traveled }; }""")
if t['trav'] and t['nl'] == 0 and (t['cash'] > r2['cash'] or t['n'] == r2['n'] + 1):
OK(f"godbay/travel: the ride to melbourne hammered the listing (cash ${r2['cash']}→${t['cash']}, "
f"collection {r2['n']}{t['n']})")
else:
FAIL(f"godbay/travel: {r2}{t}")
# (e) ?game=0 — no crate, nothing to list from
boot(pg, 'game=0')
g0 = pg.evaluate("() => ({ crate: !!document.getElementById('pc-crate'), game: !!window.PROCITY.game })")
if not g0['crate'] and not g0['game']: OK('godbay/control: ?game=0 — no game, no crate, no auction house')
else: FAIL(f"godbay/control: game=0 leaked — {g0}")
if errs: FAIL(f"godbay: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK('godbay: 0 console errors')
finally:
b.close()
def smoke_classic_purity(p): def smoke_classic_purity(p):
"""R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any """R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any
page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the
@ -2408,6 +2512,7 @@ def main():
smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding) smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding)
smoke_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control smoke_bible(p) # R32 — THE BIBLE (bands · gems · ledger) + per-day scarcity + ?game=0 control
smoke_wantlist_travel(p) # R33 — wantlist · keeper rumor · travel-costs-a-day (+ controls) smoke_wantlist_travel(p) # R33 — wantlist · keeper rumor · travel-costs-a-day (+ controls)
smoke_godbay(p) # R34 — GODBAY offline auction (one hammer · bounded · morning paper)
smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1) smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1)
smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path) smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
finally: finally:

View File

@ -264,7 +264,9 @@ const game = GAME_ON ? createGame({
onDay: (day) => { onDay: (day) => {
if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day 1) % 7 — see the seam note below if (gigState) gigState.setWeekNight(weekNightOf(day)); // (day 1) % 7 — see the seam note below
lighting.setSegment(0); lighting.setSegment(0);
hud.showToast(`🌅 day ${day}`); // [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; }) : null;
const wallet = game ? game.wallet : baseWallet; const wallet = game ? game.wallet : baseWallet;
@ -283,7 +285,13 @@ const weekNightOf = (day) => (Math.max(1, day) - 1) % 7;
if (game && gigState) gigState.setWeekNight(weekNightOf(game.day)); 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 // [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. // adopt() did the increment + pull-pruning; weekNight above already read the incremented day). Tell John.
if (game && game.traveled) hud.showToast(`🚌 ${plan.name || 'a new town'} — the ride cost a day. Day ${game.day}`, 4); // [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);
}
// [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,

View File

@ -200,7 +200,34 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
const meta = document.createElement('div'); meta.className = 'pc-crate-meta'; const meta = document.createElement('div'); meta.className = 'pc-crate-meta';
meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`; meta.textContent = `paid $${e.pricePaid} · ${foundAt(e, game)} · day ${e.dayFound}`;
main.append(t, a, meta); main.append(t, a, meta);
row.append(th, main); panel.appendChild(row); 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 // [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. // clears it automatically). Section exists only when non-empty — no stray header over nothing.
@ -245,8 +272,8 @@ export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }
shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`; shownCount = col.length; btnCrate.textContent = `CRATE ${shownCount}`;
if (open) rebuildList(); if (open) rebuildList();
} }
const nw = (game.wants || []).length; // [R33] want add/clear refreshes an open panel too const nw = (game.wants || []).length + (game.listings || []).length * 100; // [R33/R34] wants OR
if (nw !== shownWants) { shownWants = nw; if (open) rebuildList(); } 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 // [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. // everywhere else — street AND interior. Cheap: a style write only when the answer changes.
const P = window.PROCITY, im = P && P.interiorMode; const P = window.PROCITY, im = P && P.interiorMode;

View File

@ -18,6 +18,33 @@
export const SAVE_SCHEMA = 'procity-save/1'; export const SAVE_SCHEMA = 'procity-save/1';
export const SAVE_KEY = 'procity-save'; 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) ───────────────────────────── // ── 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 // 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 // check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never
@ -50,6 +77,15 @@ export function validateSave(obj) {
for (let i = 0; i < obj.pulls.length; i++) 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` }; 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 // [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. // least one present per entry — a want with nothing to hunt is malformed, not empty-but-fine.
if (obj.wants != null) { if (obj.wants != null) {
@ -80,6 +116,9 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
// [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried. // [R33] the wantlist — what you're hunting ({artist?|title?|sku?}); capped, deduped, save-carried.
let wants = []; let wants = [];
const WANTS_CAP = 40; 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. // [R33 travel] true when THIS boot adopted a save from a DIFFERENT town — the ride cost a day.
let traveled = false; let traveled = false;
// [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER // [R32 scarcity] pull records — one string per bought dig slot, keyed by the CONSUMER
@ -94,8 +133,30 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
const p = { schema: SAVE_SCHEMA, day, cash, town: townKey, collection, savedAt: Date.now() }; 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 (pulls.length) p.pulls = pulls; // absent when empty → alpha-era byte shape preserved
if (wants.length) p.wants = wants; // [R33] same convention if (wants.length) p.wants = wants; // [R33] same convention
if (listings.length) p.listings = listings; // [R34] same convention
return p; 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() { function save() {
if (!store) return false; if (!store) return false;
try { store.setItem(SAVE_KEY, JSON.stringify(payload())); return true; } try { store.setItem(SAVE_KEY, JSON.stringify(payload())); return true; }
@ -114,6 +175,8 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
// byte-equality survives untouched. Day moves BEFORE prunePulls so yesterday's-town day-tagged // 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). // 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; } 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(); prunePulls();
} }
@ -188,6 +251,7 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
// save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into. // save (noted vs the brief's literal order in §30.1): disk always carries the morning you woke into.
sleep() { sleep() {
day += 1; 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 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); } } if (onDay) { try { onDay(day); } catch (e) { console.warn('[procity save] onDay hook threw:', e); } }
save(); save();
@ -293,6 +357,27 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
notify(); notify();
return true; 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. // boot: adopt an existing save (fresh start on absence/rejection), then arm the unload save-point.