Lane A round 9: freeze CityPlan v2 contract in CITY_SPEC + closing-time occupant ruling

Ship round. Docs + one selfcheck log-line only; no generator change ⇒ all three goldens
unchanged (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510).

1. CITY_SPEC v2 contract FROZEN — new "CityPlan v2 — frozen producer contract" section: producer
   API, goldens table (keyed seed/plansrc/town) + how to pin a new one, town-key mechanism, osm
   normalization rules, hours/openLate/storeys laws. Matches code as shipped (F diffs claims vs
   code). Refreshed the stale round-6 Plan-sources blockquote to multi-town reality. Layer-1
   changes now require a CITY_SPEC amendment + golden re-pin in the same commit.
2. Recipe sanity — DRY-RAN end-to-end: appended a throwaway Newtown (42 shops), selfcheck auto-ran
   the full parity suite (1931/1932, only golden unpinned) and printed a paste-ready
   `newtown: 0x9045e577,`. Reverted; no 3rd town shipped. Added the selfcheck `⚠ UNPINNED — add to
   OSM_GOLDENS` line so the recipe is mechanical; fixed the recipe wording to match.
3. Closing-time occupant ruling (D's on-call Q) — ratifies + freezes what D's sim.js already does:
   half-open hours, no new entry when closed, occupants DRAIN (finish bounded dwell) never popped,
   player not force-ejected, openLate video is the night crowd. In CITY_SPEC + LANE_A_NOTES. No
   code change asked of D.

qa.sh --strict GREEN; selfcheck 1727/1727.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 10:56:19 +10:00
parent 34145e24a7
commit e5d4679471
4 changed files with 135 additions and 10 deletions

View File

@ -1,9 +1,35 @@
# LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 28 closed** (see below).
*Status: **all deliverables landed; self-check all-green (1727/1727); rounds 29 closed** (see below).
`qa.sh --strict` GREEN.*
*Date: 2026-07-15 · owner: PROCITY-A (Opus 4.8)*
## Round 9 (2026-07-15) — freeze the v2 contract + closing-time ruling (ship round)
Small round; the most "done" lane makes it official.
1. **CITY_SPEC v2 contract FROZEN** — added a "CityPlan v2 — frozen producer contract" section: the
producer API (`generatePlanFor`/`generatePlan`/`generatePlanOSM`/`osmTownKeys`/`chunkIndex`), the
goldens table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`, keyed
(seed,plansrc,town)), the town-key mechanism, osm normalization rules, and the
hours/openLate/storeys laws — all matching code as shipped (F diffs claims vs code in review).
Refreshed the stale round-6 "Plan sources" blockquote to the multi-town reality. Layer-1 changes now
require a CITY_SPEC amendment + golden re-pin in the same commit.
2. **Recipe sanity — dry-ran end-to-end** (not just eyeballed): appended a throwaway Newtown (42 shops)
to `OSM_TOWNS`, ran selfcheck → the full parity suite auto-covered it (1931/1932, only the golden
unpinned) and printed a ready-to-paste `newtown: 0x9045e577,` line. Reverted; no 3rd town shipped
(didn't fall out free). Improved selfcheck to print that `⚠ UNPINNED — add to OSM_GOLDENS` line so
the recipe is genuinely mechanical; fixed the recipe wording to match.
3. **Closing-time occupant ruling** (D's on-call question) — ratifies what D's `sim.js` already does and
freezes it: half-open hours, no new entry when closed, **occupants drain (finish their bounded
dwell) — never popped**, player not force-ejected, openLate video is the night destination. In
CITY_SPEC (contract law) + `LANE_A_NOTES.md` (for D). No code change asked of D.
Self-check **ALL GREEN 1727/1727**; `qa.sh --strict` GREEN. Only doc + one selfcheck log-line touched;
no generator change ⇒ all three goldens unchanged.
## Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED)
## Round 8 (2026-07-15) — OSM to parity + a second town (carried R7 brief, FULLY LANDED)
The carried brief (slipped R7) is done — all four items, not a partial.

View File

@ -101,22 +101,88 @@ Schema v1 (Lane A owns it; extend, don't break):
> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite
> numbers, no stray keys).
> **Plan sources (round 6).** A CityPlan can come from two producers behind this one contract, chosen
> via `generatePlanFor(seed, source)` (`web/js/citygen/index.js`) — the shell selects with `?plansrc=`:
> **Plan sources.** A CityPlan can come from two producers behind this one contract, chosen via
> `generatePlanFor(seed, source, { town })` (`web/js/citygen/index.js`) — the shell selects with
> `?plansrc=` (+ `&town=` for osm):
> - `'synthetic'` (default) — `generatePlan(seed)`, the procedural town. Byte-identical, golden `0x3fa36874`.
> - `'osm'``generatePlanOSM(seed)`, a real-data import from a **checked-in fixture** (`osm_fixture.js`,
> real AU shops from thriftgod's OSM cache; **zero network**). Golden `0x34cfdec0`.
> - `'osm'``generatePlanOSM(seed, town)`, a real-data import from a **checked-in fixture**
> (`osm_fixture.js` → `OSM_TOWNS`; real AU shops from thriftgod's OSM cache; **zero network**).
> Towns: `melbourne` (default, golden `0x34cfdec0`), `katoomba` (`0x0f652510`). `osmTownKeys()` lists them.
>
> An `'osm'` plan sets `source:'osm'`, reports its **real bounding-box `size`** (not 1024²), and contains
> **only real retail types** — no synthetic market square, arcade, dept anchor, milk bars, or stalls.
> All structural invariants above hold identically. Consumers that assume a synthetic-only feature
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite is parameterized by source.
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite runs on every (source, town).
> **Full frozen v2 producer contract below** (§"CityPlan v2 — frozen producer contract").
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture,
footpath NPCs) resolves to a chunk whose `edges` list includes its edge.
## CityPlan v2 — frozen producer contract
> **FROZEN at `v2.0` (round 9).** This is the authoritative Layer-1 contract — the v3 epoch
> (editor / override layer) reads THIS. It matches the code as shipped; `web/js/citygen/selfcheck.js`
> enforces every law here. Changes are a **CITY_SPEC amendment in the same commit + a golden re-pin**,
> never a silent drift. Import everything from **`web/js/citygen/index.js`** (the barrel) — internals
> (`plan.js`, `plan_osm.js`, `osm_fixture.js`, `names.js`) may move behind it.
**Producer API** (all from `web/js/citygen/index.js`):
- `generatePlanFor(seed, source = 'synthetic', { town } = {}) → CityPlan` — the selector the shell uses.
- `generatePlan(seed) → CityPlan` — synthetic producer (unchanged, byte-identical).
- `generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan` — osm producer; pass a
`report` object to receive the normalization log (below). `osmTownKeys() → string[]`.
- `chunkIndex(plan, chunkSize = 64) → { chunkSize, chunks }`; `CHUNK = 64`; `chunkKey(cx, cz)`.
- Geometry helpers (shared with the harness): `lotCorners(lot)`, `obbOverlap(a, b)`.
**Determinism & goldens.** Same inputs ⇒ byte-identical plan, forever. The determinism gate keys on
**(seed, plansrc, town)**. Committed goldens (`xmur3(JSON.stringify(plan))` at seed `20261990`):
| source / town | golden |
|--------------------|--------------|
| synthetic | `0x3fa36874` |
| osm / melbourne | `0x34cfdec0` |
| osm / katoomba | `0x0f652510` |
*Adding a town* is mechanical (recipe in `docs/LANES/LANE_A_NOTES.md`): append to `OSM_TOWNS`, run
selfcheck, paste the printed fingerprint into `OSM_GOLDENS`, tell Lane F the new hash.
**Trading-hours law.** `shop.hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. Openness is
**half-open**: `isOpen(shop, h) ⟺ open ≤ h < close` (a shop closing at 17 is shut at 17:00 exactly).
Both the shell (`index.html`) and the NPC sim (`citizens/sim.js _openAt`) use this identical predicate.
**openLate law.** **Exactly one** shop per town is the late-night landmark. It is the sole shop that
closes `≥ 22:00` (`LATE_HOUR`), so the field and the threshold are equivalent: `{s: s.openLate}`
`{s: s.hours[1] ≥ 22}` (a singleton). It is the **video rental** (a town with no video falls back to
another non-stall type, logged). Every other shop closes by 21:00 — so after ~21:00 the streets thin
to that one lit destination.
**Storeys law.** `shop.storeys ∈ [registryMin, permittedMax]`, `permittedMax = registryMax ≥ 2 ?
min(registryMax + 1, 3) : registryMax`. The occasional 3-storey corner anchor adds ≤1 storey (tall
types only); single-storey types (`video`/`milkbar`/`stall`, `[1,1]`) are **never** boosted.
**OSM normalization rules** (the importer bends the *data* to the contract, never the contract; each
change is logged into `report`): unknown OSM `shop=` kind → `opshop` (`report.typesRemapped`); any shop
closing `≥ 22:00` → clamped to 21:00 so only the one openLate landmark is late (`report.hoursClamped`);
the openLate landmark prefers a video, else a fallback non-stall type (`report.openLate`). Shop `name`s
are the real OSM names; `sign` is derived. `osm` plans carry `source:'osm'`, a real bbox `size`, and no
synthetic dept/market/milkbar/stall/arcade.
**Closing-time occupant ruling (Lane A, round 9 — ratifies Lane D's `sim.js`).** When a shop closes:
1. **No new entry.** Patronage only ever routes a ped into an *open* shop (`_nearestOpenShop` gates on
`_openAt`); the player likewise gets a CLOSED plate/toast and cannot enter (`index.html`).
2. **Occupants drain, they don't pop.** A ped already inside when the shop closes finishes its current
bounded visit (seeded 520s dwell) and re-emerges through the door normally — it is **never**
teleported out or culled at the close instant. Occupancy therefore drains to 0 within one dwell
window; `sim.occupancyOf(shopId)` falls to 0 on its own. Any browser-rig presence (Lane D/F) tracks
`occupancy`, so it clears with the same drain — no orphaned rigs in a shut shop.
3. **The player is never force-ejected.** Closing locks the door to *entry* only; a player already inside
stays until they choose to leave (leaving restores the street where they stood, as always).
4. **The openLate video shop** is the one place still filling after ~21:00 (the night crowd); at its own
close (22:00/23:00) it drains identically. Determinism holds: entry/dwell/emerge are seeded + clock
driven, so same (seed, time) ⇒ same occupancy.
### Layer 2 — Streetscape (Lane B)
Realizes CityPlan chunks around the player. Hard requirements the two parent games skipped:

View File

@ -2,6 +2,31 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`.
## Round 9 (2026-07-15)
### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it)
Question: what happens to a ped browsing inside a shop when it hits closing time? **Ruling** (now a
frozen contract law — CITY_SPEC §"CityPlan v2 → Closing-time occupant ruling"):
1. **No new entry when closed.** Openness is half-open: `open ≤ h < close` (a shop closing at 17 is shut
at 17:00). Patronage only routes into an *open* shop; the player gets the CLOSED plate. ✓ your
`sim.js _openAt` + `_nearestOpenShop` already do exactly this.
2. **Occupants drain, they don't pop.** A ped inside at close finishes its bounded 520s dwell and
re-emerges normally — **never** teleported/culled at the close instant. `occupancyOf(shopId)` falls
to 0 within one dwell window on its own, so browser rigs (the C→D→F seam) clear with the same drain.
✓ your `patronTimer`/`_emerge` already do this — do NOT add a hard close-time eviction.
3. **Player is never force-ejected** — closing locks *entry* only; a player inside stays until they leave.
4. **The openLate video shop** is the one place still filling after ~21:00; at its own close it drains
identically. All seeded + clock-driven ⇒ deterministic occupancy.
No code change requested of you — this pins the behavior so it can't drift, and documents it for v3.
### → everyone / F: the CityPlan v2 producer contract is FROZEN
CITY_SPEC now has a **"CityPlan v2 — frozen producer contract"** section: producer API, the goldens
table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`), the town-key
mechanism, osm normalization rules, and the hours/openLate/storeys/closing laws — all matching code
(F: diff away). Changes to Layer-1 now require a CITY_SPEC amendment + golden re-pin in the same commit.
## Round 8 (2026-07-15)
### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg.
@ -44,10 +69,16 @@ Real OSM data that fights a CityPlan contract is normalized deterministically, a
`key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops
by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book,
music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn).
3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`, read the printed
`osm/<key>: fingerprint 0x…`, add `<key>: 0x…` to `OSM_GOLDENS` in `selfcheck.js`. Re-run → green.
3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste
line — `⚠ osm/<key>: UNPINNED — add to OSM_GOLDENS → <key>: 0x…,` — paste it into `OSM_GOLDENS`
in `selfcheck.js`, re-run → green. (The full parity suite already ran on the town; only the golden
needed pinning.)
4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the
full parity suite covers it automatically.
full parity suite covers the town automatically.
*Dry-run (round 9): verified end-to-end with a throwaway Newtown (42 shops) — the parity suite auto-ran
(1931/1932, only the golden unpinned) and selfcheck printed `newtown: 0x9045e577,`. Reverted; no 3rd
town shipped (didn't fall out "for free"). The recipe is mechanical as written.*
## Round 6 (2026-07-14)

View File

@ -197,7 +197,9 @@ for (const town of osmTownKeys()) {
}
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, town)))() >>> 0;
const want = OSM_GOLDENS[town];
ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches 0x${((want ?? 0) >>> 0).toString(16)}`);
// Unpinned town (just added via the "add a town" recipe) → tell the human the exact line to paste.
if (want === undefined) console.log(` ⚠ osm/${town}: UNPINNED — add to OSM_GOLDENS → ${town}: 0x${hash.toString(16).padStart(8, '0')},`);
ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches pinned 0x${((want ?? 0) >>> 0).toString(16)}`);
console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`);
}