Lane B R39 39.2: THE FOG — the map shows what you walked past, and is finally readable
minimap.js drew every lot in the plan from the first frame (493 shops on the synthetic, one M press, against 5,330 m / 4.8 game-days of walking to earn it). It now draws what has been walked past AND FRAMES ITSELF TO IT — a shop lot goes 0.83x1.11 px -> 29.4x39.2 px on bowral_real, 35.3x. ZERO DRAWS, measured as an A/B: two port-isolated no-store servers, HEAD vs treatment, the same 15 stations x 4 yaws compared as JSON before either sweep runs. Draw calls IDENTICAL on 60/60 samples at noon and at night; with the gig layer off both sides (the venue queue's async rigs were the only noise) NIGHT is identical in draws AND triangles. Map mode cannot cost a draw structurally: the frame loop never calls composer.render() in the map branch. - discovery.js (new): a 25 m PROXIMITY PROBE, not onChunkBuilt (R=2 => the outermost live chunk corner is 181 m out), on a plan-derived spatial hash — 1.09-1.37 us a probe, every 6th street frame = every 0.46 m at WALK. Plus a side test: you must be in FRONT of the shopfront. 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. - save.js: optional `known` block under the delta law, keyed by TOWN (re-derived: 23 caches x 3 seeds, shop/edge ids and lot geometry all identical; the synthetic alone keys @<seed>). Bounded by the plan (max 493 shops / 2,593 edges) at 8,192, 64 towns FIFO, ints only. Whole corpus fully learned = 137,547 B (2.6% of quota); 482 B after a 500 m outing. Eight reject arms demonstrated. - classic BY CONSTRUCTION: createMinimap(plan, null) is the pre-R39 map and the file has no flag test at all. ?classic=1 / ?fog=0 / ?game=0 map canvases are hash-identical to HEAD's. - Lane A's address layer consumed (getTownCache + createAddresses): street names along walked streets, deduped; the synthetic gets district labels from the same line. No town-type branch. FILED, NOT FIXED: index.html:384 (Lane D patronage door points) and :450 (the R32 spawn) use (-sin ry, -cos ry) — the BACK of the building. buildings.js:410/583-597, dbg.js:53 and minimap.js all use local +Z. Measured against each lot's own frontEdge the +sin point is 0.00 m from the kerb and the shipped one 15.25 m the wrong side, 493/493 and 72/72 shops. At the shipped spawn 0 of the shops within 25 m are in front of you on all four towns tested; at the corrected one, all of them. Two characters, but it moves the default boot's opening pose mid-round — Fable's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ -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 `@<seed>`). 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
|
||||
|
||||
@ -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: { "<fogKey>": { 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@<seed>`.
|
||||
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
|
||||
|
||||
17
docs/shots/laneB_r39/README.md
Normal file
@ -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 |
|
||||
BIN
docs/shots/laneB_r39/fog-00-head-katoomba_real.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/shots/laneB_r39/fog-00-head.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/shots/laneB_r39/fog-01-before-katoomba_real.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/shots/laneB_r39/fog-01-before.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/shots/laneB_r39/fog-02-after-katoomba_real.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/shots/laneB_r39/fog-02-after.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/shots/laneB_r39/map-classic.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/shots/laneB_r39/spawn-katoomba-FRONT-NORMAL.png
Normal file
|
After Width: | Height: | Size: 691 KiB |
BIN
docs/shots/laneB_r39/spawn-katoomba-SHIPPED.png
Normal file
|
After Width: | Height: | Size: 746 KiB |
BIN
docs/shots/laneB_r39/spawn-synthetic-FRONT-NORMAL.png
Normal file
|
After Width: | Height: | Size: 844 KiB |
BIN
docs/shots/laneB_r39/spawn-synthetic-SHIPPED.png
Normal file
|
After Width: | Height: | Size: 918 KiB |
@ -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=<key> whose key is NOT a checked-in fixture is one of
|
||||
// Lane E's real AU town caches (web/assets/towns/<key>.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
|
||||
|
||||
150
web/js/world/discovery.js
Normal file
@ -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@<seed>`.
|
||||
//
|
||||
// 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
// { "<fogKey>": { 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<shopId>, e:Set<edgeId> }. 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;
|
||||
|
||||