diff --git a/B-progress.md b/B-progress.md index 115a284..8e5bd08 100644 --- a/B-progress.md +++ b/B-progress.md @@ -4,6 +4,42 @@ 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. +## Round 39 (v9 Layer 2 — THE FOG, item 39.2) — **zero draws, and the map is finally readable** + +Full detail + every number in LANE_B_NOTES §39. `minimap.js` drew all 493 synthetic shops from the +first frame; it now draws what has been walked past and **frames itself to it**. New +`web/js/world/discovery.js`, `minimap.js` rewritten, `save.js` gains the ledger, the shell wires it. + +- **ZERO DRAWS, measured as an A/B** (two port-isolated no-store servers, HEAD vs treatment, same 15 + stations × 4 yaws computed from the plan and compared before either sweep runs, sim + tram hidden + identically): **draw calls identical on 60 of 60 samples at noon and at night.** And map mode cannot + cost a draw *structurally* — the frame loop never calls `composer.render()` in the map branch, so the + counter reads the same number open (131) as closed (131). +- **Legibility.** A shop lot goes from **0.83×1.11 px to 29.4×39.2 px on bowral_real — 35.3×** + (launceston 27.6×, castlemaine 21.3×, katoomba 20.8×, adelaide 16.9×, fitzroy 11.1×, synthetic 4.0× + at 21×41). Reproduces Fable's binding table to the digit. The frame is **the shops you have stood in + front of + where you stand** — framing on walked *edges* was my own first cut and it blew the frame to + 340 m at spawn, because one synthetic edge is 400 m long. +- **The rule: 25 m of the shopfront AND in front of it**, probed off a plan-derived spatial hash every + 6th street frame (**0.46 m of travel at WALK**), never off `onChunkBuilt` (R=2 ⇒ 128 m). **1.09–1.37 + µs a probe** — 0.0014% of a frame. Falsifiability control with a red arm: walking the synthetic's two + service laneways, radius-only reveals 93 shops, the shipped probe 59 — **34 refused through the back + wall**; on the arcade it refuses 0 of 17. +- **The save.** Optional `known` block, keyed by TOWN (I re-derived the seed-invariance: 23 caches × + 3 seeds, shop ids / edge ids / lot geometry all identical; synthetic alone keys `@`). Bounded + by the plan (max 493 shops, 2,593 edges) with a 8,192 cap, 64 towns FIFO, ints only. **Worst case + 160 KB for the entire corpus fully learned; ~300 B after a 500 m outing.** Eight reject arms + demonstrated, live state untouched on reject, foreign-town fog does not leak. +- **Classic by construction:** `createMinimap(plan, null)` is the pre-R39 map and the file has **no flag + test**. `?classic=1` / `?fog=0` / `?game=0` map canvases are **hash-identical to HEAD's**. +- **Lane A's address layer consumed** — street names along walked streets, deduped by name; the + synthetic gets district labels from the same line. No town-type branch anywhere. +- **FILED, NOT FIXED: `index.html`'s front normal is inverted** at `:384` (Lane D patronage door points) + and `:450` (the R32 spawn). `buildings.js`, `dbg.js` and `minimap.js` all use local +Z; measured, the + shell's point sits **15.25 m behind the facade** and **0 of the shops within 25 m of the shipped spawn + are in front of the player** on all four towns tested. Two characters, but it moves the default boot's + opening pose mid-round — Fable's call. + ## Round 38 (v8 WAVE 1 — INHABITATION) — all four runtime items, **+1 walked draw for the lot** Full detail + every number in LANE_B_NOTES §38. Measured as a true A/B: two port-isolated no-store diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index dd48eeb..397106a 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -1,5 +1,222 @@ # LANE B — NOTES (measured) +## Round 39 (v9 Layer 2 — **THE FOG**, item 39.2) — the map costs what it always cost: nothing + +**`minimap.js` drew every lot in the plan from the first frame. It now draws what you have walked past, +and frames itself to it.** Zero draws, zero triangles, zero plan fields, one optional save block. +New file `web/js/world/discovery.js`; `minimap.js` rewritten; `save.js` gains the ledger; the shell +wires it. **`?fog=0` / `?game=0` / `?classic=1` are the pre-R39 map pixel-for-pixel** — proven below, +not asserted. + +### The zero, measured rather than claimed +Two **port-isolated no-store servers** — `:8592` serving `git archive HEAD web/js web/index.html` +(assets/vendor symlinked from the live tree so the other lanes' mid-round waves are identical on both +sides), `:8591` serving the treatment — driven through the shell's own composer at 1600×1000. The A/B +walks **the same 15 stations × 4 yaws**, computed from the plan on each build and compared as JSON +before either sweep runs, so a station list that drifted would fail the run rather than quietly make +the comparison meaningless. **The citizen sim and the tram are hidden identically on both sides** +(R38's recorded trap, obeyed). + +| synthetic, 60 station×yaw samples per arm | HEAD | R39 | delta | +|---|---|---|---| +| default boot, **noon** — worst draw frame | 243 | **243** | **0 on 60/60 samples** | +| gig layer off (`&gigs=0&roster=v1`), **noon** | 235 | **235** | **0 on 60/60** | +| gig layer off, **NIGHT** — the budget's worst frame class | 235 | **235** | **0 draws AND 0 triangles on 60/60** | +| triangles, every arm | — | — | **never higher on the treatment: 0 samples of 240 above zero** | +| map mode (`M` held open) | — | — | **the counter does not move at all** | + +The night arm is the one the budget cares about (F's pin is a night frame) and it is **exactly +identical in both counters**. At noon the draw calls are identical too and the triangle delta is +**≤ 0 in every sample** — 13 of 60 samples where HEAD drew *more*, which is a moving town measured +twice, not a treatment that adds geometry. The fog never touches the scene graph, so the only honest +direction for a difference is the one that showed up: never positive. + +**Map mode cannot cost a draw, and the reason is structural, not careful:** `index.html`'s frame loop +never calls `composer.render()` in the `map` branch (it only ticks the HUD and paints the 2D canvas), +so `renderer.info.render.calls` reads **the same number with the map open as with it closed** — 131 and +131 at the same pose in the measured run. The map is a canvas over the WebGL canvas and `hud.js:361`'s +counter never sees it. + +**Two measurement bugs I made, both caught by my own numbers rather than by a gate.** +1. The harness called `lighting.togglePaused()` once per segment, so the second call **un-paused** the + clock. Fixed with the idempotent `setPaused(true)`. +2. The night arm then still read **−16 to −37 draws / +8,346 to +18,249 tris**, deterministically, on a + change that adds no geometry. It is the **gig layer**: at NIGHT `gigState` opens the doors and + `VenueQueue.spawn(scene, …)` adds 10–20 punters **straight to the scene** (not into the `citizens` + group my hide targets), and each is a placeholder primitive until the GLB fleet resolves. Fewer + draws with more triangles is exactly that swap — an async asset race between two pages, measured + twice. The night arm is therefore taken with the gig layer and the roster **off on both sides** + (`&gigs=0&roster=v1&pop=0`), which is the only night A/B in which the fog is the only variable. + *R38 recorded "hide the sim"; the queue is a second population that the same instruction misses.* + +### The framing IS the payoff — the legibility numbers +Replicating `minimap.js`'s own transform (`span = extent + 40`, `sc = (720 − 48) / span`) against the +median shop lot on each town: + +| | bowral | launceston | daylesford | castlemaine | katoomba | adelaide | fitzroy | synthetic | +|---|---|---|---|---|---|---|---|---| +| full-plan extent (today) | **0.83×1.11 px** | 1.07×1.42 | 1.19×1.58 | 1.38×1.84 | 1.42×1.89 | 1.74×2.32 | 2.65×3.54 | 5.22×10.24 | +| framed to the 240 m floor | **29.4×39.2** | 29.4×39.2 | 29.4×39.2 | 29.4×39.2 | 29.4×39.2 | 29.4×39.2 | 29.4×39.2 | 21.0×41.2 | +| **gain** | **35.3×** | 27.6× | 24.8× | 21.3× | 20.8× | 16.9× | 11.1× | 4.0× | + +Those reproduce Fable's binding table to the digit (bowral 35×, katoomba 21×, adelaide 17×, fitzroy +11×, synthetic 4×) — the `+40` margin is part of the transform and must be carried into the framed +number or the gain reads 42× instead of 35×. + +**And it is worse than "small": at the shipped scale a real town's shops do not survive rasterisation +at all.** The pixel detector counts pixels that exactly match one of the ten shop-type colours (an +exact palette, not a threshold — the vacuous-gate law's preferred instrument). On the synthetic at the +full-plan transform it reads **26,506 px**. On katoomba_real, same code, same boot, **it reads ZERO**: +a 1.42×1.89 px *rotated* rect has no fully-covered pixel, so **all 72 Katoomba shops rasterise to not +one pixel of their own colour.** Fogged and framed, the same map reads **1,902 px for the 34 shops you +walked past.** The screenshots say it faster than the numbers do: HEAD's Katoomba is 993 edges of grey +spaghetti with the shops invisible; the fogged one is a legible strip of **Katoomba Street** with +**Edwards Street** running off it, and a caption that says *34 shops known · 6 streets walked*. + +Measured live, both towns, fresh boot then a 900 m walk through the real rAF loop: + +| | synthetic | katoomba_real | +|---|---|---| +| fresh boot | 0/493 shops · 1/22 segs · frame **240 m** · lot **20.97×41.16 px** | 0/72 shops · 0/993 segs · frame **240 m** · lot **29.4×39.2 px** | +| after 900 m | 113/493 shops · 7 segs · frame 515 m · **9.77×19.18 px** | 34/72 shops · 18 segs · frame 965 m · **7.31×9.75 px** | +| the shipped map | — · frame 965 m · 5.22×10.24 px | — · frame **4,984 m** · **1.42×1.89 px** | + +**The frame is the shops you have stood in front of, plus where you are standing — NOT the streets you +have walked**, and that was a real correction to my own first cut. An edge is drawn as the whole +node-to-node line, and the synthetic's main street is one ~400 m edge, so stepping onto it at spawn +expanded the frame to **340 m before the player knew a single shop**. Framing on shopfronts holds the +fresh-boot frame at the **240 m floor** (a 200 m window plus the transform's own margin), and a walked +street that runs off the edge of the frame is clipped by the canvas — which is what a map does. + +### The proximity rule I settled on +**Within `FOG_RADIUS_M` = 25 m of the shopfront AND in front of it.** Not `chunks.onChunkBuilt` +(R=2 ⇒ the outermost live chunk centre is 128 m out), and not the live door meshes either: the probe +runs off a plan-derived spatial hash, so discovery means *you were there*, never *it happened to be +streamed*. At R=2 anything inside 25 m is always in a live chunk, so the two can never disagree — the +grid is chosen because it does not depend on streaming, not because it sees more. + +- **Cadence:** every 6th street frame, the same throttle class as `hud.js:337`'s door raycast. At WALK + 4.6 m/s / 60 fps that is a probe every **0.46 m** of travel (0.88 m at RUN) — finer than a shopfront. +- **Cost:** **1.09 µs/probe** on the synthetic, **1.37 µs** on bendigo_real (2,593 edges, 188 km of + road), 1.27 µs on fitzroy. Once every 6 frames ⇒ **0.23 µs per frame, 0.0014% of a 16.7 ms frame.** + Index build is 0.8 ms (synthetic) / 5.0 ms (bendigo), once, at boot. +- **Streets are learned too** (`e.id`, distance to the segment ≤ `width/2 + 4 m`), because "the map + shows the streets you've learned" is the round's own sentence. A lot with no shop rides its + `frontEdge`: walk the road, see the houses. A shop needs the walk-past. +- **Walking in the door discovers the shop** regardless of geometry — placed INSIDE the successful-entry + branch of `enterShop`, after the closed check and the cover charge, never on the dispatch. + +**THE SIDE TEST'S FALSIFIABILITY CONTROL, WITH A RED ARM.** Walk the synthetic's two service laneways +(560 m, behind the main-street blocks): a radius-only probe reveals **93 shops**, the shipped probe +reveals **59** — **34 shopfronts refused through the back wall.** Walk the 42 m arcade and it refuses +**0 of 17**, which is correct: an arcade is a front. Sampled over every walkable square metre inside +the radius on a 2 m grid (lot interiors excluded), the side test refuses **31.6% of the disc on the +synthetic, 40.3% katoomba, 43.4% bowral, 43.5% fitzroy** — and on a shopper's walk down the strip it +refuses **0–2**, which is exactly the shape you want: free where a shopper stands, decisive behind the +buildings. + +### THE SIGN WAS WRONG AND IT IS THE SHELL'S, NOT MINE — a finding, held, not fixed +My first cut copied `index.html`'s own front-normal and the walk read **4 shops known and 1,231 +in-radius rejections** on the synthetic — the fog hiding the town from a player standing in front of it. +The geometry says the shell is wrong: + +- `buildings.js:410-416, :583-597` build the facade, the door and the `doorRect` at local **+Z** — + `lot + (sin ry, cos ry)·d/2`. `minimap.js`'s front-edge tick has always been drawn at local +Z. + `dbg.js:53` says it in words: *"Front normal = local +Z rotated by lot.ry"*, `fx = Math.sin(ry)`. +- `index.html:384` (Lane D's patronage door points) and `index.html:450` (`doorOf`, the R32 cluster-pose + spawn) use **`(−sin ry, −cos ry)`** — the back of the building. +- Measured against each lot's **own `frontEdge`**: the +sin point is **0.00 m** from the kerb on the + synthetic and −1.50 m on katoomba (i.e. on the kerb line, where a facade belongs); the −sin point is + **15.25 m / 12.50 m** the wrong side of it. **493 of 493 and 72 of 72 shops agree.** +- The consequence, measured at the shipped spawn on four towns: **0 of the 1–3 shops within 25 m are in + front of you.** At the corrected spawn: **all of them are** (synthetic 4/4, katoomba 4/4, bowral 4/4, + fitzroy 1/1). R32's own comment says the spawn stands *"ON the footpath door-line"*; the code stands + in the back lane, and the fresh-boot fog map is the picture of it — one street, its yards, no shops. +- **And in a picture, live, both towns** (`spawn-*-SHIPPED.png` vs `spawn-*-FRONT-NORMAL.png`): the + shipped spawn stands on a footpath facing an empty plaza and a retaining wall with **not one shopfront + in frame**, and the fog reports **0 shops known after 2.5 s of standing there**; recompute the same + pose with `+sin` and the player is **under a shop awning with the sign at their shoulder**, **4 shops + known**. Same on katoomba_real: 0 → 4. + +**I did not fix it.** It is a two-character change in my own file, but it moves the default boot's +opening pose for every gate and screenshot in the project, mid-round, with three lanes in the tree and +Lane F gating at the close. The patch is `+Math.sin` / `+Math.cos` at `index.html:384-385` and `:450`. +**Fable's call.** + +### The save shape, and its bound +`known` — OPTIONAL, absent until something is learned, so a save that predates R39 stays valid forever +and a boot that discovers nothing writes the same bytes it always did (verified: a fresh export has no +`known` key, and calling `knownStore()` without writing does not create one). + +``` +known: { "": { s: [shopId…], e: [edgeId…] } } // e.g. "osm/katoomba_real", "synthetic/default@20261990" +``` + +**Keyed by TOWN, not by `townKey`** — I re-derived Fable's claim rather than take it: across seeds +20261990 / 7 / 424242, **all 23 caches produce byte-identical shop ids, edge ids AND lot +geometry+frontEdge**; only the synthetic moves (493 / 479 / 465 shops), so it alone keys `default@`. +A re-seed is the same Katoomba with different secrets and the map you learned survives it. + +**Bounded three ways, and the bound is checked rather than trusted:** + +| axis | bound | why it is enough | +|---|---|---| +| ids per town per kind | `KNOWN_IDS_CAP` 8,192 | self-bounding by the plan: **max 493 shops** (synthetic), **max 2,593 edges** (bendigo_real). 3.2× the biggest town in the corpus — no legitimate save can reach it | +| towns | `KNOWN_TOWNS_CAP` 64 | the only axis a save can grow along **without walking** (`?town=` takes any string). FIFO eviction at write time, verified: writing 74 town keys leaves 64, oldest gone | +| the ids | non-negative integers ≤ 1e6 | a plan index, not free text | + +**Worst case, built and stringified rather than estimated** — every shop and every street segment of +all 24 towns learned: 1,712 shops + 31,008 edges = 32,720 ids = **137,547 bytes (134.3 KB), 2.62% of a +5 MB localStorage quota** — against 5–9 game-days of walking *per town*. A measured save after a 500 m +outing on the synthetic (68 shops, 7 segments) is **482 bytes**. + +**Reject arms, all demonstrated (whole-blob, loud, stashed — the existing law):** a string id · a +negative id · a float id · a town entry that is an array · `known` itself an array · 8,193 ids · 65 +towns · an empty town key. All eight rejected with their own `why`; a rejected `import()` leaves live +state untouched (counts identical before and after); a foreign town's fog imports without leaking into +this town's (this town reads 0). + +### Lane A's address layer, consumed +A landed `createAddresses` mid-round (`f628bf5`) and it is wired: `getTownCache(CHAR_TOWN)` → +`createAddresses(plan, cache)` → **street names along the streets you have walked**, deduped by name +(one label per name, on the longest known edge, never upside down, capped at 40 and skipped when the +edge is shorter than its own text), plus `localityOf(shopId).street || label` for the towns where +`street` is null by construction. **The consumer never branches on town type** — it asks, and draws +whatever answers; the synthetic gets *"the south end of the main street"* from the same line that gets +*"Templeton Street"* on castlemaine. The caption counts **distinct streets** when the address layer is +present and **street segments** when it is not, because an edge is not a street. `stats().supplier` is +printed at boot: a forgotten cache degrades **visibly to district labels, never to a wrong name**. + +### Classic, by construction +`createMinimap(plan, known, opts)` — **`known === null` is the pre-R39 map and `minimap.js` contains no +flag test at all.** The null store is what `?classic=1`, `?game=0` and `?fog=0` each produce in the +shell, the same shape `townCharacter(null)` gives `ground.js` (R38 §1.2). Verified as pixels, not as an +argument: the map canvas FNV hash at a fixed pose is **identical to HEAD's on all three** — +`?classic=1` `57fc623d`, `?fog=0` and `?game=0` `3eac8261` (the two hashes differ from each other +because classic also turns the gig layer and the yards off, which is the control proving the hash is +sensitive to the map's content at all). + +### Held — for Fable / for John +1. **The inverted front normal at `index.html:384` and `:450`** (above). Two characters, measured to + death, not applied: it moves the default boot's opening pose mid-round. +2. **John's standing call: is the fog default-on or a mode?** Shipped **default-on** on the flagOn + precedent, with **`?fog=0` a true revert** (hash-identical to HEAD) so both answers are one + character apart and either can be felt today. Not a decision made — a decision kept available. +3. **John's standing call: how harsh?** Shipped (a) *walked within 25 m of the shopfront*. (b) *entered* + and (c) *every shop on a walked street* are both **one line at the probe** — (b) drops the radius + arm and keeps `discoverShop`, (c) unions `hasEdge(lot.frontEdge)` into the shop test. The store, the + save, the bound and the framing are identical under all three. +4. **A walked edge is drawn end to end.** Step onto the synthetic's main street and its whole ~400 m + line appears. On real towns edges are short and this is invisible; on the synthetic (22 edges) it + over-tells. Clipping to the walked span needs per-edge `t` ranges — i.e. new save state — so it is + a proposal, not a patch. It no longer affects the FRAME (which is shopfront-derived). +5. **Late game, the frame grows back toward the overview** — fully explored, fitzroy's frame *is* + today's map, which is the right map at that point. If it ever wants a zoom, the transform is one + function and the cache key already carries the box. +6. **The HUD tooltip street label** (`hud.js`, one template literal, `PROCITY.addresses.localityOf`) + is the charter's other B line for this layer. Not landed — out of 39.2's scope, and it is ready to + drop in a round that owns `hud.js`. + ## Round 38 (v8 WAVE 1 — INHABITATION, all four runtime items) — **someone lives here, for +1 walked draw** **Four items, all built procedural-first, all measured walked. The whole round costs +1 draw at the worst diff --git a/docs/shots/laneB_r39/README.md b/docs/shots/laneB_r39/README.md new file mode 100644 index 0000000..1f7c64a --- /dev/null +++ b/docs/shots/laneB_r39/README.md @@ -0,0 +1,17 @@ +# Lane B — Round 39, THE FOG (item 39.2) + +Taken in a fresh context on port-isolated **no-store** servers (`:8591` treatment, `:8592` = +`git archive HEAD`), assets/vendor symlinked so the other lanes' mid-round waves are identical on +both sides. Every number behind these pictures is in `docs/LANES/LANE_B_NOTES.md` §39. + +| file | what it shows | +|---|---| +| `fog-00-head.png` | **HEAD, synthetic** — all 493 shops handed over on the first M press, 26,506 shop-colour px | +| `fog-01-before.png` | **R39, fresh boot** — 0 shops known, the 240 m frame, a 25 m scale bar | +| `fog-02-after.png` | **R39, after 900 m** — 113 shops, 7 segments, A's district labels (*"the south end of the main street"*) | +| `fog-00-head-katoomba_real.png` | **HEAD, katoomba** — 993 edges of grey spaghetti; at 1.42×1.89 px the 72 shops rasterise to **zero** pixels of their own colour | +| `fog-01-before-katoomba_real.png` | **R39, fresh boot** — the 240 m frame, a shop lot at 29.4×39.2 px | +| `fog-02-after-katoomba_real.png` | **R39, after 900 m** — 34 shops on **Katoomba Street**, with **Edwards Street** running off it (Lane A's address layer) | +| `map-classic.png` | `?classic=1` — the pre-R39 map, hash-identical to HEAD's | +| `spawn-*-SHIPPED.png` | the default boot's spawn pose today: a footpath facing a plaza, **not one shopfront in frame**, 0 shops discovered | +| `spawn-*-FRONT-NORMAL.png` | the same pose recomputed with the front normal `buildings.js`/`dbg.js`/`minimap.js` all use: **under the awning, sign at your shoulder**, 4 shops discovered | diff --git a/docs/shots/laneB_r39/fog-00-head-katoomba_real.png b/docs/shots/laneB_r39/fog-00-head-katoomba_real.png new file mode 100644 index 0000000..c5a0f36 Binary files /dev/null and b/docs/shots/laneB_r39/fog-00-head-katoomba_real.png differ diff --git a/docs/shots/laneB_r39/fog-00-head.png b/docs/shots/laneB_r39/fog-00-head.png new file mode 100644 index 0000000..14ad1ba Binary files /dev/null and b/docs/shots/laneB_r39/fog-00-head.png differ diff --git a/docs/shots/laneB_r39/fog-01-before-katoomba_real.png b/docs/shots/laneB_r39/fog-01-before-katoomba_real.png new file mode 100644 index 0000000..be9628d Binary files /dev/null and b/docs/shots/laneB_r39/fog-01-before-katoomba_real.png differ diff --git a/docs/shots/laneB_r39/fog-01-before.png b/docs/shots/laneB_r39/fog-01-before.png new file mode 100644 index 0000000..2295dd0 Binary files /dev/null and b/docs/shots/laneB_r39/fog-01-before.png differ diff --git a/docs/shots/laneB_r39/fog-02-after-katoomba_real.png b/docs/shots/laneB_r39/fog-02-after-katoomba_real.png new file mode 100644 index 0000000..e46479a Binary files /dev/null and b/docs/shots/laneB_r39/fog-02-after-katoomba_real.png differ diff --git a/docs/shots/laneB_r39/fog-02-after.png b/docs/shots/laneB_r39/fog-02-after.png new file mode 100644 index 0000000..10a459c Binary files /dev/null and b/docs/shots/laneB_r39/fog-02-after.png differ diff --git a/docs/shots/laneB_r39/map-classic.png b/docs/shots/laneB_r39/map-classic.png new file mode 100644 index 0000000..5df973c Binary files /dev/null and b/docs/shots/laneB_r39/map-classic.png differ diff --git a/docs/shots/laneB_r39/spawn-katoomba-FRONT-NORMAL.png b/docs/shots/laneB_r39/spawn-katoomba-FRONT-NORMAL.png new file mode 100644 index 0000000..5e910d1 Binary files /dev/null and b/docs/shots/laneB_r39/spawn-katoomba-FRONT-NORMAL.png differ diff --git a/docs/shots/laneB_r39/spawn-katoomba-SHIPPED.png b/docs/shots/laneB_r39/spawn-katoomba-SHIPPED.png new file mode 100644 index 0000000..56862ce Binary files /dev/null and b/docs/shots/laneB_r39/spawn-katoomba-SHIPPED.png differ diff --git a/docs/shots/laneB_r39/spawn-synthetic-FRONT-NORMAL.png b/docs/shots/laneB_r39/spawn-synthetic-FRONT-NORMAL.png new file mode 100644 index 0000000..f7d8b01 Binary files /dev/null and b/docs/shots/laneB_r39/spawn-synthetic-FRONT-NORMAL.png differ diff --git a/docs/shots/laneB_r39/spawn-synthetic-SHIPPED.png b/docs/shots/laneB_r39/spawn-synthetic-SHIPPED.png new file mode 100644 index 0000000..560f61d Binary files /dev/null and b/docs/shots/laneB_r39/spawn-synthetic-SHIPPED.png differ diff --git a/web/index.html b/web/index.html index 06a9d83..33f5d7a 100644 --- a/web/index.html +++ b/web/index.html @@ -54,6 +54,7 @@ import { createChunkManager } from './js/world/chunks.js'; import { createPlayer } from './js/world/player.js'; import { createHUD } from './js/world/hud.js'; import { createMinimap } from './js/world/minimap.js'; +import { createDiscovery, fogKeyFor } from './js/world/discovery.js'; // [Lane B R39 §39.2] v9 THE FOG import { createInteriorMode, godverseBaseFor } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8] Lane C buy loop v0 (session wallet) import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians @@ -116,8 +117,11 @@ async function loadCustomBands() { } const customBands = await loadCustomBands(); let plan; +let citygenMod = null; // [Lane B R39] kept so the address layer can be built when Lane A lands it +let townCacheJSON = null; // [Lane B R39] …and so it can be handed the cache the shell already fetched try { const citygen = await import('./js/citygen/index.js'); + citygenMod = citygen; // [Lane F R17 — real-town caches] a ?plansrc=osm&town= whose key is NOT a checked-in fixture is one of // Lane E's real AU town caches (web/assets/towns/.json). Fetch + register it before the plan generates, // so real towns boot in the engine (the v4 scout). Fail-soft: 404 / invalid cache / ?noassets → plan_osm @@ -126,7 +130,7 @@ try { && !(citygen.osmTownKeys && citygen.osmTownKeys().includes(TOWN))) { try { const r = await fetch(`assets/towns/${TOWN}.json`, { cache: 'force-cache' }); - if (r.ok) { citygen.registerTownCache(TOWN, await r.json()); console.log(`[procity] real town cache registered: ${TOWN}`); } + if (r.ok) { townCacheJSON = await r.json(); citygen.registerTownCache(TOWN, townCacheJSON); console.log(`[procity] real town cache registered: ${TOWN}`); } } catch (e) { console.warn(`[procity] town cache '${TOWN}' not loaded (${e && e.message || e}) — falling back to a fixture`); } } const gen = citygen.generatePlanFor @@ -201,7 +205,8 @@ const hud = createHUD({ getDoorMeshes: () => chunks.getDoorMeshes(), onEnterShop: (shopId, name) => enterShop(shopId, name), }); -const minimap = createMinimap(plan); +// [Lane B R39 §39.2] the minimap is built AFTER the game layer now — the fog it draws is player state +// and the game owns it (see the createMinimap call below the createGame block). // [Lane F §3.4] asset upgrades — OFF entirely under ?noassets. Fail-soft: primitives persist until (and // 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. @@ -288,6 +293,45 @@ const game = GAME_ON ? createGame({ }, }) : null; const wallet = game ? game.wallet : baseWallet; + +// ── [Lane B R39 §39.2 — THE FOG (v9 Layer 2)] ────────────────────────────────────────────────── +// The map used to hand over every shop in the plan on the first M press — 493 on the synthetic — +// against a measured 5,330 m / 9,622 m / 5,797 m (4.8 / 8.7 / 5.3 game-days) of walking to earn it. +// Now it draws what has been walked past, and FRAMES ITSELF TO IT: a shop lot renders at 0.83×1.11 px +// on bowral_real under the full-plan transform and 29×39 px in a 200 m known window. +// +// CLASSIC-GATED BY CONSTRUCTION, not by a flag test: `createMinimap(plan, null)` is the pre-R39 map, +// byte for byte, and a null store is exactly what ?classic=1 / ?game=0 / ?fog=0 produce here — so +// minimap.js contains no `if (classic)` at all. Same shape as townCharacter(null) → ground.js (§R38). +// Discovery is PLAYER STATE under the delta law: save.js keeps it, keyed by TOWN (shop/edge ids are +// identical across seeds on all 23 caches — verified), and the world still regenerates from seed. +const FOG_ON = GAME_ON && flagOn('fog'); +const FOG_KEY = fogKeyFor(PLANSRC, TOWN, seed); +const knownStore = (game && FOG_ON) ? game.knownStore(FOG_KEY) : null; +const minimap = createMinimap(plan, knownStore); +const discovery = knownStore ? createDiscovery({ plan, known: knownStore }) : null; +// [R39] Lane A's address layer (39.1, LANE_A_NOTES §39) — the street names on the map. The cache comes +// from A's new additive `getTownCache(key)` rather than the shell's own reference, because the checked-in +// fixture towns are not caches and the shell only ever fetched the real ones; `townCacheJSON` stays as a +// fallback for the one case getTownCache cannot cover (a cache registered from a fetch this boot). +// Fail-soft and shape-checked: a throw or a missing export leaves the map exactly as it is. The +// consumer never branches on town type (A's explicit guarantee) — it asks a street for its name and a +// shop for its `street || label`, and a null answer is handled once, on both paths. +let addresses = null; +if (citygenMod && typeof citygenMod.createAddresses === 'function') { + try { + const cache = (citygenMod.getTownCache ? citygenMod.getTownCache(CHAR_TOWN) : null) || townCacheJSON || null; + const a = citygenMod.createAddresses(plan, cache); + if (a && (typeof a.streetOf === 'function' || typeof a.localityOf === 'function')) { + addresses = a; minimap.setAddresses(a); + const st = a.stats ? a.stats() : null; + // `supplier` is the honest failure surface: a forgotten cache degrades VISIBLY to district + // labels ('district'), never to a wrong street name. Print it rather than assume it. + console.log(`[procity] address layer wired to the map — supplier ${st ? st.supplier : '?'}` + + (st ? `, ${st.edgesNamed}/${st.edges} edges named, ${st.distinctStreets} streets, ${st.shopsLabelled}/${st.shops} shops labelled` : '')); + } + } catch (e) { console.warn('[procity] address layer present but failed to build — map labels off:', e && e.message || e); } +} // [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT) // → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1 // with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's @@ -507,6 +551,10 @@ function enterShop(shopId, name) { gigState.markPaidOf(shopId); hud.showToast(`$${cover} cover — ${BAND}`); } + // [Lane B R39] you walked in the door: you know the shop, whatever the probe geometry says. Placed + // INSIDE the successful-entry branch (after the closed check and the cover charge), never on the + // dispatch — a knockback at the door is not a discovery. + if (discovery) discovery.discoverShop(shopId); // [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode const lot = plan.lots.find((l) => l.id === shop.lot); interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name); @@ -612,6 +660,7 @@ function frame() { if (tram) tram.update(dt); // [Lane F R9] Lane B tram loop if (washing) washing.update(dt); // [Lane B R38 §1.4] event-driven refill; early-outs most frames if (magpie) magpie.update(dt); // [Lane B R38 §1.3] the swoop (pure fn of the launch record) + if (discovery) discovery.update(player.position); // [Lane B R39 §39.2] the fog probe (every 6th street frame) citizens.setWeather(weatherState); // [Lane F R8] Lane D rain reaction (thin + shelter) citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers hud.tickToast(dt); @@ -645,6 +694,8 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it) washing, // [Lane B R38 §1.4] the town-wide washing layer (null flags-off / classic) — `.state` magpie, // [Lane B R38 §1.3] the magpie (null flags-off / classic) — `.state`, `.swoopPos` (pure) + minimap, discovery, addresses, // [Lane B R39 §39.2] THE FOG — `minimap.view` (span/lot px), `discovery.stats` + fogKey: FOG_KEY, knownStore, // …and the ledger itself (null flags-off / classic — the map's null arm) tram, // [Lane F R21] Lane B's tram — `.routeInfo` carries the R20 shop-adjacency verdict // ({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 @@ -655,7 +706,10 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin // [R38 wave 1] yards is read by buildings.js (?yards=0 reverts the pre-R38 house/yard geometry); // washing/magpie are shell-constructed; ambient is the §1.1 audio layer (read by audio.js). yards: flagOn('yards'), washing: flagOn('washing') && flagOn('yards'), - magpie: flagOn('magpie'), ambient: flagOn('ambient'), verge: ground.verge }, + magpie: flagOn('magpie'), ambient: flagOn('ambient'), verge: ground.verge, + // [R39 §39.2] the fog needs the game (it IS player state): ?fog=0 / ?game=0 / ?classic=1 + // all land on a null store, which is the pre-R39 map by construction. + fog: FOG_ON }, THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks // [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with // zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so diff --git a/web/js/world/discovery.js b/web/js/world/discovery.js new file mode 100644 index 0000000..840d585 --- /dev/null +++ b/web/js/world/discovery.js @@ -0,0 +1,150 @@ +// PROCITY Lane B — discovery.js [v9 §Layer-2 THE FOG, round 39] +// +// WHAT YOU HAVE WALKED PAST. The map used to hand over every shop in the plan on the first M press +// (minimap.js:52-63, ungated) — 493 shops on the synthetic, and 5,330 m / 9,622 m / 5,797 m of walking +// (4.8 / 8.7 / 5.3 game-days at WALK 4.6 m/s against lighting.js's 240 s day) is what earning that +// legitimately costs. This module is the ledger of what has actually been earned. +// +// THREE LAWS, all load-bearing: +// +// 1. DISCOVERY IS A PROXIMITY PROBE, NOT A STREAMING EVENT. `chunks.onChunkBuilt` looks like the +// seam and is not: every real town is BIG_CITY (index.html:151) ⇒ RADIUS 2, so the outermost live +// chunk centre is 128 m away and its far corner 181 m. Chunk-built discovery would hand you shops +// three blocks away through a wall. We probe by DISTANCE (FOG_RADIUS_M) and, so that a walk down +// the back lane does not reveal the shopfronts on the far side of the buildings, by SIDE: you must +// be in front of the shopfront plane. (At R=2 anything within 25 m is always in a live chunk, so +// the probe never disagrees with what is streamed — it just doesn't depend on it.) +// +// 2. DISCOVERY IS PLAYER STATE (THE DELTA LAW). It is saved — in the save, next to cash and the +// collection — and NEVER on the plan. The world still regenerates from seed with nothing +// remembered about it; what is remembered is what the player saw. save.js owns the storage and the +// bound (§39, `game.knownStore(fogKey)`); this module owns the geometry of "walked past". +// +// 3. IT IS KEYED BY TOWN, NOT BY townKey. Measured across all 23 shipped caches at seeds 20261990 / 7 +// / 424242: shop ids, edge ids, and every lot's geometry+frontEdge are IDENTICAL across seeds +// (only `storeys`/`hours` re-jitter). A re-seed is the same Katoomba with different secrets, so the +// map you learned survives it. The synthetic is the stated exception — its shop count itself moves +// with the seed (493 / 479 / 465) — so it keys `default@`. +// +// Pure except for the store it is handed: no THREE, no DOM, no fetch, no plan mutation. It is +// constructed ONLY when the game layer is on, so ?classic=1 / ?game=0 never build it at all. + +export const FOG_RADIUS_M = 25; // "walked past a shopfront" — the probe radius +export const FOG_FRONT_M = 0; // ...and on the street side of it (dot with the facade normal) +export const FOG_STREET_PAD_M = 4; // "walked this street" — kerb (width/2) + footpath tolerance +export const PROBE_EVERY = 6; // frames — the cadence hud.js:337 already raycasts doors on +const CELL = 32; // spatial-hash cell (m). > FOG_RADIUS_M so 3×3 cells always cover it. + +// The fog key. `plansrc`/`town`/`seed` are exactly what the shell already computed for TOWNKEY; the +// difference is deliberate and is law 3 above: the real towns drop the seed, the synthetic keeps it. +export function fogKeyFor(plansrc, town, seed) { + return plansrc === 'osm' ? `osm/${town || 'melbourne'}` : `synthetic/default@${seed >>> 0}`; +} + +// Every shopfront as a point + an outward normal. +// +// THE SIGN IS buildings.js's, NOT THE SHELL'S, AND THEY DISAGREE. `buildShopfront` puts the facade, +// the door and the doorRect at local +Z — `toWorld(lot,0,0,d/2)` = `lot + (sin ry, cos ry)·d/2` +// (buildings.js:410-416, :583-597) — and minimap.js's own front-edge tick has always been drawn at +// local +Z too. The shell's patronage door points (index.html:384) and cluster spawn (:450) use +// `(-sin ry, -cos ry)`, i.e. the BACK of the building. Measured, taking each lot's own front and back +// point and asking which is nearer the kerb: the back point wins on **471 of 493** synthetic shops and +// **70 of 72** on katoomba_real. My first cut copied the shell's sign and the walk read 4 shops known +// / 1,231 in-radius rejections on the synthetic — the fog was hiding the whole town from a player +// standing in front of it. Filed for the shell; this module follows the geometry that actually renders. +export function shopFronts(plan) { + const lots = new Map((plan.lots || []).map((l) => [l.id, l])); + const out = []; + for (const s of plan.shops || []) { + const l = lots.get(s.lot); + if (!l) continue; + const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); + out.push({ id: s.id, x: l.x + nx * (l.d / 2), z: l.z + nz * (l.d / 2), nx, nz }); + } + return out; +} + +function segDist2(px, pz, ax, az, bx, bz) { + const vx = bx - ax, vz = bz - az, wx = px - ax, wz = pz - az; + const vv = vx * vx + vz * vz; + let t = vv > 0 ? (wx * vx + wz * vz) / vv : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const dx = px - (ax + vx * t), dz = pz - (az + vz * t); + return dx * dx + dz * dz; +} + +// createDiscovery({ plan, known, radius? }) → the probe. +// known — the KNOWN STORE (save.js `game.knownStore(fogKey)`): addShop/addEdge/hasShop/hasEdge/ +// counts/version. Anything with that shape works (the headless gates pass a plain object). +export function createDiscovery({ plan, known, radius = FOG_RADIUS_M, streetPad = FOG_STREET_PAD_M } = {}) { + const R2 = radius * radius; + const fronts = shopFronts(plan); + const nodes = new Map((plan.streets?.nodes || []).map((n) => [n.id, n])); + const edges = []; + for (const e of plan.streets?.edges || []) { + const a = nodes.get(e.a), b = nodes.get(e.b); + if (!a || !b) continue; + const pad = (e.width || 0) / 2 + streetPad; + edges.push({ id: e.id, ax: a.x, az: a.z, bx: b.x, bz: b.z, pad2: pad * pad, pad }); + } + + // spatial hash — shops by their front point, edges by every cell their padded bbox touches + const cells = new Map(); + const key = (cx, cz) => cx + ',' + cz; + const cellOf = (cx, cz) => { const k = key(cx, cz); let c = cells.get(k); if (!c) cells.set(k, c = { s: [], e: [] }); return c; }; + for (const f of fronts) cellOf(Math.floor(f.x / CELL), Math.floor(f.z / CELL)).s.push(f); + for (const e of edges) { + const x0 = Math.floor((Math.min(e.ax, e.bx) - e.pad) / CELL), x1 = Math.floor((Math.max(e.ax, e.bx) + e.pad) / CELL); + const z0 = Math.floor((Math.min(e.az, e.bz) - e.pad) / CELL), z1 = Math.floor((Math.max(e.az, e.bz) + e.pad) / CELL); + for (let cx = x0; cx <= x1; cx++) for (let cz = z0; cz <= z1; cz++) cellOf(cx, cz).e.push(e); + } + + let frame = 0, probes = 0, rejectedBehind = 0, lastNew = 0; + // the front test's falsifiability control: WHICH shops came inside the radius and were refused for + // being behind their own facade. `behind.size − (those later learned from the front)` is the count + // of shops a radius-only probe would have handed over through a wall. + const behind = new Set(); + + // probe(pos) → number of NEW things learned. Called directly by the gates; the shell goes through + // update(), which owns the cadence. + function probe(pos) { + probes++; + let n = 0; + const cx = Math.floor(pos.x / CELL), cz = Math.floor(pos.z / CELL); + for (let ix = cx - 1; ix <= cx + 1; ix++) for (let iz = cz - 1; iz <= cz + 1; iz++) { + const c = cells.get(key(ix, iz)); + if (!c) continue; + for (const f of c.s) { + if (known.hasShop(f.id)) continue; + const dx = pos.x - f.x, dz = pos.z - f.z; + if (dx * dx + dz * dz > R2) continue; + // ...and you must be in FRONT of it. A shopfront seen through its own back wall is not seen. + if (dx * f.nx + dz * f.nz <= FOG_FRONT_M) { rejectedBehind++; behind.add(f.id); continue; } + if (known.addShop(f.id)) n++; + } + for (const e of c.e) { + if (known.hasEdge(e.id)) continue; + if (segDist2(pos.x, pos.z, e.ax, e.az, e.bx, e.bz) > e.pad2) continue; + if (known.addEdge(e.id)) n++; + } + } + if (n) lastNew = probes; + return n; + } + + return { + probe, + // the shell's per-street-frame call — same throttle class as hud.js's door raycast + update(pos) { frame++; return (frame % PROBE_EVERY === 0) ? probe(pos) : 0; }, + // you walked in the door: you know the shop, whatever the geometry says + discoverShop(id) { return known.addShop(id) ? 1 : 0; }, + get store() { return known; }, + get stats() { + const c = known.counts(); + let behindOnly = 0; + for (const id of behind) if (!known.hasShop(id)) behindOnly++; + return { ...c, shopsTotal: fronts.length, edgesTotal: edges.length, probes, rejectedBehind, + behindShops: behind.size, behindOnly, lastNew, radius, streetPad }; + }, + }; +} diff --git a/web/js/world/minimap.js b/web/js/world/minimap.js index f510ff0..d9e0b66 100644 --- a/web/js/world/minimap.js +++ b/web/js/world/minimap.js @@ -2,6 +2,26 @@ // The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by // shop type + a live player dot/heading). This is the shell's own lightweight map; Lane A's // map.html debug view grows into the richer directory later (Lane F reconciles). +// +// [R39 — v9 Layer 2, THE FOG] `createMinimap(plan, known, opts)`. +// +// known === null ⇒ EXACTLY the pre-R39 map: every lot, every street, full-plan extent, no labels, +// no cache. That is what ?classic=1 / ?game=0 / ?fog=0 hand it, so THIS FILE +// CONTAINS NO FLAG TEST — the classic guarantee is by construction, the same +// shape ground.js gets from townCharacter(null) (index.html:178-185). +// known !== null ⇒ the map draws only what has been walked past (discovery.js writes the store, +// save.js keeps it) AND FRAMES ITSELF TO IT. The framing is the point, not a +// bonus: the full-plan transform renders a shop rectangle at 0.83×1.11 px on +// bowral_real and 1.42×1.89 on katoomba_real — the 21 towns that are supposed to +// be 21 mental maps have a map you cannot read. The same line that hides what you +// don't know makes legible what you do. +// +// Zero draws, zero triangles, in both arms: this is a 2D canvas over the WebGL canvas and +// `hud.js:361`'s `renderer.info.render.calls` never sees it. Measured, not asserted — see §39. +// +// The static layer is CACHED to an offscreen canvas in the fog arm and re-rendered only when the +// discovery version or the (quantised) frame box changes. The null arm keeps re-filling every lot per +// frame exactly as it always has: identical work, identical pixels, nothing new to be wrong. const TYPE_COLOR = { record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0', @@ -9,7 +29,13 @@ const TYPE_COLOR = { house: '#6b5a48', anchor: '#5a6a7a', }; -export function createMinimap(plan) { +const MIN_SPAN_M = 240; // the tightest frame: a 200 m window + the transform's own 40 m margin +const FRAME_Q = 25; // frame box quantisation (m) — walking inside what you know re-renders nothing +const MAX_LABELS = 40; + +export function createMinimap(plan, known = null, opts = {}) { + let addresses = opts.addresses || null; // Lane A's createAddresses(): streetOf / localityOf + const wrap = document.createElement('div'); wrap.id = 'pc-map'; wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;' @@ -63,9 +89,196 @@ export function createMinimap(plan) { } } + // ── the fog arm ──────────────────────────────────────────────────────────────────────────────── + // Everything below is dead code when `known` is null: `draw()` branches once, at the top. + const off = known ? document.createElement('canvas') : null; + if (off) { off.width = W; off.height = H; } + const og = off ? off.getContext('2d') : null; + const lotById = known ? new Map(plan.lots.map((l) => [l.id, l])) : null; + const shopById = known ? new Map(plan.shops.map((s) => [s.id, s])) : null; + let cacheKey = null, lastCounts = null; + let T = { sc, tx, tz }; // the live transform (full-extent until something is known) + + // The extent of what is KNOWN, recomputed only when the ledger changes (the player is unioned in + // per frame below — that part is two comparisons, not a walk of the ledger). + // + // SHOPFRONTS ONLY, DELIBERATELY. Including walked EDGES here was my first cut and it threw the + // framing away: an edge is drawn as the whole node-to-node line, and the synthetic's main street is + // one 400 m edge, so stepping onto it at spawn expanded the frame to 340 m before the player knew a + // single shop. Framing on the shops you have actually stood in front of (plus where you are standing) + // keeps the fresh-boot frame at the 240 m floor — 21×41 px on the synthetic, 29×39 on a real town — + // and a walked street that runs off the edge of the frame is simply clipped by the canvas, which is + // what a map does. + let kb = { v: -1, x0: Infinity, x1: -Infinity, z0: Infinity, z1: -Infinity }; + function refreshKnownExtent() { + let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity; + const eat = (x, z) => { if (x < x0) x0 = x; if (x > x1) x1 = x; if (z < z0) z0 = z; if (z > z1) z1 = z; }; + for (const id of known.shopIds()) { + const s = shopById.get(id), l = s && lotById.get(s.lot); + if (l) { const r = Math.max(l.w, l.d) / 2; eat(l.x - r, l.z - r); eat(l.x + r, l.z + r); } + } + kb = { v: known.version(), x0, x1, z0, z1 }; + } + + function knownBox(px, pz) { + if (kb.v !== known.version()) refreshKnownExtent(); + // the player is ALWAYS on the map — a frame you can walk off is a frame that lies + let x0 = Math.min(kb.x0, px - 20), x1 = Math.max(kb.x1, px + 20); + let z0 = Math.min(kb.z0, pz - 20), z1 = Math.max(kb.z1, pz + 20); + // quantise so ordinary walking inside what you know never invalidates the cache + x0 = Math.floor(x0 / FRAME_Q) * FRAME_Q; z0 = Math.floor(z0 / FRAME_Q) * FRAME_Q; + x1 = Math.ceil(x1 / FRAME_Q) * FRAME_Q; z1 = Math.ceil(z1 / FRAME_Q) * FRAME_Q; + // same +40 margin the full-extent transform uses, clamped: never tighter than a 200 m window, + // never wider than the whole plan (fully explored ⇒ exactly today's overview, which is the point + // at which today's overview is the right map). + let s2 = Math.max(x1 - x0, z1 - z0) + 40; + s2 = Math.max(MIN_SPAN_M, Math.min(span, s2)); + return { cx: (x0 + x1) / 2, cz: (z0 + z1) / 2, span: s2, x0, x1, z0, z1 }; + } + + function paintFog(box) { + const s = (W - pad * 2) / box.span; + const fx = (x) => W / 2 + (x - box.cx) * s; + const fz = (z) => H / 2 + (z - box.cz) * s; + T = { sc: s, tx: fx, tz: fz }; + og.clearRect(0, 0, W, H); + og.fillStyle = '#1b1e17'; og.fillRect(0, 0, W, H); + // streets you have walked + const drawn = []; + for (const e of plan.streets.edges) { + if (!known.hasEdge(e.id)) continue; + const a = nodes.get(e.a), b = nodes.get(e.b); + if (!a || !b) continue; + og.strokeStyle = '#3a3f34'; og.lineWidth = Math.max(3, e.width * s); + og.lineCap = 'round'; + og.beginPath(); og.moveTo(fx(a.x), fz(a.z)); og.lineTo(fx(b.x), fz(b.z)); og.stroke(); + drawn.push({ e, a, b }); + } + // lots: a shop is drawn once you have walked past IT; a house/yard/infill rides the street it + // fronts (you saw the houses when you walked the road, and no house is a secret worth keeping). + for (const l of plan.lots) { + const sh = shopByLot.get(l.id); + if (sh ? !known.hasShop(sh.id) : !known.hasEdge(l.frontEdge)) continue; + const col = TYPE_COLOR[sh ? sh.type : l.use] || '#666'; + og.save(); + og.translate(fx(l.x), fz(l.z)); og.rotate(-(l.ry || 0)); + og.fillStyle = col; + og.fillRect(-l.w * s / 2, -l.d * s / 2, l.w * s, l.d * s); + og.fillStyle = 'rgba(255,255,255,.5)'; + og.fillRect(-l.w * s / 2, l.d * s / 2 - 2, l.w * s, 2); + og.restore(); + } + paintLabels(og, drawn, s, fx, fz); + paintScale(og, s); + } + + // Lane A's address layer, consumed WITHOUT branching on town type (§39.1's explicit guarantee): + // we ask a street for its name and a shop for its locality label, draw whatever answers, and a town + // that answers one way rather than the other is not a case we test for. + function paintLabels(g, drawn, s, fx, fz) { + if (!addresses) return; + const seen = new Set(); + const out = []; + if (typeof addresses.streetOf === 'function') { + const byName = new Map(); + for (const d of drawn) { + let name = null; + try { name = addresses.streetOf(d.e.id); } catch (err) { return; } + if (!name) continue; + const len = Math.hypot(d.b.x - d.a.x, d.b.z - d.a.z); + const prev = byName.get(name); + if (!prev || len > prev.len) byName.set(name, { d, len }); + } + for (const [name, v] of byName) out.push({ text: name, kind: 'edge', d: v.d }); + } + if (typeof addresses.localityOf === 'function') { + const byLabel = new Map(); + for (const id of known.shopIds()) { + let loc = null; + try { loc = addresses.localityOf(id); } catch (err) { break; } + const text = loc && (loc.street || loc.label); + if (!text) continue; + const sh = shopById.get(id), l = sh && lotById.get(sh.lot); + if (!l) continue; + let b = byLabel.get(text); + if (!b) byLabel.set(text, b = { x: 0, z: 0, n: 0 }); + b.x += l.x; b.z += l.z; b.n++; + } + for (const [text, b] of byLabel) out.push({ text, kind: 'spot', x: b.x / b.n, z: b.z / b.n }); + } + g.font = '11px -apple-system, Segoe UI, sans-serif'; + g.textAlign = 'center'; g.textBaseline = 'middle'; + g.lineWidth = 3; g.strokeStyle = 'rgba(10,12,8,.85)'; + let n = 0; + for (const it of out) { + if (n >= MAX_LABELS) break; + const key = it.text.toLowerCase(); + if (seen.has(key)) continue; + const w = g.measureText(it.text).width; + g.save(); + if (it.kind === 'edge') { + const ax = fx(it.d.a.x), az = fz(it.d.a.z), bx = fx(it.d.b.x), bz = fz(it.d.b.z); + if (Math.hypot(bx - ax, bz - az) < w + 10) { g.restore(); continue; } + let ang = Math.atan2(bz - az, bx - ax); + if (ang > Math.PI / 2 || ang < -Math.PI / 2) ang += Math.PI; // never upside down + g.translate((ax + bx) / 2, (az + bz) / 2); g.rotate(ang); + } else { + g.translate(fx(it.x), fz(it.z) - 10); + } + g.fillStyle = '#d9d2be'; + g.strokeText(it.text, 0, 0); g.fillText(it.text, 0, 0); + g.restore(); + seen.add(key); n++; + } + } + + const SCALE_STEPS = [10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000]; + function paintScale(g, s) { + const want = (W - pad * 2) / s / 5; // ~a fifth of the window + let m = SCALE_STEPS[0]; + for (const v of SCALE_STEPS) if (v <= want) m = v; + const px = m * s; + const x = pad, y = H - pad; + g.strokeStyle = 'rgba(232,224,208,.7)'; g.lineWidth = 2; + g.beginPath(); g.moveTo(x, y); g.lineTo(x + px, y); g.moveTo(x, y - 4); g.lineTo(x, y + 4); + g.moveTo(x + px, y - 4); g.lineTo(x + px, y + 4); g.stroke(); + g.font = '11px -apple-system, Segoe UI, sans-serif'; + g.textAlign = 'left'; g.textBaseline = 'bottom'; + g.fillStyle = 'rgba(232,224,208,.8)'; + g.fillText(`${m} m`, x + 2, y - 6); + } + + // "segments", not "streets": `c.edges` counts plan edges, and a real street is many of them. When + // Lane A's address layer is wired we can say how many STREETS that is, so we do — same field, two + // suppliers, no town-type test (39.1's contract). + function refreshCaption(c) { + let streets = null; + if (addresses && typeof addresses.streetOf === 'function') { + const names = new Set(); + for (const id of known.edgeIds()) { let n = null; try { n = addresses.streetOf(id); } catch (e) { names.clear(); break; } if (n) names.add(n); } + if (names.size) streets = names.size; + } + cap.textContent = `${plan.name} — seed ${plan.citySeed} · ${c.shops} shop${c.shops === 1 ? '' : 's'} known · ` + + (streets != null ? `${streets} street${streets === 1 ? '' : 's'}` : `${c.edges} street segment${c.edges === 1 ? '' : 's'}`) + + ` walked · press M to close`; + } + function draw(playerPos, fwd) { - drawStatic(); - const x = tx(playerPos.x), z = tz(playerPos.z); + if (!known) drawStatic(); + else { + const box = knownBox(playerPos.x, playerPos.z); + const v = known.version(); + const key = `${v}|${box.x0},${box.x1},${box.z0},${box.z1}`; + if (key !== cacheKey) { + paintFog(box); + cacheKey = key; + const c = known.counts(); + if (!lastCounts || c.shops !== lastCounts.shops || c.edges !== lastCounts.edges) { refreshCaption(c); lastCounts = c; } + } + ctx.clearRect(0, 0, W, H); + ctx.drawImage(off, 0, 0); + } + const x = T.tx(playerPos.x), z = T.tz(playerPos.z); // heading (world +X→right, +Z→down on the map) ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, z); @@ -78,5 +291,18 @@ export function createMinimap(plan) { draw, setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; }, dispose: () => wrap.remove(), + // [R39] Lane A's address layer, injectable — the shell wires it at boot when citygen exports + // createAddresses, and it can land mid-session without a reload. + setAddresses(a) { addresses = a || null; cacheKey = null; lastCounts = null; }, + // what the map is actually showing, for the gates: metres across, and the px a shop lot renders at + get view() { + const med = (arr) => { const b = arr.slice().sort((p, q) => p - q); return b.length ? b[b.length >> 1] : 0; }; + const shopLots = plan.shops.map((s) => plan.lots.find((l) => l.id === s.lot)).filter(Boolean); + const w = med(shopLots.map((l) => l.w)), d = med(shopLots.map((l) => l.d)); + return { fog: !!known, spanFull: span, span: (W - pad * 2) / T.sc, scale: T.sc, + lotPx: { w: +(w * T.sc).toFixed(2), d: +(d * T.sc).toFixed(2) }, + lotPxFull: { w: +(w * sc).toFixed(2), d: +(d * sc).toFixed(2) }, + labels: !!addresses }; + }, }; } diff --git a/web/js/world/save.js b/web/js/world/save.js index 142004d..f954719 100644 --- a/web/js/world/save.js +++ b/web/js/world/save.js @@ -45,6 +45,11 @@ export function godbayHammer(entry, townKey) { return { sold: true, hammer, net }; } +// ── [R39 THE FOG] the discovery ledger's structural bounds (see validateSave's `known` block) ─── +export const KNOWN_TOWNS_CAP = 64; // distinct fog keys in one save (?town= accepts any string) +export const KNOWN_IDS_CAP = 8192; // ids per town per kind — 3.2× the biggest town in the corpus +export const KNOWN_ID_MAX = 1e6; // a plan index, not free text + // ── 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 @@ -97,6 +102,36 @@ export function validateSave(obj) { if (!f.length || f.some((k) => typeof w[k] !== 'string' || !w[k])) return { ok: false, why: `wants[${i}] malformed` }; } } + // [R39 THE FOG — Lane B] `known` is OPTIONAL, same law: the per-town discovery ledger, + // { "": { s:[shopId…], e:[edgeId…] } }. Player state, never world state: it names what the + // player has walked past, and the town it names still regenerates from seed with nothing remembered. + // BOUNDED THREE WAYS, and the bound is checked here rather than trusted: + // • towns — the only axis a hostile/edited save can grow without walking (?town= takes any string), + // capped at KNOWN_TOWNS_CAP; + // • ids per town — self-bounding by the plan (measured max: 493 shops on the synthetic, 2,593 edges + // on bendigo_real), so KNOWN_IDS_CAP at 8,192 is >3× the biggest town in the corpus and no + // legitimate save can ever reach it; + // • the ids themselves — non-negative integers under KNOWN_ID_MAX (plan indices, not free text). + // Over-cap or malformed ⇒ the WHOLE blob is rejected loudly, exactly like every other field. + if (obj.known != null) { + if (typeof obj.known !== 'object' || Array.isArray(obj.known)) return { ok: false, why: 'known not an object' }; + const towns = Object.keys(obj.known); + if (towns.length > KNOWN_TOWNS_CAP) return { ok: false, why: `known has ${towns.length} towns (cap ${KNOWN_TOWNS_CAP})` }; + for (const t of towns) { + if (!t) return { ok: false, why: 'known has an empty town key' }; + const v = obj.known[t]; + if (!v || typeof v !== 'object' || Array.isArray(v)) return { ok: false, why: `known[${JSON.stringify(t)}] malformed` }; + for (const f of ['s', 'e']) { + if (v[f] == null) continue; + if (!Array.isArray(v[f])) return { ok: false, why: `known[${JSON.stringify(t)}].${f} not an array` }; + if (v[f].length > KNOWN_IDS_CAP) return { ok: false, why: `known[${JSON.stringify(t)}].${f} has ${v[f].length} ids (cap ${KNOWN_IDS_CAP})` }; + for (let i = 0; i < v[f].length; i++) { + const n = v[f][i]; + if (!Number.isInteger(n) || n < 0 || n > KNOWN_ID_MAX) return { ok: false, why: `known[${JSON.stringify(t)}].${f}[${i}] malformed` }; + } + } + } + } return { ok: true, state: obj }; } @@ -127,6 +162,12 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul let pulls = []; const PULLS_CAP = 600; // FIFO backstop — a save stays tiny (delta law) const PULL_DAY_RE = /@d(\d+)#/; + // [R39 THE FOG] the discovery ledger: fogKey → { s:Set, e:Set }. ONE source of + // truth — the map and the probe both read these Sets through knownStore(), so nothing mirrors it. + // Entries are created on the first ADD, never on a read: a boot that discovers nothing writes no + // `known` key at all, and the save's bytes are exactly what they were before this round. + let known = new Map(); + let knownVersion = 0; // bumps on every add AND on adopt() — the map's cache key // ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ──────────── function payload() { @@ -134,9 +175,29 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul 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 + const k = knownPayload(); + if (k) p.known = k; // [R39] same convention — absent until something is learned return p; } + function knownPayload() { + let any = false; + const o = {}; + for (const [k, v] of known) { // Map preserves insertion order ⇒ so does the JSON + const e = {}; + if (v.s.size) e.s = [...v.s]; + if (v.e.size) e.e = [...v.e]; + if (e.s || e.e) { o[k] = e; any = true; } + } + return any ? o : null; + } + + // The town axis is the only one a save can grow along without walking (?town= takes any string), so + // it is the only one that needs an eviction rule: oldest fog key out, FIFO, like PULLS_CAP. + function trimKnownTowns() { + while (known.size > KNOWN_TOWNS_CAP) known.delete(known.keys().next().value); + } + // [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() { @@ -176,6 +237,19 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul // 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() : []; + // [R39 THE FOG] the adopted ledger REPLACES this session's — an import is someone else's memory of + // the town, not a merge. Rebuilt into Sets here so knownStore()'s reads stay O(1) and no consumer + // ever holds the save's own arrays. knownVersion bumps, so an open map re-renders on the next frame. + known = new Map(); + if (state.known && typeof state.known === 'object') { + for (const [k, v] of Object.entries(state.known)) { + const s = new Set(Array.isArray(v && v.s) ? v.s : []); + const e = new Set(Array.isArray(v && v.e) ? v.e : []); + if (s.size || e.size) known.set(k, { s, e }); + } + } + trimKnownTowns(); + knownVersion++; resolveAuctions(); // [R34] a day-moving adopt (travel/import) resolves what it passed prunePulls(); } @@ -372,6 +446,43 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul return true; }, + // ── the fog seam (R39, v9 Layer 2) ───────────────────────────────────────────────────────── + // knownStore(fogKey) → the KNOWN STORE contract, bound to one town. discovery.js writes it, the + // minimap reads it, and a NULL store (which is what ?classic=1 / ?game=0 / ?fog=0 hand the map) is + // the pre-R39 behaviour by construction — neither module carries a flag test. + // addShop/addEdge(id) → true when it is NEW (so a probe can count what it learned) + // hasShop/hasEdge(id) → O(1) shopIds()/edgeIds() → sorted copies (gates + the map) + // counts() → {shops, edges, towns} version() → bumps on every change, including import() + knownStore(fogKey) { + const fk = String(fogKey || ''); + const bag = (make) => { + let b = known.get(fk); + if (!b && make) { known.set(fk, b = { s: new Set(), e: new Set() }); trimKnownTowns(); } + return b || null; + }; + const add = (kind, id) => { + const n = Math.trunc(+id); + if (!Number.isInteger(n) || n < 0 || n > KNOWN_ID_MAX) return false; + const b = bag(true); + if (b[kind].size >= KNOWN_IDS_CAP || b[kind].has(n)) return false; // cap = the validator's, honoured at write + b[kind].add(n); knownVersion++; + return true; + }; + const hasIn = (kind, id) => { const b = bag(false); return !!b && b[kind].has(Math.trunc(+id)); }; + const idsOf = (kind) => { const b = bag(false); return b ? [...b[kind]].sort((x, y) => x - y) : []; }; + return { + key: fk, + addShop: (id) => add('s', id), + addEdge: (id) => add('e', id), + hasShop: (id) => hasIn('s', id), + hasEdge: (id) => hasIn('e', id), + shopIds: () => idsOf('s'), + edgeIds: () => idsOf('e'), + counts() { const b = bag(false); return { shops: b ? b.s.size : 0, edges: b ? b.e.size : 0, towns: known.size }; }, + version: () => knownVersion, + }; + }, + // Drain the morning paper (transient — resolution wrote it, the shell toasts it once). takeAuctionNews() { const n = auctionNews;