PROCITY/web/js/world/minimap.js
m3ultra 066cc1775b Lane B R40 §40.3 (+E's §40.4 rule): the 307 was fiction, the town speaks its street names
THE ?r=3 BREACH — the carried number was stale by 84 draws. Re-measured with the pin
method of record (stepwalk 2m, 802 stations, fresh headless contexts, no-store server):
default 282/291 (≤300, 9 margin at night) · classic 269 byte-exact to the R38 pin incl.
90,580 tris · ?r=3 382/391, NOT 307. Decision: GATE, not shave — the 91-draw excess is
the R+1 live-chunk window (48 vs 31 chunks) on a per-chunk cost already collapsed to
~1 draw/kind/chunk, so shaving it means cutting default-boot content to legalise a
diagnostic. ?r= above auto now declares PROCITY.budget {draws:420, diagnostic:true} and
console-warns its own law; HUD threshold + DBG.info().budget read it; new
tools/qa/r40_lane_b.py enforces it (386 ≤ 420, and >300 so the exemption is provably
load-bearing).

?classic=1 TOWN SELECTOR (Fable ruled): true by construction, now deliberate and
qa-asserted — 27 options, one fetch (the named POST_V2_EXCEPTION), picks stay classic,
zero draws, 0 errors.

FOG SIGNAGE: three silent surfaces consume A's address layer via new
createStreetLocator(plan) — HUD street row, door tooltip ('Little Paris Cafe ·
Katoomba Street'), fog-map caption. Never branches on town type; classic-gated by
construction (#pc-street absent, tooltip pre-R40-byte).

E's arcade rule applied verbatim (§40.4): roof spans, a spanned roof has no posts;
district.kind keying, explicit ARCADEKIT classic gate + ?arcadekit=0 control.
-34 post instances exactly, lane draws 120→120 (+0). Shot pair vs E's reference.

Goldens 157,647/157,647, 0x5f76e76 unmoved after every wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:28:08 +10:00

338 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane B — minimap.js
// 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',
pawn: '#c4a028', milkbar: '#c4863a', dept: '#5a6a7a', stall: '#5a8a6a',
house: '#6b5a48', anchor: '#5a6a7a',
};
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
// [Lane B R40 §40.3 — FOG SIGNAGE] optional street locator (discovery.createStreetLocator): lets
// the fog-arm caption say where YOU are standing ("· Katoomba Street"). Consumed ONLY inside the
// fog arm (`known !== null`), so the classic/null-arm map — caption included — stays byte-for-byte
// pre-R39 with no flag test here, exactly like `known` itself. The shell passes null under classic.
let locator = opts.locator || null;
let capBase = null, capLoc = null; // caption = capBase + (' · ' + where-you-are) + tail
const wrap = document.createElement('div');
wrap.id = 'pc-map';
wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;'
+ 'align-items:center;justify-content:center;flex-direction:column;font:13px -apple-system,sans-serif;color:#e8e0d0';
const canvas = document.createElement('canvas');
canvas.width = 720; canvas.height = 720;
canvas.style.cssText = 'background:#1b1e17;border-radius:10px;max-width:92vw;max-height:80vh;box-shadow:0 8px 40px #000';
const cap = document.createElement('div');
cap.style.cssText = 'margin-top:12px;opacity:.8';
cap.textContent = `${plan.name} — seed ${plan.citySeed} · press M to close`;
wrap.append(canvas, cap);
document.body.appendChild(wrap);
const ctx = canvas.getContext('2d');
// world→canvas transform (fit plan extent with margin)
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
const pad = 24, W = canvas.width, H = canvas.height;
const span = Math.max(maxX - minX, maxZ - minZ) + 40;
const cxw = (minX + maxX) / 2, czw = (minZ + maxZ) / 2;
const sc = (W - pad * 2) / span;
const tx = (x) => W / 2 + (x - cxw) * sc;
const tz = (z) => H / 2 + (z - czw) * sc;
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
function drawStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1b1e17'; ctx.fillRect(0, 0, W, H);
// streets
for (const e of plan.streets.edges) {
const a = nodes.get(e.a), b = nodes.get(e.b);
ctx.strokeStyle = '#3a3f34'; ctx.lineWidth = Math.max(3, e.width * sc);
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(tx(a.x), tz(a.z)); ctx.lineTo(tx(b.x), tz(b.z)); ctx.stroke();
}
// lots
for (const l of plan.lots) {
const s = shopByLot.get(l.id);
const col = TYPE_COLOR[s ? s.type : l.use] || '#666';
ctx.save();
ctx.translate(tx(l.x), tz(l.z)); ctx.rotate(-(l.ry || 0));
ctx.fillStyle = col;
ctx.fillRect(-l.w * sc / 2, -l.d * sc / 2, l.w * sc, l.d * sc);
// front-edge tick (toward the street)
ctx.fillStyle = 'rgba(255,255,255,.5)';
ctx.fillRect(-l.w * sc / 2, l.d * sc / 2 - 2, l.w * sc, 2);
ctx.restore();
}
}
// ── 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;
}
capBase = `${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`;
cap.textContent = capBase + (capLoc ? ` · ${capLoc}` : '') + ` · press M to close`;
}
// [Lane B R40 §40.3] where the player dot IS: the street under your feet by name, else the nearest
// shopfront's locality label — A's contract verbatim (`street || label`, print it, never test the
// town type; null is rendered by omission). Fog arm only; a throwing layer disables itself.
function refreshLocation(px, pz) {
if (!locator || !addresses || capBase == null) return;
let text = null;
try {
const eid = locator.edgeAt(px, pz);
if (eid != null && typeof addresses.streetOf === 'function') text = addresses.streetOf(eid);
if (!text && typeof addresses.localityOf === 'function') {
const sid = locator.shopNear(px, pz);
if (sid != null) { const loc = addresses.localityOf(sid); text = (loc && (loc.street || loc.label)) || null; }
}
} catch (err) { locator = null; return; }
if (text !== capLoc) {
capLoc = text;
cap.textContent = capBase + (capLoc ? ` · ${capLoc}` : '') + ` · press M to close`;
}
}
function draw(playerPos, fwd) {
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);
refreshLocation(playerPos.x, playerPos.z); // [Lane B R40] caption: where the dot is (µs; writes DOM only on change)
}
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);
ctx.lineTo(x + fwd.x * 16, z + fwd.z * 16); ctx.stroke();
ctx.fillStyle = '#ffd75e';
ctx.beginPath(); ctx.arc(x, z, 6, 0, Math.PI * 2); ctx.fill();
}
return {
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. [R40] + the optional street
// locator (fog-arm caption signage; the shell passes null under classic — see the header note).
setAddresses(a, loc) { addresses = a || null; locator = loc || null; cacheKey = null; lastCounts = null; capLoc = 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 };
},
};
}