# Lab Report — Terrain-Field Navigation, Unified Mover, Reweave Recast & MUD Harness **Sections:** §WALK-1 (junction deletion) · §WALK-1.5 (geo re-projection) · §WALK-2 (unified mover) · §WALK-3 (reweave recast) · §WALK-4 (invariant suite) · §WALK-5 (MUD harness) **Date:** 2026-06-25 · **Original status:** 🔒 SPEC — data shapes locked before any HTML/server edit **Verified:** 2026-08-14 (§DOC-02bk) — re-measured against live `play.html` (38,712 lines, 416 nodes) and `src/js/wbapi-server.js` **Verdict:** ✅ **FULLY SHIPPED.** Every step closed inside 20 hours of the lock. One design element (`FERRY_EDGES`) shipped and was then deliberately deleted; one background figure was wrong when written. > *History note (§DX-01e): the bare `file:line` citations in the original are 2026-06 pointers into a > 34,515-line file. They are kept as written and are **not** trustworthy today. Every anchor added by > the verification pass uses the live `` `symbol@line` `` form.* --- ## Abstract This report locked the data shapes and module boundaries for a redesign of the CodexOfConquest navigation core: one source of movement truth, shared by the single-player client and the multiplayer (MUD) server, sitting on a **geographic terrain field** — an equirectangular 1° lat/lon grid (360 cols × 90 rows, band 70°N→20°S) where the grid cell *is* the geographic bin — instead of a graph of stored edges and routing-stub nodes. Sea is impassable; the canonical reachability hub is **LHR** (Birka). Ten weeks later the contract holds. All five steps shipped, the invariants it specified are three of the sixteen `check:walk` CI gates, and **43 of 43 node codes it names still resolve** — the highest score in the verification corpus, where most design documents of this age score zero. --- ## I. Intention & Inspiration — what this buys the player The trigger was a bug you could feel in the hands. The world had accumulated **316 `junction:true` nodes** — routing stubs auto-generated by an older graph-repair tool, each rendering a *"Signpost says…"* screen. Walking three cells across open country meant reading two signposts. The map was technically connected and practically miserable. Three design commitments came out of that, and all three are still load-bearing: 1. **Movement should cost nothing to think about.** A step is refused for exactly two reasons — off the band, or sea. Never a quest, never a flag, never a signpost. This report is where the *"free movement"* invariant (CONTRIBUTING #1) acquires a *mechanism* rather than a promise: if the mover has no place to consult state, it cannot be taught to refuse. 2. **The world should be a real place.** Replacing an abstract `1..500` lattice with true lat/lon means Birka sits north of Rome because Birka *is* north of Rome. Distance becomes something the player can reason about from a map they already carry in their head — and the Mediterranean stops being a walkable field. 3. **One mover, two games.** The same kernel that moves the solo player moves every MUD session, so the multiplayer world cannot quietly drift into being a different world. The playability dividend is measurable rather than rhetorical. Junction screens: **316 → 0**. Reachable nodes from the start city: **416/416, one landmass**. And the shared kernel closed a latent server bug for free — before §WALK-2 the MUD server had no sea test at all, so a networked player could walk into the Atlantic. The engine says so itself, citing this report's own risk register: `` `src/js/wbapi-server.js:the shared kernel (mover.js) decides bounds/wrap/sea@8868` ``. --- ## II. Method (verification pass, 2026-08-14) Symbol census of every identifier and node code the report names (one batched `grep -c`), then `git log -S` on each dead symbol to separate **RETIRED** from **NEVER SHIPPED**; archive reads at `efa8f7a^` — the last commit before §WALK-1 — for every claim about the file's state on the day; the three §WALK gates run live; and a cross-check against the sibling report this one contradicts. --- ## III. As-Built Inventory | Spec element | Shipped as | Anchor | |---|---|---| | §2.1 grid `ROWS 90 / COLS 360` | exact | `` `const GEO_PROJ = { ROWS: 90, COLS: 360 }@9902` `` | | §2.2 locale lists (`CELL_GRID` → array) | exact | `` `const CELL_GRID = (() =>@9852` ``, `` `const cellCodes = (key) => CELL_GRID[key] || []@9862` `` | | §2.3 sea mask | `SEA_RUNS` (run-length), 4,790 cells / 286 runs, rows 0–85 × cols 140–255 | `` `const IMPASSABLE_CELLS = (() =>@9871` `` | | §2.3 water crossings | **`SEA_LANES`** — 59 walkable channel cells, *not* ferry edges | `` `const SEA_LANES = new Set@9870` `` | | §2.4 `terrainAt` / `inferTerrain` | `_inferTerrain` — lanes→`ocean`, roads→`road`, else 4-neighbour majority, fallback `midlands` | `` `function _inferTerrain(r, c)@28383` `` | | §4.1 `move(world,pos,dir) → MoveResult` | exact signature, pure, dual-export | `` `src/js/mover.js:function moverMove(world, pos, dir)@44` `` | | §4.2 inline-and-verify | `MOVER:CORE` sentinels, 1,847 bytes byte-identical | `src/scripts/check-mover-parity.js` | | §4.3 client caller | thin: kernel → `storyBlock` or side effects | `` `function cellMove(dir)@28345` `` | | §4.3 server caller | thin: kernel → session mutation + SSE | `` `src/js/wbapi-server.js:const Mover = require('./mover')@37` `` | | §5 reachability | land-flood, sole surviving graph tool | `` `src/js/wbapi-server.js:GET /api/graph/reachability ─@5493` `` | | §6.1 invariant proof I1/I2/I3 | `check:invariants`, CI gate #1 | `src/scripts/check-invariants.js` | | §6.2 walk parity (structural + behavioural) | `check:parity`, `check:behaviour`, gates #3/#4 | `src/scripts/check-mover-*.js` | | §7 MUD harness | `src/tests/mud-harness.mjs`, `npm run test:mud` | — | **Ground truth, then and now.** The §3.5 measurement table was byte-exact at its own tree and the invariant it asserted survived seven new nodes: | | Report (2026-06-25) | HEAD (2026-08-14) | |---|---|---| | `NODE_MAP` | 409 | 416 | | `NODE_COORDS` (0 missing) | 409 | 416 — still 0 missing | | GEO2 gazetteer | "~155 cities" | **155**, exact | | `junction:true` | 316 (verified at `efa8f7a^`) | 0 | **The locale mechanism carries more of the world than anyone expected.** 416 nodes occupy **244 cells**; **66 cells hold more than one node**, and **238 nodes (57%) live in a shared cell**. The largest is `32,203` with **17** — Athens beside sixteen Atlantis-layer interiors. Every collision the report predicted is exact ten weeks later: `LDN/LON/BRK → 18,179` · `HHL/ISL → 5,158` · `PIS/PSA → 26,190` · `LHR → 10,197`. Four for four. --- ## IV. Spec → Shipped Delta Table | # | Specified | Shipped | Verdict | |---|---|---|---| | 1 | `FERRY_EDGES` — bidirectional land↔land edges over sea, *"the only sanctioned re-introduction of stored edges"* | kernel branch shipped, **no data ever authored**, branch deleted `95c4143` (2026-06-26) | **RETIRED — superseded by `SEA_LANES`** | | 2 | `MoveResult.via: 'step' \| 'ferry'` | field ships; `'ferry'` unreachable since ①; **0 readers anywhere** | **VESTIGIAL** → §DX-02by | | 3 | `terrainAt` returns `'sea'` — *"new terrain key"* | no `sea` terrain exists; sea cells are never entered, lanes render as the pre-existing `ocean` | **NOT SHIPPED — unnecessary** | | 4 | `SEA_MASK` as a flat `Set` | `SEA_RUNS` run-length table → `IMPASSABLE_CELLS` | **SHIPPED, renamed** | | 5 | world = `{cellGrid, nodeMap, proj, seaMask, ferryEdges, impassable, terrainAt}` | `{proj, impassable, cellCodes, terrainAt, encounterRate}` | **narrowed** — 3 fields exact, 2 dropped with ①, `nodeMap` never needed | | 6 | I4 (on-land) in the invariant suite | §6.1 never listed I4; gate ships I1+I2+I3 | **as specified** (I4 holds anyway: 416/416 flood) | | 7 | *"the existing **40** terrain keys in `WORLD_DB`"* | `WORLD_DB` held **107** that day; **111** today | ❌ **WRONG WHEN WRITTEN** — see Finding 3 | | 8 | dead reweave body *"~1,200 lines"* at `5891–7075+` | **3,240 lines** deleted, 12,510 → 9,270 (`d602b7f`) | ❌ **undercount 2.7×** — the phases named (P1/P4/P5) were right | | 9 | retire `fix-all-broken` / `fix-bidirectional` | deleted with the reweave body rather than 410'd | **SHIPPED, different mechanism** | | 10 | retire `fill-gap` / `rip-and-connect` / `reweave-all` → 410 | all three 410 with a pointer to `reachability` | **exact** | | 11 | §7.1 blocked move → `200 {ok:false}` | **409** | delta (louder, and correct) | | 12 | §7.1 `s.state.encounter`, `s.huntMode` override | `s.encounter`; no server hunt mode | delta — instancing property holds | | 13 | §4.3 client step advances the clock | §TIMELESS-01 later made stepping free | **superseded** | --- ## V. Findings **1 — The report was right and its sibling was wrong, and the archive proves it.** §1 opens by contradicting `lab-report-cell-map-mud-redesign.md`, which recorded §CELL-05 as *"✅ complete, 0 junctions."* At `efa8f7a^` the file held **316**. Verified independently ten weeks later, at a tree neither author could see. *A design document that audits its predecessor is rarer than one that cites it, and it is worth roughly ten times as much.* **2 — The one exception to "no stored edges" was taken, then given back.** §2.3 argued for `FERRY_EDGES` as a bounded, water-only reintroduction of the edge graph the redesign was abolishing. It shipped as a kernel branch — and one day later `95c4143` removed it, with the plainest commit message in the repo: *"dead capability: `getMoverWorld()` never set it and there was no `FERRY_EDGES` data anywhere."* §WALK-1.5 had already solved the problem by **carving 59 sea cells into walkable channels**, so a crossing is *terrain*, not a permission. The shipped navigation core contains **zero** stored edges. The spec asked for one exception; the implementation took none, and free movement is stronger for it. **3 — The only figure the author copied from a document instead of the file is the only figure that is wrong.** §2.4 says *"the existing 40 in `WORLD_DB`"*. That day it held **107**; `index.md` said **66**; the source of the 40 is `docs/spec/spec-migration.md:46`, where it correctly describes the **Layer-0 combat tracker of 2026-05**, a world three engine-generations back. Nothing was invented and nothing was miscounted — *a number copied out of a history document arrives with its date stripped off.* Every figure this report measured itself is exact; the one it borrowed is 2.7× stale. **4 — Behavioural parity shipped stronger than specified.** §6.2 asked for *"a fixed seed + scripted dir sequence"*. `check:behaviour` instead sweeps **41,760 (cell × dir) decisions** — the whole grid — and reports **0 content mismatches**, with 245 deliberate edge/wrap-fringe differences (empty ocean, no named destination: the geo clamp replacing an old `r<1` block). The regression lock the report wanted as a trace is a proof. **5 — Reweave became a diagnosis, not a mutation, exactly as argued** — and this is the design idea with the longest half-life here. `GET /api/graph/reachability` is now the only graph tool that runs; the three mutators answer 410. Live at HEAD: **416/416 reachable, 1 component**. When a node strands, the fix is content (move it, or carve a lane), never an auto-generated stub. The stub category is gone, and `check:invariants` I2 exists to keep it gone. **Rejected approaches, kept because a rejection can only be written from the work.** §3.5.3's invert-and-reproject experiment — recovering lat/lon by inverting the old linear geo-seed — was dry-run and disproved on the spot: 409 nodes collapsed into 108 cells, with cell (10,197) holding Birka *and* Damascus *and* Nuremberg. The abstract grid was never a trustworthy geographic source. Likewise §3's deletion-over-transparency call: transparency would have kept a class of node that exists only to be ignored, and every consumer — mover, audit, reachability, MUD `buildLook` — would have had to special-case it. Deleting the category cost one commit and no branches. --- ## VI. Risk-Register Outcome | Risk as filed | Outcome | |---|---| | 1° city collisions break render/quest/`buildLook` paths | **Materialised and handled.** 66 shared cells, 238 nodes; every read site goes through `cellCode`/`cellCodes`. | | `SEA_MASK` strands a coastal node | **Handled by design** — node-occupied cells are forced to land (snap-to-land reconciliation); I3 green at 416/416. | | Re-projection drift leaves nodes on `1..500` coords | **Closed** by the gazetteer pass (`96d7ef4`, 254 non-GEO2 nodes) — 0 missing coords. | | Equirectangular distance skew near 70°N | **Still live, still correct.** Movement is timeless (§TIMELESS-01), so no travel time is derived from cell count. | | Promoting vs. deleting the 316 | Predicate extended to `"Signpost says:"`; `J13`/`WRO` promoted and both are live named nodes today. | | Single-file guarantee vs. shared module | **Closed** — `MOVER:CORE` byte-parity gate, 1,847 bytes. | | Server bounds drift (*"a latent server bug fixed for free"*) | **Closed, and the engine cites this line.** | **Seven risks, seven correct calls.** The risk register is usually where these documents go wrong; here it is the strongest section. --- ## VII. Defects Filed - **§DX-02by** 🟢 — `MoveResult.via` is write-only. Set in both kernel copies, read in zero places; `'ferry'` has been unreachable since `95c4143`, so the field is fully determined by `ok`. Removing it touches a byte-parity fence, so it is a re-inline, not a one-liner. - **§AUDIT-03bd** 🟢 — `check:invariants`' WORLD_DB key scan (`src/scripts/check-invariants.js:127`) matches bare identifiers only, so it counts **110** of the live **111** terrains, missing the quoted key `'bar (Visby)'`. The NODE_MAP side of the same gate carries a stale-regex guard; this side does not. One node authored onto that terrain would produce a false red on CI gate #1. - **§DX-02bm** (already open, filed §DOC-02ay) — `TERRAIN_ENCOUNTER_RATE.junction:0` survived the junction purge. §WALK-4 removed the `WORLD_DB` twin and left a tombstone comment at `` `terrain was removed here@6402` ``; the encounter-rate key two hundred lines below was missed. Unreachable: no node carries `name:'junction'`, so `_inferTerrain` can never return it. --- ## VIII. Verification Record **Live gates:** `check:invariants` → `I3 reachable=416/416 · unreachable=0 · components=1`; `I2 junction:true=0 · WORLD_DB.junction=false`; `I1 node-terrains=416 · missing=0`. `check:parity` → MOVER:CORE identical, 1,847 bytes. `check:behaviour` → 41,760 decisions, 0 content mismatches. **Symbol census:** 43 of 43 node codes resolve (100%). Dead symbols: `FERRY_EDGES` (11 commits → RETIRED), `ferryEdges` (8 → RETIRED), `SEA_MASK` (7 → renamed `SEA_RUNS`), `fix-all-broken` (31) and `fix-bidirectional` (22) → **RETIRED, not never-shipped** — the naïve path-scoped `git log -S` reads zero for both and is wrong. **Spec → close interval:** §WALK-1 committed `efa8f7a` **2026-06-25 17:43**, an hour *before* this file's own mtime; the series closed `95c4143` **2026-06-26 13:33**. Under **20 hours**, sixteen commits, spec to full closure. That interval is the explanation for the accuracy score: this is a document written *beside* the implementation, not from memory of it — which is why its inventories read like inventories and its one borrowed figure reads like a summary. --- *© 2026 Paul Richeson — MIT License.*