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>
This commit is contained in:
m3ultra 2026-08-03 21:04:34 +10:00
parent 41d9878574
commit 0ee7a84710
18 changed files with 817 additions and 6 deletions

View File

@ -4,6 +4,42 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser. `generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 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.091.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** ## 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 Full detail + every number in LANE_B_NOTES §38. Measured as a true A/B: two port-isolated no-store

View File

@ -1,5 +1,222 @@
# LANE B — NOTES (measured) # 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 1020 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 **02**, 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 13 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 59 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** ## 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 **Four items, all built procedural-first, all measured walked. The whole round costs +1 draw at the worst

View 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 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

View File

@ -54,6 +54,7 @@ import { createChunkManager } from './js/world/chunks.js';
import { createPlayer } from './js/world/player.js'; import { createPlayer } from './js/world/player.js';
import { createHUD } from './js/world/hud.js'; import { createHUD } from './js/world/hud.js';
import { createMinimap } from './js/world/minimap.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 { 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 { 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 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(); const customBands = await loadCustomBands();
let plan; 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 { try {
const citygen = await import('./js/citygen/index.js'); 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 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, // 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 // 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))) { && !(citygen.osmTownKeys && citygen.osmTownKeys().includes(TOWN))) {
try { try {
const r = await fetch(`assets/towns/${TOWN}.json`, { cache: 'force-cache' }); 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`); } } catch (e) { console.warn(`[procity] town cache '${TOWN}' not loaded (${e && e.message || e}) — falling back to a fixture`); }
} }
const gen = citygen.generatePlanFor const gen = citygen.generatePlanFor
@ -201,7 +205,8 @@ const hud = createHUD({
getDoorMeshes: () => chunks.getDoorMeshes(), getDoorMeshes: () => chunks.getDoorMeshes(),
onEnterShop: (shopId, name) => enterShop(shopId, name), 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 // [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. // unless) a GLB lands, so the town is fully playable the instant it boots.
// • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders. // • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders.
@ -288,6 +293,45 @@ const game = GAME_ON ? createGame({
}, },
}) : null; }) : null;
const wallet = game ? game.wallet : baseWallet; 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) // [Lane F R12] the gig state machine (F-owned, js/world/gig_state.js): quiet → doors (DUSK) → on (NIGHT)
// → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1 // → done, off Lane B's clock and Lane A's plan.gigs[0] (alpha's "tonight"). Constructed ONLY under ?gigs=1
// with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's // with a real schedule, so window.PROCITY.gigs stays undefined flags-off — which is precisely what Lane B's
@ -507,6 +551,10 @@ function enterShop(shopId, name) {
gigState.markPaidOf(shopId); gigState.markPaidOf(shopId);
hud.showToast(`$${cover} cover — ${BAND}`); 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 // [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); const lot = plan.lots.find((l) => l.id === shop.lot);
interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name); 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 (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 (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 (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.setWeather(weatherState); // [Lane F R8] Lane D rain reaction (thin + shelter)
citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers
hud.tickToast(dt); 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) 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` 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) 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 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. // ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off.
// [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs // [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs
@ -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); // [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). // washing/magpie are shell-constructed; ambient is the §1.1 audio layer (read by audio.js).
yards: flagOn('yards'), washing: flagOn('washing') && flagOn('yards'), 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 THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with // [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so // zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so

150
web/js/world/discovery.js Normal file
View 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 };
},
};
}

View File

@ -2,6 +2,26 @@
// The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by // 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 // 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). // 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 = { const TYPE_COLOR = {
record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0', record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0',
@ -9,7 +29,13 @@ const TYPE_COLOR = {
house: '#6b5a48', anchor: '#5a6a7a', 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'); const wrap = document.createElement('div');
wrap.id = 'pc-map'; wrap.id = 'pc-map';
wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;' 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) { function draw(playerPos, fwd) {
drawStatic(); if (!known) drawStatic();
const x = tx(playerPos.x), z = tz(playerPos.z); 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) // heading (world +X→right, +Z→down on the map)
ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3; ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(x, z); ctx.beginPath(); ctx.moveTo(x, z);
@ -78,5 +291,18 @@ export function createMinimap(plan) {
draw, draw,
setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; }, setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; },
dispose: () => wrap.remove(), 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 };
},
}; };
} }

View File

@ -45,6 +45,11 @@ export function godbayHammer(entry, townKey) {
return { sold: true, hammer, net }; 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) ───────────────────────────── // ── validation — exact and loud (the delta law's falsifiable half) ─────────────────────────────
// A corrupted/foreign blob is REJECTED WHOLE: schema string must match exactly, every field must // A corrupted/foreign blob is REJECTED WHOLE: schema string must match exactly, every field must
// check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never // check. Rejecting loudly + starting fresh is the design — the save can cost you your stuff, never
@ -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` }; 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 }; return { ok: true, state: obj };
} }
@ -127,6 +162,12 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
let pulls = []; let pulls = [];
const PULLS_CAP = 600; // FIFO backstop — a save stays tiny (delta law) const PULLS_CAP = 600; // FIFO backstop — a save stays tiny (delta law)
const PULL_DAY_RE = /@d(\d+)#/; 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) ──────────── // ── storage (fail-soft: a blocked/full localStorage warns and plays on in memory) ────────────
function payload() { 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 (pulls.length) p.pulls = pulls; // absent when empty → alpha-era byte shape preserved
if (wants.length) p.wants = wants; // [R33] same convention if (wants.length) p.wants = wants; // [R33] same convention
if (listings.length) p.listings = listings; // [R34] same convention 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; 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 // [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. // all funnel here. One hammer per item (identity-seeded), so WHEN it resolves never changes WHAT.
function resolveAuctions() { 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). // pulls drop here, and the shell reads game.day/traveled at boot (onDay stays un-fired, §30.1).
if (state.town !== townKey) { day += 1; traveled = true; } if (state.town !== townKey) { day += 1; traveled = true; }
listings = Array.isArray(state.listings) ? state.listings.slice() : []; 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 resolveAuctions(); // [R34] a day-moving adopt (travel/import) resolves what it passed
prunePulls(); prunePulls();
} }
@ -372,6 +446,43 @@ export function createGame({ townKey, startCash = 0, storage = null, onDay = nul
return true; 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). // Drain the morning paper (transient — resolution wrote it, the shell toasts it once).
takeAuctionNews() { takeAuctionNews() {
const n = auctionNews; const n = auctionNews;