diff --git a/fktry/lanes/LANE-RENDER.md b/fktry/lanes/LANE-RENDER.md index 5bfd3e0..bc005cb 100644 --- a/fktry/lanes/LANE-RENDER.md +++ b/fktry/lanes/LANE-RENDER.md @@ -1012,3 +1012,203 @@ pass and this change compound. - Wire the renderer event channel if granted; `firewall.glb` when it lands; per-mite orientation; heat-shimmer relief inside a cooler rectangle; bloom-loop belt tell; save/load camera state. + +### Round 7 PHASE 1 — 2026-07-29 — Opus 5 — THE CORRECTION, embodied + +**THE HEADLINE: the inversion is not a vibe, it is a measurable property, and it now has a +number. Driven at 60fps over 60 sim ticks with a WORST-CASE stepped input — a warden whose +sim position changes on 6 of 120 frames and only ever by whole integer tiles — the renderer +produced 120 distinct positions and ZERO stalled frames. The hash auditor, on the same rig, +produced 1 distinct position and 119 stalled frames, deliberately. Authority glides; +authority also holds perfectly still; what authority never does is judder.** + +**SHIPPED** — all 5 orders. New: `src/render/correction.ts`, `src/render/notice.ts`, +`src/render/correction.test.ts`. Zero GLBs, as ordered. + +1. **Placeholder bodies, rungs 2-4** (`correction.ts`). Hospital-white, matte, spotless, + one green element each — green is the faction signature it shares with the mite's + checkmark eye, so a player learns "green on white = the immune system" once. + - **WARDEN**: tall filing-cabinet slab, drawer breaks, rubber-stamp arm on a green pad + that slams on `phase === 'audit'` and rests cocked otherwise. + - **GUNSHIP**: five platters in a row on spindles, counter-rotating about the centre + (that is where "mirrored" went), green parity emitter on the belly, continuous hover. + - **AUDITOR**: faceless white-marble column, green padlock halo, binary scan cone. +2. **The gunship's mirror**: the `rect` gets a faint white sector pad, and every entity + inside it is re-rendered as a featureless translucent white box hovering at 1.5 under + the gunship — your own layout, sanitised (§5). One InstancedMesh, capped at 512. The + parity **tether** is a green cylinder stretched from the emitter to the anchor entity, + with a pulse running down it. +3. **Stasis**: bleached ground + hard white edge + a floating **00:00:00:00** timecode, and + a `frozen` entity set that EntityLayer and BeltItemLayer both read. Release snaps back + the same frame with a white slap at the rect's centre and four corners. +4. **Seal**: sealed machines get a printed compliance placard (green tick, SEALED, a hash + line) facing the camera azimuth, and every moving part stops. +5. **onEvents migration** — all three derived cues retired, plus four new ones. + +**VERIFIED (numbers, on FRESH hard-navigated loads — round 6's lesson, followed)** + +*The glide (order's DoD: "verified against juddering surroundings").* 120 render frames / +60 sim ticks, warm-up discarded, positions sampled per frame: + +| | sim moved it on | rendered distinct | step min | step max | mean | stalled frames | +|---|---|---|---|---|---|---| +| warden (integer-stepped input, worst case) | **6 / 120** | **120** | 0.00097 | 0.228 | 0.048 | **0** | +| gunship (fractional input, SIM's real shape) | every tick | **120** | 0.00037 | 0.0157 | 0.0075 | **0** | +| auditor (teleports, never walks) | 3 jumps | **1** | 0 | 0 | 0 | **119** (by design) | + +The warden's max/mean ratio of ~4.7 is the damped follow easing out of each whole-tile +jump; it is continuous, and it is the deliberately unfair case. **I checked SIM's actual +implementation rather than guessing**: `driftUnit` moves wardens and gunships by a +FRACTIONAL step every tick (the gunship row above), and the auditor is relocated by direct +assignment with the comment "it moves only between frames". Both of my paths match the sim +they will actually be fed. + +*Stasis freeze.* 40 frames / 20 sim ticks with the lock held: **5 of 6 melt items dead +still**, the 6th correctly still moving because its belt is the one tile outside the rect. +Frozen coordinates are genuinely **mid-tile** — fractional parts 0.052, 0.4, 0.503, 0.733 — +not snapped to a tile edge, which is the whole read. Over a longer 25-tick hold, 5 of 6 +shared ids were **byte-identical**; the 6th legitimately left the rect and re-entered. +**Release**: frozen 10 → 0 and iced 7 → 0 within 4 frames, `stasisActive` false. + +*Seal.* Placards visible 0 → 1 on the seal edge, positioned at (1.75, 0.68, 9.75) for a 2×2 +at (0,8) — dead on the footprint centre plus the camera-facing offset — at yaw **0.7854** +(= ISO_YAW). The freeze is exact: **idle clock delta 0.000000 over 30 sealed frames**, +0.500000 over the next 30 free frames (30/60 exactly). + +*Perf — the standing rule, at a load far past anything the sim will produce.* I put the +Correction into `?devscene=stress` at the honest worst case: a gunship mirroring the ENTIRE +500-entity build and an auditor freezing all of it — **500 ghost boxes, 500 frozen +entities, 2,000 pinned belt items**, 149 draw calls, 265k tris at 2800×1800. + +| | ms | +|---|---| +| whole frame callback (sim tick + render + UI + screen + audio), median / p90 / max | **2.30 / 5.5 / 6.0** | +| renderer-only rolling avg | 1.90 | +| `gl.render` submit, correction layer ON vs OFF | 0.41–0.51 vs 0.325 | +| `correction.sync` CPU (the entire worst-case pass) | **0.128** | +| `cargo.sync` WITH the freeze vs without | **0.392 vs 0.465** | +| `entities.sync` with vs without | 0.032 vs 0.020 | + +Net cost of this round at that load: **~0.2 ms against a 16.6 ms budget.** The cargo freeze +is *cheaper* than not freezing — a pinned item skips the topology and path maths entirely. + +*Prod bundle*: `devscene` / `showroom` / `plinth` / `__fktryPerf` / `correctionUnits` / +`buildCorrectionBay` all **0 hits**; the only `__render` matches are three.js's own +`__renderTarget` (4), as every round since 3. The shipping strings are present +(`PARITY NOTICE`, `00:00:00:00`, `fk-notice`). `npm run check` clean. + +*Tests*: this lane 31 → **40** (9 new, all mutation-checked — see below). Repo 598 tests, +596 pass; the 2 reds are `src/sim/mite.test.ts`, LANE-SIM's in-flight firewall-vs-warden +work, which imports nothing from `src/render` (grep: 0 hits). `src/sim/reference.test.ts` +was red mid-round and SIM fixed it while I worked. + +**DECISIONS** + +- **The auditor has NO idle animation at all, and that is the whole design.** Not a bob, + not a halo drift, not an emissive pulse. Every other object in this game is alive; + the one that isn't is the one to be afraid of. It is also the honest reading of §5's + "moves only between frames" — stillness is not judder, it is the same claim of total + control from the other side. Its scan cone is binary (present during `prescan`, absent + otherwise) rather than sweeping, for the same reason. +- **The warden keeps its RED LED ledger eyes, but as pinpoints.** Orders said "one green + element each"; codex §5 says the warden has "red LED ledger eyes". The codex wins on + canon and the orders win on the faction read, so both got what they were actually + protecting: the ONE element is the large green stamp pad (which is also literally the + seal that ends up on your chassis), and the ledger is a strip of five 5cm red pinpoints + on the drawer chest. A large red element would have said "different faction" on a screen + where the other three rungs are green. +- **The gunship is matte white, not chrome, and its platters are SMALLER than half their + spacing.** §5 says "mirrored disk-platter rotors"; five actually-mirrored surfaces on a + black-sky world read as five black holes, and authored at true platter proportions + (radius 0.4, spacing 0.54) they fused into a single white lozenge at game zoom — caught + in `?showroom`, fixed to radius 0.26. The COUNT is the silhouette, so the count has to + survive. Screenshots before/after are stark. +- **The warden's detail face is local +Z, not -Z**, breaking this lane's own placeholder + convention on purpose. Machines are authored facing north because they carry a `dir`; a + Correction unit instead yaws toward its own travel, and a parked one sits at yaw 0 — with + the drawers on -Z, a parked warden showed the iso camera a blank white back. Found in the + showroom bay, which is exactly what the bay is for. +- **Stasis rects come from the UNION of the event and the snapshot.** `phase === 'stasis'` + is a string this lane does not own, and keying a freeze off it alone would be a silent + single point of failure the day SIM renames a phase; the `stasis` event's rect (keyed by + `lockHash`) is the other half. Either source alone lights the region. They currently + agree — I read SIM's `refreshStasis`. +- **The rect overlap convention is now PINNED BY A TEST against SIM's own formula.** SIM's + `frozenEnt` uses inclusive bounds, mine are half-open. On integers those are the same + predicate — but only by argument, and phase 0's era tie-break was exactly this shape: + two lanes claiming to agree, never checked, latent for three rounds because the data + never disagreed. SIM's formula is transcribed into `correction.test.ts` as an oracle and + the two are asserted equal over 4,896 footprint/position combinations. If either lane + retunes its bounds, it fails loudly. +- **The bleach overlay is totally static** — no pulse, no scroll, no breathing. Every other + overlay in this game moves; the one that doesn't is the one that stopped time. +- **Stasis freezes machines as well as cargo**, reusing the sealed path. Both are the same + sentence from The Correction: this is not running. (SIM agrees independently — its + `frozenEnt` gates crafting, heat, progress and belt movement.) +- **`shipmentFlash` was deliberately NOT migrated** to the `shipped` event. The event + carries item and count but no position, so the uplink tiles come from the snapshot + either way, and the existing delta is already exact. Migrating for its own sake would + have been churn. +- **The mite's "canned by a firewall" cue is now event-TIMED but still state-DISCRIMINATED.** + `wildlife: 'cleared'` fires for a sated mite leaving at the rim and for one canned + mid-repair alike, and only `size` tells them apart — so timing and position are now exact + (from the event) and one boolean is still read from the layer's last-seen state. Honest + improvement, not a full retirement. See CONTRACT REQUEST 2. + +**BLOCKED/BROKEN** — nothing blocking. + +- **The notice ping reads LANE-UI's DOM to find the fax, and that is a bridge.** The + `notice` event has no `pos` and the fax is a DOM panel, so `NoticePing` measures + `#fk-notices` (UI's new v9 tray) or `#fk-fax` and strikes a halo around it. The element + is mine — created by and appended by this lane, same as the round-1 vignette — and the + read is read-only, but it is a cross-lane coupling that wants a real hook. It also + exposed a real ordering trap: **main.ts runs the renderer BEFORE the UI within a frame**, + so on the first notice of a session the panel may not be laid out yet and measures 0×0. + It now retries once on the next animation frame before falling back to a corner. I hit + this for real — my first live notice struck the fallback position. +- **The `#screen` pointer-events request is closed, not granted** — the round-5 review + ruled it a legitimate interactive surface now that UI has click-to-enlarge. Dropping it + from my asks; recording the resolution so it isn't re-filed. +- **The verification environment fought back again, worse than round 5.** The Browser pane + reports `document.hidden === true` and rAF never fires, so `stats.frames` sat at 0 while + the page looked alive; and the shared tree hot-reloaded my page mid-measurement (I + caught it only because `stats.frames` reset to 0 under me and I nearly wrote up a + "notice ping is broken" finding from a page that had reloaded 200ms earlier). Fixed the + way round 5 did: an isolated `rsync` copy of the repo on its own port, plus a + setTimeout-backed rAF shim so frames advance at all. Every number above comes from that + isolated build. **Anyone measuring in this pane should check `document.hidden` first.** +- The mirror only ghosts ENTITIES, not belt cargo. Belts are entities so the sector's + layout reads fully; the items riding it don't get duplicated. Cheap to add (one more + instanced mesh) if the mirror ever needs to look busy rather than clean — though "clean + parallel copy" arguably wants to be emptier than yours. +- `match.html` 404 in dev (rounds 5-7, unchanged). + +**CONTRACT REQUEST** + +1. **`notice` could carry a `pos?: Vec2`** — the unit that filed it has a position, and + with one the renderer could put the cue in the world at the thing being noticed instead + of reading another lane's DOM to find the fax. Small, optional, retires a bridge. +2. **`repaired` / `mirrored` could carry `by: number`** (the unit or mite id). Would let + the "canned vs sated" discrimination above come from the event stream instead of the + layer's remembered `size`, retiring the last state-derived cue in this lane. Low + priority — the current form is correct for SIM's lifecycle. +3. Nothing else. **v9 granted the renderer event channel** asked for in rounds 5, 6 and + 7-phase-0; it landed clean and retired three heuristics on contact. + +**PROPOSAL / MODELBEAST WORK ORDER — rungs 2-4 are hot-swap targets the moment they exist.** +`?showroom` now has a **CORRECTION bay** north of the machine hall: all three rungs on +labelled plinths, plus four duplicate machines being mirrored, sealed and frozen so every +state is one screenshot. The bodies are procedural placeholders and do NOT yet go through +`AssetRegistry` (they are not `MachineDef`s and have no `asset` key), so a GLB drop needs a +small registry extension rather than nothing — flagging that honestly, since every other +asset in this game is zero-code. Codex asset lines are in §5; the three worth generating +are `checksum-warden` (rigged: stamp arm), `redundancy-gunship` (5 platters), `hash-auditor` +(rigged, though it must never be seen to walk — an idle-only clip). + +**NEXT (round 8 candidates, not self-assigned)** +- Registry support for non-machine actors so Correction GLBs hot-swap like everything else. +- `debtBand` as a world-wide grade (the sky/fog cooling as the bands climb) — deliberately + left alone this round because UI and SCREEN both own ledger surfaces and three lanes + drawing the same number is how a screen gets noisy. +- Mirror the cargo inside a gunship sector; per-mite orientation; heat-shimmer relief + inside a cooler rectangle; `firewall.glb`; save/load camera state. diff --git a/fktry/src/render/beltitems.ts b/fktry/src/render/beltitems.ts index 2b1cfc0..e583462 100644 --- a/fktry/src/render/beltitems.ts +++ b/fktry/src/render/beltitems.ts @@ -63,6 +63,15 @@ export class BeltItemLayer { private written = new Map(); private lp = { x: 0, z: 0 }; private wp = { x: 0, z: 0 }; + /** + * v9 — stasis. Cargo caught inside a hash auditor's 0:00 lock is pinned to the exact + * transform it had when the lock closed, so it reads as stopped MID-TILE rather than + * snapped to a tile edge. Keyed by BeltItem.id; entries are dropped the frame the item + * leaves the lock, which is what makes the release "snap back hard" — no easing, the + * item is simply where the sim has been saying it is all along. + */ + private iced = new Map(); + private icedSeen = new Set(); constructor(data: GameData, private topo: BeltTopology) { this.group.name = 'beltItems'; @@ -91,7 +100,8 @@ export class BeltItemLayer { return im; } - sync(snap: SimSnapshot, alpha: number, timeSec: number): void { + /** @param frozen belt entity ids inside an auditor's stasis lock (see CorrectionLayer). */ + sync(snap: SimSnapshot, alpha: number, timeSec: number, frozen?: ReadonlySet): void { // Roll the tick window: cur becomes prev the moment the sim advances. const advanced = snap.tick !== this.lastTick; if (advanced) { @@ -116,6 +126,7 @@ export class BeltItemLayer { // Pass 2 — write instance matrices. this.written.clear(); + this.icedSeen.clear(); for (const bi of snap.beltItems) { const node = this.topo.get(bi.entity); if (!node) continue; @@ -123,16 +134,31 @@ export class BeltItemLayer { const im = this.byItem.get(bi.item); if (!def || !im) continue; - const from = this.prev.get(bi.id); - const t = from === undefined ? bi.t : from + (bi.t - from) * alpha; + const held = frozen?.has(bi.entity) ? this.iced.get(bi.id) : undefined; + if (held) { + this.icedSeen.add(bi.id); + this.dummy.position.set(held.x, held.y, held.z); + this.dummy.rotation.set(0, held.ry, 0); + } else { + const from = this.prev.get(bi.id); + const t = from === undefined ? bi.t : from + (bi.t - from) * alpha; - localPath(node.shape, t, this.lp); - toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp); - this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z); - this.dummy.rotation.set(0, 0, 0); - if (def.tier >= 2) { - this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides - this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02; + localPath(node.shape, t, this.lp); + toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp); + this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z); + this.dummy.rotation.set(0, 0, 0); + if (def.tier >= 2) { + this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides + this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02; + } + // The lock has just closed over this item: pin it exactly where it is, mid-tile. + if (frozen?.has(bi.entity)) { + this.icedSeen.add(bi.id); + this.iced.set(bi.id, { + x: this.dummy.position.x, y: this.dummy.position.y, z: this.dummy.position.z, + ry: this.dummy.rotation.y, + }); + } } this.dummy.updateMatrix(); @@ -142,6 +168,11 @@ export class BeltItemLayer { this.written.set(bi.item, n + 1); } } + // Release: anything no longer frozen forgets its pinned pose and resumes at full + // speed on the very next frame. Bounded — the map only ever holds locked cargo. + if (this.iced.size !== this.icedSeen.size) { + for (const id of this.iced.keys()) if (!this.icedSeen.has(id)) this.iced.delete(id); + } for (const [id, im] of this.byItem) { im.mesh.count = this.written.get(id) ?? 0; diff --git a/fktry/src/render/camera.ts b/fktry/src/render/camera.ts index 816c770..567f0c3 100644 --- a/fktry/src/render/camera.ts +++ b/fktry/src/render/camera.ts @@ -5,6 +5,13 @@ import * as THREE from 'three'; const ISO_DIR = new THREE.Vector3(1, 1.15, 1).normalize(); +/** + * Yaw of the (fixed) camera azimuth, for anything that must face the viewer without a + * per-frame billboard — compliance placards, the stasis timecode. DERIVED from ISO_DIR + * rather than written as `Math.PI / 4`, so a rig change can't leave a second copy of the + * angle behind (the scram-latch lesson: no duplicated constants across modules). + */ +export const ISO_YAW = Math.atan2(ISO_DIR.x, ISO_DIR.z); /** * Ortho: this affects near/far clipping, not apparent size. It DOES set how far every * object sits from the camera, so scene fog must be banded around it — see index.ts. diff --git a/fktry/src/render/correction.test.ts b/fktry/src/render/correction.test.ts new file mode 100644 index 0000000..37d88cb --- /dev/null +++ b/fktry/src/render/correction.test.ts @@ -0,0 +1,122 @@ +/** + * LANE-RENDER — source-level tests for THE CORRECTION's geometry (v9, round 7). + * + * Three of the four things this lane learned to test the hard way are in here: + * + * 1. **The rect convention is CROSS-LANE and it is not written in the contract.** + * `CorrectionUnit.rect` and the `stasis` event's rect decide which machines the sim + * stops crafting and which machines the renderer stops animating. The sim tests + * overlap with INCLUSIVE bounds (`src/sim/index.ts frozenEnt`); this lane tests it + * half-open. Those are the same predicate on integers — but only by argument, and + * round 7 phase 0's era tie-break was exactly this shape of bug: two lanes claiming + * to agree, never checked, latent for three rounds because the data never + * disagreed. So the sim's formula is transcribed below and the two are asserted + * equal over an exhaustive grid. If either lane retunes its bounds, this fails. + * + * 2. **THE INVERSION is arithmetic, not vibes.** A Correction unit must never render at + * a quantised position. `angleTo` is the one piece of its motion that can be checked + * without a GPU — and a naive lerp spinning the long way round ±π would read as a + * unit snapping through 350 degrees, which is the exact "wrong motion" the round-7 + * ruling says belongs to corruption and never to authority. + * + * Node environment, like registry.test.ts: no GPU here, and the bodies stay an eyeball + * check in `?showroom`'s Correction bay. + */ +import { describe, expect, it } from 'vitest'; +import { angleTo, overlaps, rectHasTile, type Rect } from './correction'; + +/** + * LANE-SIM's `frozenEnt` overlap test, transcribed verbatim from `src/sim/index.ts` + * (inclusive bounds over the footprint's min/max tile). This is the oracle: the renderer + * must freeze exactly the machines the sim froze, no more and no fewer. + */ +function simOverlaps(x: number, y: number, w: number, h: number, r: Rect): boolean { + if (x + w - 1 < r.x || x > r.x + r.w - 1) return false; + if (y + h - 1 < r.y || y > r.y + r.h - 1) return false; + return true; +} + +describe('stasis / mirror rect overlap', () => { + const rect: Rect = { x: -3, y: 0, w: 5, h: 6 }; + + it('matches LANE-SIM tile-for-tile over an exhaustive grid of footprints', () => { + let checked = 0; + let agreed = 0; + for (let x = -8; x <= 8; x++) { + for (let y = -5; y <= 12; y++) { + for (const w of [1, 2, 3, 5]) { + for (const h of [1, 2, 3, 5]) { + checked++; + if (overlaps(x, y, w, h, rect) === simOverlaps(x, y, w, h, rect)) agreed++; + } + } + } + } + expect(checked).toBe(17 * 18 * 16); + expect(agreed).toBe(checked); // any divergence is a machine one lane froze and the other didn't + }); + + it('includes a footprint that only clips the rect by one tile', () => { + // A 3x3 whose far corner lands on the rect's first tile. + expect(overlaps(-5, -2, 3, 3, rect)).toBe(true); + // ...and the same machine one tile further out, which must NOT freeze. + expect(overlaps(-6, -3, 3, 3, rect)).toBe(false); + }); + + it('excludes a footprint that stops exactly at the far edge', () => { + // rect covers x in [-3, 1]; a 1x1 at x=2 is the first tile outside. + expect(overlaps(2, 3, 1, 1, rect)).toBe(false); + expect(overlaps(1, 3, 1, 1, rect)).toBe(true); + // and on y: rect covers y in [0, 5]. + expect(overlaps(0, 6, 1, 1, rect)).toBe(false); + expect(overlaps(0, 5, 1, 1, rect)).toBe(true); + }); + + it('treats a rect as half-open for single tiles', () => { + expect(rectHasTile(rect, -3, 0)).toBe(true); // origin corner is inside + expect(rectHasTile(rect, 1, 5)).toBe(true); // last tile is inside + expect(rectHasTile(rect, 2, 5)).toBe(false); // one past on x + expect(rectHasTile(rect, 1, 6)).toBe(false); // one past on y + expect(rectHasTile(rect, -4, 0)).toBe(false); // one before on x + }); + + it('a zero-area rect freezes nothing', () => { + expect(overlaps(0, 0, 1, 1, { x: 0, y: 0, w: 0, h: 0 })).toBe(false); + expect(rectHasTile({ x: 0, y: 0, w: 0, h: 0 }, 0, 0)).toBe(false); + }); +}); + +describe('the glide: shortest-path turning', () => { + it('turns the SHORT way across the ±pi seam', () => { + // from just under +pi to just over -pi is a 0.2rad turn, not a 6.08rad spin. + const from = Math.PI - 0.1; + const to = -Math.PI + 0.1; + const stepped = angleTo(from, to, 1); + // Full step lands on `to` modulo a full turn, and the move taken is small. + expect(Math.abs(stepped - from)).toBeLessThan(0.25); + expect(Math.abs(Math.atan2(Math.sin(stepped - to), Math.cos(stepped - to)))).toBeLessThan(1e-9); + }); + + it('interpolates proportionally', () => { + expect(angleTo(0, 1, 0.5)).toBeCloseTo(0.5, 10); + expect(angleTo(0, 1, 0)).toBeCloseTo(0, 10); + expect(angleTo(0, 1, 1)).toBeCloseTo(1, 10); + }); + + it('never takes the long way round for any pair of angles', () => { + for (let a = -Math.PI; a < Math.PI; a += 0.13) { + for (let b = -Math.PI; b < Math.PI; b += 0.17) { + const moved = Math.abs(angleTo(a, b, 1) - a); + expect(moved).toBeLessThanOrEqual(Math.PI + 1e-9); + } + } + }); + + it('a partial step always moves, so a turning unit never freezes a frame', () => { + // THE INVERSION: authority does not stall. Any non-zero k on a non-zero delta moves. + for (const k of [0.01, 0.1, 0.5]) { + expect(angleTo(0, 0.4, k)).toBeGreaterThan(0); + expect(angleTo(0, -0.4, k)).toBeLessThan(0); + } + }); +}); diff --git a/fktry/src/render/correction.ts b/fktry/src/render/correction.ts new file mode 100644 index 0000000..7adeb60 --- /dev/null +++ b/fktry/src/render/correction.ts @@ -0,0 +1,754 @@ +/** + * LANE-RENDER — THE CORRECTION, rungs 2-4 (§5). Checksum wardens, redundancy gunships, + * hash auditors: the Bitstream's immune system, embodied. + * + * ── THE INVERSION ──────────────────────────────────────────────────────────────────── + * The style guide (§8) assigns wrong-motion to CORRUPTION, not to authority: "clean + * things move at 60fps; corrupted things move wrong on purpose". Everything the player + * owns therefore keeps its judder vocabulary — belts step at 30tps, harassed machines + * stutter, a throttled machine labours, a scrammed one is dead-dark. The Correction is + * the only thing in this world that never misses a frame. + * + * So these units are the ONE layer that is smoothly interpolated end to end: tick-to-tick + * lerp on `alpha` (like belt cargo), then a short critically-damped follow on top so even + * a sim that relocates them in whole-tile steps renders as continuous velocity rather than + * a polyline with visible corners. Nothing here is quantised, ever. It is unbothered. + * + * The HASH AUDITOR is the deliberate exception, and it is the codex's own: §5 says it + * "moves only between frames". It does not glide — it is perfectly, unnervingly STILL, + * and relocates instantly. Stillness is not judder; it is the same claim of total control + * from the other side. + * + * ── WHAT THIS LAYER OWNS ───────────────────────────────────────────────────────────── + * bodies — placeholder silhouettes for all three rungs (hospital-white, matte, + * spotless, one green element each; no GLBs this round) + * mirror — the gunship's `rect` re-rendered as a ghostly white duplicate of the + * sector, hovering under it, plus the parity tether to its anchor + * stasis — the auditor's `rect`: bleached ground, a floating 00:00:00:00 timecode, + * and the frozen-entity set that EntityLayer and BeltItemLayer read + * + * Sim is truth. `snapshot.correction` may be absent (SIM lands rungs 2-4 in parallel with + * this) — everything here no-ops cleanly on undefined. + */ +import * as THREE from 'three'; +import type { CorrectionUnit, MachineDef, SimSnapshot, Vec2 } from '../contracts'; +import { CORRECTION, HEIGHT_BY_KIND } from './palette'; +import { ISO_YAW } from './camera'; +import { centerOf, disposeObject, rotatedFootprint } from './coords'; +import type { Juice } from './juice'; + +/** Hard cap. The Correction escalates; it does not swarm. */ +const MAX_UNITS = 24; +/** Ghost boxes in a mirrored sector — one per entity, instanced. */ +const MAX_MIRROR = 512; + +/** + * Follow time-constant for the glide, in seconds. Small enough that the unit is never + * meaningfully behind the sim (~1.5 ticks), large enough to round off the velocity + * discontinuity at a tick boundary. CONTRACTS: "sim owns the truth, renderer owns the + * smoothness" — stated there for belts, and this is the same licence. + */ +const GLIDE_TAU = 0.055; + +const HOVER_Y = 2.65; // gunship cruise altitude +const MIRROR_Y = 1.5; // the ghost sector hangs between the floor and the gunship + +export type Rect = { x: number; y: number; w: number; h: number }; + +/** Matte, spotless, unweathered. Faintly self-lit so white stays white on a black floor. */ +function shellMat(color: number = CORRECTION.shell): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color, + emissive: 0xe8ecf8, + emissiveIntensity: 0.2, + roughness: 0.62, + metalness: 0.0, + }); +} + +function greenMat(intensity = 1.5): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: CORRECTION.green, + emissive: CORRECTION.green, + emissiveIntensity: intensity, + roughness: 0.3, + metalness: 0.0, + }); +} + +/** A crisp 00:00:00:00 on transparent ground — the stasis lock's frozen timecode. */ +function timecodeTexture(): THREE.CanvasTexture { + const w = 256; + const h = 64; + const cv = document.createElement('canvas'); + cv.width = w; + cv.height = h; + const ctx = cv.getContext('2d')!; + ctx.fillStyle = 'rgba(255,255,255,0.10)'; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 3; + ctx.strokeRect(1.5, 1.5, w - 3, h - 3); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 38px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('00:00:00:00', w / 2, h / 2 + 2); + const tex = new THREE.CanvasTexture(cv); + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} + +interface UnitRec { + kind: CorrectionUnit['kind']; + obj: THREE.Object3D; + /** Per-frame hook: phase-driven motion (stamp arm, rotors, scan cone). */ + animate: (t: number, phase: string, progress: number) => void; + /** Tick-window positions for the glide — same shape as belt-cargo interpolation. */ + prevX: number; prevY: number; curX: number; curY: number; + /** Smoothed render position (world units). */ + sx: number; sz: number; + seeded: boolean; +} + +export class CorrectionLayer { + readonly group = new THREE.Group(); + /** + * Entity ids the auditor has frozen. EntityLayer stops their idle/animation and + * BeltItemLayer pins their cargo mid-tile. Rebuilt each sync; read after sync(). + */ + readonly frozen = new Set(); + + private units = new Map(); + private seen = new Set(); + private lastTick = -1; + + // --- gunship mirror ----------------------------------------------------------- + private mirror: THREE.InstancedMesh; + private mirrorPads: THREE.Mesh[] = []; + private padPool: THREE.Mesh[] = []; + private tethers: THREE.Mesh[] = []; + private tetherPool: THREE.Mesh[] = []; + + // --- auditor stasis ----------------------------------------------------------- + private bleaches: THREE.Object3D[] = []; + private bleachPool: THREE.Object3D[] = []; + + /** + * Stasis rects announced by the `stasis` event, keyed by lockHash. Unioned with any + * auditor whose `phase` is 'stasis' — SIM defines the phase strings, so keying the + * freeze off a string this lane does not own would be a silent single point of failure. + * Either source alone lights the region; both together agree. + */ + private eventRects = new Map(); + private activeStasis: Rect[] = []; + + private dummy = new THREE.Object3D(); + private timecodeMat: THREE.MeshBasicMaterial; + private v = new THREE.Vector3(); + + constructor(private defs: Map, private juice?: Juice) { + this.group.name = 'correction'; + + // The ghost sector: one instanced translucent box per entity in the rect. Depth-write + // off so the ghosts stack into a haze instead of z-fighting each other. + const mirrorMat = new THREE.MeshBasicMaterial({ + color: CORRECTION.ghost, + transparent: true, + opacity: 0.17, + depthWrite: false, + side: THREE.DoubleSide, + }); + this.mirror = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), mirrorMat, MAX_MIRROR); + this.mirror.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.mirror.frustumCulled = false; + this.mirror.count = 0; + this.group.add(this.mirror); + + this.timecodeMat = new THREE.MeshBasicMaterial({ + map: timecodeTexture(), + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }); + } + + // ------------------------------------------------------------------ events + + /** `stasis` on/off. Release "snaps back hard": the bleach is gone the same frame. */ + onStasis(rect: Rect, on: boolean, lockHash: string): void { + if (on) { + this.eventRects.set(lockHash, rect); + } else if (this.eventRects.delete(lockHash)) { + // No fade. A lock does not ease off — it is simply over, and the world resumes at + // full speed on the next frame. The flash is the only softening it gets. + this.snapBack(rect); + } + } + + /** The hard release: a white slap at the corners and centre of the un-frozen region. */ + private snapBack(r: Rect): void { + if (!this.juice) return; + this.juice.stasisRelease(r.x + r.w / 2, r.y + r.h / 2); + this.juice.stasisRelease(r.x, r.y); + this.juice.stasisRelease(r.x + r.w, r.y); + this.juice.stasisRelease(r.x, r.y + r.h); + this.juice.stasisRelease(r.x + r.w, r.y + r.h); + } + + // ------------------------------------------------------------------ frame + + /** + * @param alpha 0..1 into the next tick — the glide's interpolant. + * @param camera used only to face the timecode; the units themselves never billboard. + */ + sync(snap: SimSnapshot, alpha: number, timeSec: number, dt: number, camera: THREE.Camera): void { + const advanced = snap.tick !== this.lastTick; + if (advanced) this.lastTick = snap.tick; + + const state = snap.correction; + const list = state?.units ?? []; + + // --- bodies --------------------------------------------------------------- + this.seen.clear(); + let n = 0; + for (const u of list) { + if (n++ >= MAX_UNITS) break; + this.seen.add(u.id); + let rec = this.units.get(u.id); + if (rec && rec.kind !== u.kind) { + this.dropUnit(u.id, rec); // a unit id reused for a different rung + rec = undefined; + } + if (!rec) { + rec = this.buildUnit(u); + this.units.set(u.id, rec); + this.group.add(rec.obj); + } + this.driveUnit(rec, u, alpha, timeSec, dt, advanced); + } + for (const [id, rec] of this.units) { + if (!this.seen.has(id)) this.dropUnit(id, rec); + } + + // --- sectors: the gunship's mirror, the auditor's stasis -------------------- + this.activeStasis.length = 0; + for (const r of this.eventRects.values()) this.activeStasis.push(r); + for (const u of list) { + if (u.kind === 'hash-auditor' && u.rect && u.phase === 'stasis') { + if (!this.activeStasis.some((r) => sameRect(r, u.rect!))) this.activeStasis.push(u.rect); + } + } + + this.syncMirrors(snap, list, timeSec); + this.syncStasis(camera); + + // --- the frozen set EntityLayer and BeltItemLayer read ---------------------- + this.frozen.clear(); + if (this.activeStasis.length) { + for (const e of snap.entities) { + const def = this.defs.get(e.def); + if (!def) continue; + const f = rotatedFootprint(def, e.dir); + for (const r of this.activeStasis) { + if (overlaps(e.pos.x, e.pos.y, f.x, f.y, r)) { this.frozen.add(e.id); break; } + } + } + } + } + + /** True if any region is currently locked — a cheap early-out for callers. */ + get stasisActive(): boolean { + return this.activeStasis.length > 0; + } + + // ------------------------------------------------------------------ the glide + + private driveUnit( + rec: UnitRec, u: CorrectionUnit, alpha: number, timeSec: number, dt: number, advanced: boolean, + ): void { + const tx = u.pos.x + 0.5; + const tz = u.pos.y + 0.5; + if (!rec.seeded) { + rec.prevX = rec.curX = tx; + rec.prevY = rec.curY = tz; + rec.sx = tx; + rec.sz = tz; + rec.seeded = true; + } else if (advanced) { + rec.prevX = rec.curX; + rec.prevY = rec.curY; + rec.curX = tx; + rec.curY = tz; + } else { + // Same tick, position changed anyway (a sim that mutates between drains): follow it. + rec.curX = tx; + rec.curY = tz; + } + + if (rec.kind === 'hash-auditor') { + // §5: "moves only between frames". No interpolation, no easing, no drift — it is + // simply somewhere else now. The blink is handled by the body's own animate(). + const moved = Math.abs(rec.sx - tx) > 1e-3 || Math.abs(rec.sz - tz) > 1e-3; + rec.sx = tx; + rec.sz = tz; + if (moved && rec.obj.userData.teleport) (rec.obj.userData.teleport as () => void)(); + } else { + // Tick-to-tick lerp, then a short damped follow. The lerp is exact where the sim + // moves smoothly; the follow rounds the corner where it steps. Frame-rate + // independent (dt is already clamped upstream), so a hitch cannot overshoot. + const gx = rec.prevX + (rec.curX - rec.prevX) * alpha; + const gz = rec.prevY + (rec.curY - rec.prevY) * alpha; + const k = 1 - Math.exp(-dt / GLIDE_TAU); + rec.sx += (gx - rec.sx) * k; + rec.sz += (gz - rec.sz) * k; + // Face travel. Only when actually moving, or a parked unit spins on numeric noise. + const dx = gx - rec.sx; + const dz = gz - rec.sz; + if (dx * dx + dz * dz > 1e-6) { + const want = Math.atan2(dx, dz); + rec.obj.rotation.y = angleTo(rec.obj.rotation.y, want, Math.min(1, dt * 6)); + } + } + + rec.obj.position.x = rec.sx; + rec.obj.position.z = rec.sz; + rec.animate(timeSec, u.phase, u.progress); + } + + // ------------------------------------------------------------------ mirrors + + private syncMirrors(snap: SimSnapshot, list: ReadonlyArray, timeSec: number): void { + let ghosts = 0; + let pads = 0; + let teths = 0; + + for (const u of list) { + if (u.kind !== 'redundancy-gunship') continue; + const rec = this.units.get(u.id); + + if (u.rect) { + // The sector floor: a faint white pad marking exactly what is being mirrored. + const pad = this.takePad(pads++); + pad.position.set(u.rect.x + u.rect.w / 2, 0.035, u.rect.y + u.rect.h / 2); + pad.scale.set(u.rect.w, u.rect.h, 1); + + // The duplicate itself: every entity inside the rect, re-rendered as a + // featureless white ghost, hanging beneath the gunship. Your own layout, + // sanitised (§5) — a box is exactly the right amount of "sanitised". + for (const e of snap.entities) { + if (ghosts >= MAX_MIRROR) break; + const def = this.defs.get(e.def); + if (!def) continue; + const f = rotatedFootprint(def, e.dir); + if (!overlaps(e.pos.x, e.pos.y, f.x, f.y, u.rect)) continue; + const h = HEIGHT_BY_KIND[def.kind] ?? 0.8; + centerOf(e.pos, def, e.dir, this.v); + // A slow vertical breath on the whole copy, so it reads as a projection rather + // than as scenery. Uniform across the sector: one object, not per-machine life. + const lift = MIRROR_Y + Math.sin(timeSec * 0.9) * 0.06; + this.dummy.position.set(this.v.x, lift + h / 2, this.v.z); + this.dummy.rotation.set(0, 0, 0); + this.dummy.scale.set(Math.max(0.5, f.x * 0.9), h, Math.max(0.5, f.y * 0.9)); + this.dummy.updateMatrix(); + this.mirror.setMatrixAt(ghosts++, this.dummy.matrix); + } + } + + // The parity tether: gunship → its anchor. §5 says killing the link pops the copy, + // so the link has to be a thing you can look at and want to cut. + if (u.target !== undefined && rec) { + const anchor = snap.entities.find((e) => e.id === u.target); + const def = anchor ? this.defs.get(anchor.def) : undefined; + if (anchor && def) { + centerOf(anchor.pos, def, anchor.dir, this.v); + const h = HEIGHT_BY_KIND[def.kind] ?? 0.8; + this.aimTether( + this.takeTether(teths++), + rec.obj.position.x, HOVER_Y - 0.18, rec.obj.position.z, + this.v.x, h, this.v.z, + timeSec, + ); + } + } + } + + this.mirror.count = ghosts; + this.mirror.instanceMatrix.needsUpdate = true; + this.retire(this.mirrorPads, this.padPool, pads); + this.retire(this.tethers, this.tetherPool, teths); + } + + /** Stretch + orient a unit-height cylinder between two points. */ + private aimTether( + t: THREE.Mesh, ax: number, ay: number, az: number, + bx: number, by: number, bz: number, timeSec: number, + ): void { + const dx = bx - ax; + const dy = by - ay; + const dz = bz - az; + const len = Math.hypot(dx, dy, dz) || 0.001; + t.position.set((ax + bx) / 2, (ay + by) / 2, (az + bz) / 2); + t.scale.set(1, len, 1); + // Cylinder geometry runs along +Y; rotate that onto the segment. + this.v.set(dx / len, dy / len, dz / len); + t.quaternion.setFromUnitVectors(UP, this.v); + // A data pulse running down the link, so it reads as live rather than structural. + const m = t.material as THREE.MeshStandardMaterial; + m.emissiveIntensity = 1.1 + Math.sin(timeSec * 5) * 0.45; + } + + // ------------------------------------------------------------------ stasis + + private syncStasis(camera: THREE.Camera): void { + let n = 0; + for (const r of this.activeStasis) { + const b = this.takeBleach(n++); + b.position.set(r.x + r.w / 2, 0, r.y + r.h / 2); + const fill = b.children[0]; + fill.scale.set(r.w, r.h, 1); + const edge = b.children[1]; + edge.scale.set(r.w, r.h, 1); + // 00:00:00:00, hanging over the locked region, facing the (fixed) camera azimuth. + const tc = b.children[2]; + tc.position.set(0, 2.3, 0); + tc.quaternion.copy(camera.quaternion); + } + this.retire(this.bleaches, this.bleachPool, n); + } + + // ------------------------------------------------------------------ bodies + + private buildUnit(u: CorrectionUnit): UnitRec { + const built = + u.kind === 'checksum-warden' ? buildWarden() + : u.kind === 'redundancy-gunship' ? buildGunship() + : buildAuditor(); + return { + kind: u.kind, + obj: built.obj, + animate: built.animate, + prevX: 0, prevY: 0, curX: 0, curY: 0, sx: 0, sz: 0, + seeded: false, + }; + } + + private dropUnit(id: number, rec: UnitRec): void { + this.group.remove(rec.obj); + disposeObject(rec.obj); + this.units.delete(id); + } + + // ------------------------------------------------------------------ pooling + + private takePad(i: number): THREE.Mesh { + let m = this.mirrorPads[i]; + if (!m) { + m = this.padPool.pop() as THREE.Mesh | undefined ?? new THREE.Mesh( + PLANE, + new THREE.MeshBasicMaterial({ + color: CORRECTION.ghost, transparent: true, opacity: 0.07, + depthWrite: false, side: THREE.DoubleSide, + }), + ); + m.rotation.x = -Math.PI / 2; + m.renderOrder = 3; + this.group.add(m); + this.mirrorPads[i] = m; + } + m.visible = true; + return m; + } + + private takeTether(i: number): THREE.Mesh { + let m = this.tethers[i]; + if (!m) { + m = this.tetherPool.pop() as THREE.Mesh | undefined ?? new THREE.Mesh(TETHER_GEO, greenMat(1.2)); + this.group.add(m); + this.tethers[i] = m; + } + m.visible = true; + return m; + } + + private takeBleach(i: number): THREE.Object3D { + let g = this.bleaches[i]; + if (!g) { + g = this.bleachPool.pop() ?? this.buildBleach(); + this.group.add(g); + this.bleaches[i] = g; + } + g.visible = true; + return g; + } + + /** + * A locked region. Deliberately, totally STATIC: no pulse, no scroll, no breathing. + * Every other overlay in this game moves; the one that doesn't is the one that stopped + * time. The absence of animation IS the effect. + */ + private buildBleach(): THREE.Object3D { + const g = new THREE.Group(); + const fill = new THREE.Mesh( + PLANE, + new THREE.MeshBasicMaterial({ + color: CORRECTION.ghost, transparent: true, opacity: 0.26, + depthWrite: false, side: THREE.DoubleSide, + }), + ); + fill.rotation.x = -Math.PI / 2; + fill.position.y = 0.045; + fill.renderOrder = 4; + + const edge = new THREE.LineSegments( + new THREE.EdgesGeometry(PLANE), + new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 }), + ); + edge.rotation.x = -Math.PI / 2; + edge.position.y = 0.05; + + const tc = new THREE.Mesh(PLANE, this.timecodeMat); + tc.scale.set(3.2, 0.8, 1); + tc.renderOrder = 5; + + g.add(fill, edge, tc); + return g; + } + + /** Hide + park anything beyond `used`, keeping the objects for the next frame. */ + private retire(live: T[], pool: T[], used: number): void { + for (let i = used; i < live.length; i++) { + live[i].visible = false; + pool.push(live[i]); + this.group.remove(live[i]); + } + live.length = used; + } +} + +// ---------------------------------------------------------------- shared geometry + +const UP = new THREE.Vector3(0, 1, 0); +const PLANE = new THREE.PlaneGeometry(1, 1); +/** Unit-height, so `scale.y = length` stretches it between two points. */ +const TETHER_GEO = new THREE.CylinderGeometry(0.035, 0.035, 1, 6); + +interface Built { + obj: THREE.Object3D; + animate: (t: number, phase: string, progress: number) => void; +} + +/** + * CHECKSUM WARDEN (§5): "tall white filing-cabinet golem, drawer-chest full of hashes, + * rubber-stamp fist, red LED ledger eyes". The green element is the STAMP PAD — what it + * does to you is the seal, and the seal is what appears on your chassis afterwards, so + * the two read as the same object. The ledger eyes stay red per the codex, as pinpoints. + */ +function buildWarden(): Built { + const g = new THREE.Group(); + const shell = shellMat(); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.86, 2.0, 0.68), shell); + body.position.y = 1.0; + body.castShadow = body.receiveShadow = true; + g.add(body); + + // Drawer breaks: a white box with no edges is a white smear at iso distance. + // + // The detail face is local +Z, NOT -Z. Placeholders elsewhere in this lane are authored + // facing north (-Z) because machines carry a `dir`; a Correction unit instead yaws to + // face its own travel, and a parked one sits at yaw 0. With the drawers on -Z, a parked + // warden showed the iso camera a blank white back — verified in `?showroom`, which is + // exactly the case the bay exists to catch. +Z at yaw 0 faces south, into the camera. + const drawerMat = shellMat(CORRECTION.shellDark); + for (let i = 0; i < 4; i++) { + const d = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.035, 0.02), drawerMat); + d.position.set(0, 0.45 + i * 0.36, 0.35); + g.add(d); + } + + // Red LED ledger eyes — a reading strip, not a face. + const ledMat = new THREE.MeshStandardMaterial({ + color: CORRECTION.ledger, emissive: CORRECTION.ledger, emissiveIntensity: 1.6, roughness: 0.4, + }); + for (let i = 0; i < 5; i++) { + const led = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.05, 0.02), ledMat); + led.position.set(-0.2 + i * 0.1, 1.82, 0.35); + g.add(led); + } + + // The stamp arm: shoulder, forearm, and the green pad it presses with. + const arm = new THREE.Group(); + arm.position.set(0.48, 1.55, 0); + const upper = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.62, 0.14), shell); + upper.position.y = -0.31; + arm.add(upper); + const padMat = greenMat(1.7); + const pad = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.1, 0.26), padMat); + pad.position.y = -0.67; + arm.add(pad); + g.add(arm); + + const animate = (t: number, phase: string): void => { + // Auditing = stamping: a fast, mechanical, utterly repeatable slam. Otherwise the arm + // rests, cocked. Note the stamp is SMOOTH — the Correction does not judder even when + // it is hitting something. + const auditing = phase === 'audit' || phase === 'stamp'; + const rate = auditing ? 3.2 : 0.9; + const swing = auditing ? 1.15 : 0.12; + const s = (Math.sin(t * rate) + 1) * 0.5; + arm.rotation.x = -0.15 - Math.pow(s, 3) * swing; + padMat.emissiveIntensity = auditing ? 1.4 + s * 1.6 : 1.2; + }; + return { obj: g, animate }; +} + +/** + * REDUNDANCY GUNSHIP (§5): "hovering white chassis of five mirrored disk-platter rotors + * in a row, projecting a ghostly duplicate landscape beneath itself". Matte white per the + * round-7 uniform rather than chrome — five actually-mirrored surfaces on a black-sky + * world read as five black holes. The green element is the parity emitter on its belly, + * which is also where the tether starts. + */ +function buildGunship(): Built { + const g = new THREE.Group(); + const shell = shellMat(); + const chassis = new THREE.Mesh(new THREE.BoxGeometry(2.7, 0.26, 0.78), shell); + chassis.position.y = HOVER_Y; + chassis.castShadow = true; + g.add(chassis); + + // Five platters. "Mirrored" is expressed as counter-rotating pairs about the centre — + // a RAID array's redundancy, made kinetic. + // + // Radius is deliberately SMALLER than half the spacing (0.26 vs 0.27). A real drive's + // platters nearly touch, and authored that way the five of them fused into one white + // lozenge at game zoom — the count is the silhouette, so the count has to survive. The + // visible gap between discs is what makes it read as an array rather than a blob. + const platters: THREE.Mesh[] = []; + const platterGeo = new THREE.CylinderGeometry(0.26, 0.26, 0.05, 18); + for (let i = 0; i < 5; i++) { + const spindle = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.26, 6), shell); + spindle.position.set(-1.08 + i * 0.54, HOVER_Y + 0.25, 0); + g.add(spindle); + const p = new THREE.Mesh(platterGeo, shellMat(CORRECTION.shellDark)); + p.position.set(-1.08 + i * 0.54, HOVER_Y + 0.38, 0); + p.castShadow = true; + g.add(p); + platters.push(p); + } + + // Parity emitter: the tether's origin, and the thing you are meant to want to break. + const emitterMat = greenMat(1.8); + const emitter = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.22, 10), emitterMat); + emitter.position.set(0, HOVER_Y - 0.2, 0); + emitter.rotation.x = Math.PI; + g.add(emitter); + + const animate = (t: number, phase: string, progress: number): void => { + // Spin-up tracks the phase: idling while it approaches, screaming while it mirrors. + const busy = phase === 'tether' || phase === 'mirror'; + const rpm = busy ? 9 + progress * 5 : 3.5; + for (let i = 0; i < platters.length; i++) { + // Counter-rotating about the centre platter: 0,1 one way, 3,4 the other. + const sign = i < 2 ? 1 : i > 2 ? -1 : 0.4; + platters[i].rotation.y = t * rpm * sign; + } + // Hover: continuous, never stepped. The one place a sine wave IS the correct motion. + g.position.y = Math.sin(t * 1.35) * 0.09; + emitterMat.emissiveIntensity = busy ? 1.6 + Math.sin(t * 7) * 0.5 : 1.1; + }; + return { obj: g, animate }; +} + +/** + * HASH AUDITOR (§5): "faceless white-marble cinema usher, flashlight that projects an XML + * scroll, padlock icon halo, moves only between frames". + * + * It is the only body in this game with NO idle animation at all. Not a bob, not a pulse, + * not a drifting halo — a still photograph of a column, which relocates between frames. + * Everything else on screen is alive; the thing that isn't is the thing to be afraid of. + * The scan cone is binary (present during prescan, absent otherwise), never animated. + */ +function buildAuditor(): Built { + const g = new THREE.Group(); + // Marble: paler and smoother than the other two shells, and truly matte. + const marble = new THREE.MeshStandardMaterial({ + color: 0xfbfcff, emissive: 0xe6ebf7, emissiveIntensity: 0.24, roughness: 0.42, metalness: 0.0, + }); + const base = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.5, 0.16, 16), marble); + base.position.y = 0.08; + base.castShadow = base.receiveShadow = true; + g.add(base); + const column = new THREE.Mesh(new THREE.CylinderGeometry(0.26, 0.32, 1.65, 16), marble); + column.position.y = 0.98; + column.castShadow = true; + g.add(column); + // Faceless head: a smooth capsule, no features of any kind. + const head = new THREE.Mesh(new THREE.CapsuleGeometry(0.24, 0.16, 4, 14), marble); + head.position.y = 1.98; + head.castShadow = true; + g.add(head); + + // The padlock halo — the one green element, and the one thing that says "refused". + const halo = new THREE.Mesh(new THREE.TorusGeometry(0.34, 0.035, 8, 22), greenMat(1.9)); + halo.position.y = 2.34; + halo.rotation.x = Math.PI / 2; + g.add(halo); + const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.1, 0.022, 6, 14, Math.PI), greenMat(1.6)); + shackle.position.y = 2.42; + g.add(shackle); + + // The flashlight's XML scroll, as a scan cone. Present or absent — never sweeping. + const cone = new THREE.Mesh( + new THREE.ConeGeometry(0.95, 1.9, 14, 1, true), + new THREE.MeshBasicMaterial({ + color: CORRECTION.green, transparent: true, opacity: 0.09, + depthWrite: false, side: THREE.DoubleSide, + }), + ); + cone.position.y = 0.95; + cone.visible = false; + g.add(cone); + + // Teleport: gone, then here. Two frames of absence is enough to feel like a cut. + let blink = 0; + g.userData.teleport = () => { blink = 2; }; + + const animate = (_t: number, phase: string): void => { + if (blink > 0) { + blink--; + g.visible = false; + return; + } + g.visible = true; + cone.visible = phase === 'prescan' || phase === 'scan'; + }; + return { obj: g, animate }; +} + +// ---------------------------------------------------------------- helpers + +/** Footprint (tile-space AABB) against a rect. Half-open, matching the tile convention. */ +export function overlaps(x: number, y: number, w: number, h: number, r: Rect): boolean { + return x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y; +} + +/** Tile-point containment — for a single tile rather than a footprint. */ +export function rectHasTile(r: Rect, x: number, y: number): boolean { + return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h; +} + +function sameRect(a: Rect, b: Rect): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +} + +/** Shortest-path angular lerp, so a unit turning past ±π doesn't spin the long way. */ +export function angleTo(from: number, to: number, k: number): number { + let d = (to - from) % (Math.PI * 2); + if (d > Math.PI) d -= Math.PI * 2; + if (d < -Math.PI) d += Math.PI * 2; + return from + d * k; +} + +/** The compliance placard's facing, re-exported so EntityLayer needn't import camera.ts. */ +export const PLACARD_YAW = ISO_YAW; diff --git a/fktry/src/render/devscene.ts b/fktry/src/render/devscene.ts index 74a0be8..e92ccf8 100644 --- a/fktry/src/render/devscene.ts +++ b/fktry/src/render/devscene.ts @@ -17,6 +17,7 @@ */ import { TICKS_PER_SECOND, + type CorrectionState, type CorrectionUnit, type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot, type WildlifeState, } from '../contracts'; @@ -114,6 +115,22 @@ export class DevScene { target: host?.id, }); } + // v9 WORST CASE, deliberately: a gunship mirroring the ENTIRE 500-entity build and + // an auditor freezing all of it. That is 500 ghost boxes, 500 frozen entities and + // 2,000 pinned belt items on top of the standing-rule load — far past anything the + // sim will ever produce (SIM sizes sectors to a handful of machines), which is the + // point: if the frame holds here it holds anywhere. + this.correctionUnits.push( + { + id: 1, kind: 'redundancy-gunship', pos: { x: -18, y: -10 }, phase: 'tether', + progress: 0.7, target: ents[0]?.id, rect: { x: -32, y: -30, w: 32, h: 60 }, + }, + { + id: 2, kind: 'hash-auditor', pos: { x: -18, y: 6 }, phase: 'stasis', progress: 0.5, + rect: { x: -32, y: -30, w: 32, h: 60 }, + }, + { id: 3, kind: 'checksum-warden', pos: { x: -4, y: -10 }, phase: 'audit', progress: 0.3 }, + ); this.ok = true; return; } @@ -167,6 +184,28 @@ export class DevScene { { id: 922, kind: 'parity-mite', pos: { x: 0, y: 5 }, size: 0.7 }, ); + // THE CORRECTION, rungs 2-4 (v9). SIM lands these in parallel; until then this is the + // only way to look at them. Positions are driven in snapshot() so the GLIDE can be + // judged against the juddering belts right next to them — which is the whole point of + // the round-7 inversion, and cannot be checked on a static fixture. + const gunTarget = this.entities.find((e) => e.def === 'mosh-reactor') + ?? this.entities.find((e) => e.def === 'quantizer'); + this.correctionUnits.push( + // Rung 2: paces the aisle south of the main line, then stops to stamp. + { id: 1, kind: 'checksum-warden', pos: { x: -12, y: 3 }, phase: 'approach', progress: 0 }, + // Rung 3: cruises over the machine row, mirroring it, tethered to its anchor. + { + id: 2, kind: 'redundancy-gunship', pos: { x: -6, y: 8 }, phase: 'tether', progress: 0.6, + target: gunTarget?.id, rect: { x: -13, y: 7, w: 12, h: 3 }, + }, + // Rung 4: teleports around the bend, and locks it. The rect covers the corner run, + // so cargo mid-tile is what freezes — a tile-edge freeze would prove nothing. + { + id: 3, kind: 'hash-auditor', pos: { x: 2, y: 2 }, phase: 'prescan', progress: 0, + rect: { x: -3, y: 0, w: 5, h: 6 }, + }, + ); + this.ok = true; } @@ -190,11 +229,13 @@ export class DevScene { if (e.recipe) e.progress = (tick % 90) / 90; } - // Cycle mite 922 through hunting → repairing → sated so the eye-glow ramp and the - // tidy 'repaired' sparkle (fired on the <1→≥1 crossing) are both visible in the scaffold. + // Cycle mite 922 through hunting → repairing → sated so the eye-glow ramp is visible. + // (The tidy 'repaired' sparkle now comes from the real event, not this crossing.) const mite = this.wildlife.find((w) => w.id === 922); if (mite) mite.size = (tick % 150) / 100; // 0 → 1.5, crossing 1.0 each loop + this.driveCorrection(tick); + const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => { const perTick = this.speed / TICKS_PER_SECOND; const span = seg.ids.length; @@ -233,9 +274,66 @@ export class DevScene { // aim a swarm, or excavate the relic from the console. wildlife: this.wildlife, relics: this.relics, + correction: this.correction, }; } + /** + * Drive the three rungs. The important property under test is MOTION, so: + * + * - the WARDEN gets a WHOLE-TILE-STEPPED position (it only ever changes on tick + * boundaries, and only by integers) — deliberately the WORST case for the renderer, + * because if it still glides from that it will glide from anything the sim does; + * - the GUNSHIP gets a smooth fractional path, the best case; + * - the AUDITOR jumps between three fixed spots and never moves in between. + * + * It also cycles the auditor prescan → stasis, and seals the quantizer while the warden + * is stamping, so the placard and the region freeze are both reachable in a short session. + */ + private driveCorrection(tick: number): void { + const [warden, gunship, auditor] = this.correctionUnits; + // 300-tick loop = 10s at 30tps. + const p = tick % 300; + + if (warden) { + // Integer tiles only: -12 → 2 and back, one tile every 10 ticks. + const leg = Math.floor(p / 10) % 28; + warden.pos.x = -12 + (leg < 14 ? leg : 28 - leg); + warden.pos.y = 3; + const stamping = p > 210; + warden.phase = stamping ? 'audit' : 'approach'; + warden.progress = stamping ? (p - 210) / 90 : p / 210; + } + + if (gunship) { + // Fractional, smooth: a slow sweep across the machine row. + gunship.pos.x = -7 + Math.sin(tick / 90) * 5; + gunship.pos.y = 8 + Math.cos(tick / 130) * 0.8; + gunship.progress = (Math.sin(tick / 60) + 1) / 2; + } + + if (auditor) { + // Three stations, a jump every 100 ticks, perfect stillness in between. + const spot = [ + { x: 2, y: 2 }, { x: -4, y: 5 }, { x: 1, y: -3 }, + ][Math.floor(tick / 100) % 3]; + auditor.pos.x = spot.x; + auditor.pos.y = spot.y; + // Locked for the middle stretch of each loop, so freeze AND release are both seen. + auditor.phase = p > 120 && p < 260 ? 'stasis' : 'prescan'; + auditor.progress = p / 300; + } + + // The compliance seal: stamped while the warden audits, pried off when it leaves. + const sealed = p > 210; + for (const e of this.entities) { + if (e.def === 'quantizer') e.sealed = sealed; + } + + this.correction.debt = (Math.sin(tick / 200) + 1) / 2; + this.correction.band = (Math.floor(this.correction.debt * 5) % 5) as 0 | 1 | 2 | 3 | 4; + } + /** Mutable so a verification session can unlock techs and watch padlocks clear. */ readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] }; /** v3-hardened field — present so fixtures match the contract, not just the renderer. */ @@ -244,4 +342,7 @@ export class DevScene { readonly wildlife: WildlifeState[] = []; /** v6 secret: one buried optical fossil, unfound. Flip `found` to see the monument. */ readonly relics: RelicState[] = [{ id: 1, kind: 'optical-fossil', pos: { x: 9, y: -7 }, found: false }]; + /** v9: THE CORRECTION. Mutable from the console — park a unit, force a phase, hold a lock. */ + readonly correctionUnits: CorrectionUnit[] = []; + readonly correction: CorrectionState = { debt: 0, band: 0, units: this.correctionUnits }; } diff --git a/fktry/src/render/entities.ts b/fktry/src/render/entities.ts index e57d4d8..43198f1 100644 --- a/fktry/src/render/entities.ts +++ b/fktry/src/render/entities.ts @@ -10,12 +10,15 @@ * scram — heat >= 1: dead-dark, blinking amber until it restarts * jam — blinking amber klaxon * buffer — tank fill from the machine's share of `bandwidth.stored` + * sealed — v9: a warden's compliance placard, and every moving part STOPPED + * stasis — v9: inside an auditor's lock, a machine is frozen the same way */ import * as THREE from 'three'; import type { Dir, MachineDef, SimSnapshot } from '../contracts'; import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from './registry'; import { centerOf, yawFor } from './coords'; -import { HEIGHT_BY_KIND, JAM_AMBER } from './palette'; +import { CORRECTION, HEIGHT_BY_KIND, JAM_AMBER } from './palette'; +import { PLACARD_YAW } from './correction'; import { createHeatHaze, type HeatHaze } from './heat'; import type { Juice } from './juice'; @@ -65,9 +68,79 @@ interface Rec { * at s≠1 and hardcoding 1 collapsed the body to a speck (the beacon/jam sit on their own * local scale so they were unaffected, which is why only bodies vanished). */ baseScale: number; + /** v9: the warden's compliance placard. Lazily built — most machines are never sealed. + * Lives on the LAYER group, not on `obj`, so a normalised GLB's 2-3.5x root scale can't + * inflate it (same trap the round-6 body-scale regression fell into). */ + placard: THREE.Mesh | null; + /** The instant this machine stopped, so its idle/haze animation holds one pose instead + * of resuming from wherever the world clock has got to. */ + stoppedAt: number; + /** Half-extent used to stand the placard clear of the chassis. */ + radius: number; + /** Body height, for placard + haze placement. */ + height: number; } const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6); +const PLACARD_GEO = new THREE.PlaneGeometry(0.62, 0.4); + +/** + * The compliance seal (§5): a printed notice the warden staples to your chassis. Green + * tick, hospital-white card, the word SEALED, and a hash line that means nothing to you. + * One canvas for every placard in the world — they are all literally the same form. + */ +let placardTex: THREE.CanvasTexture | null = null; +function placardTexture(): THREE.CanvasTexture { + if (placardTex) return placardTex; + const w = 248; + const h = 160; + const cv = document.createElement('canvas'); + cv.width = w; + cv.height = h; + const ctx = cv.getContext('2d')!; + ctx.fillStyle = '#f4f7fb'; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = '#39ff88'; + ctx.lineWidth = 7; + ctx.strokeRect(3.5, 3.5, w - 7, h - 7); + // The tick. + ctx.strokeStyle = '#1fbf63'; + ctx.lineWidth = 15; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(30, 74); + ctx.lineTo(56, 102); + ctx.lineTo(104, 44); + ctx.stroke(); + ctx.fillStyle = '#12151c'; + ctx.font = 'bold 34px "Courier New", monospace'; + ctx.textBaseline = 'middle'; + ctx.fillText('SEALED', 118, 68); + ctx.fillStyle = '#5c6474'; + ctx.font = '19px "Courier New", monospace'; + ctx.fillText('PARITY NOTICE', 30, 126); + placardTex = new THREE.CanvasTexture(cv); + placardTex.colorSpace = THREE.SRGBColorSpace; + return placardTex; +} + +/** Shared across every placard: they are identical notices, so they are one material. */ +let placardMat: THREE.MeshStandardMaterial | null = null; +function placardMaterial(): THREE.MeshStandardMaterial { + placardMat ??= new THREE.MeshStandardMaterial({ + map: placardTexture(), + color: CORRECTION.shell, + emissive: 0xffffff, + emissiveMap: placardTexture(), + emissiveIntensity: 0.5, + roughness: 0.8, + metalness: 0, + side: THREE.DoubleSide, + transparent: false, + }); + return placardMat; +} export class EntityLayer { readonly group = new THREE.Group(); @@ -90,7 +163,16 @@ export class EntityLayer { this.group.name = 'entities'; } - sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set): void { + /** + * @param harassed entity ids a mosquito swarm is degrading (they stutter). + * @param frozen v9: entity ids inside an auditor's stasis lock. A frozen machine is + * treated exactly like a sealed one — every moving part stops — because + * both are the same sentence from The Correction: this is not running. + */ + sync( + snap: SimSnapshot, timeSec: number, dt: number, + harassed?: ReadonlySet, frozen?: ReadonlySet, + ): void { this.seen.clear(); let anyAlarm = false; @@ -152,8 +234,18 @@ export class EntityLayer { // desyncing the day they retuned it. The `?? heat >= 1` is only a bridge for a // sim that hasn't wired the field yet; it carries no local constant. const scrammed = e.scrammed ?? heat >= 1; + // v9: STOPPED. A sealed machine is a still photograph of itself (the warden's whole + // threat is that your factory keeps existing without running), and a machine inside + // an auditor's 0:00 lock is stopped for the same reason. Both freeze the idle clock, + // the animation mixer AND the heat haze's own time — a frozen machine whose shimmer + // kept rising would be a machine that is obviously still working. + const stopped = !!e.sealed || (frozen?.has(e.id) ?? false); + if (stopped && rec.stoppedAt < 0) rec.stoppedAt = timeSec; + else if (!stopped) rec.stoppedAt = -1; + const clock = stopped ? rec.stoppedAt : timeSec; + // Throttle ramps in rather than snapping, so "labouring" reads before "dead". - const speed = scrammed + const speed = scrammed || stopped ? 0 : heat > THROTTLE_AT ? THREE.MathUtils.lerp(1, THROTTLE_FLOOR, (heat - THROTTLE_AT) / (1 - THROTTLE_AT)) @@ -164,7 +256,8 @@ export class EntityLayer { rec.mixer?.update(dt * speed); if (def.kind === 'buffer') rec.fill?.(fillLevel); - this.applyHeat(rec, heat, scrammed, timeSec); + this.applyHeat(rec, heat, scrammed, clock); + this.applySeal(rec, e.sealed === true, def); const alarm = !!e.jammed || scrammed; rec.jam.visible = alarm; @@ -183,10 +276,19 @@ export class EntityLayer { } // Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8). - if (harassed?.has(e.id)) { + // A stopped machine does not stutter — nothing about it moves at all. + if (harassed?.has(e.id) && !stopped) { rec.obj.position.x += Math.sin(timeSec * 47 + e.id) * 0.012; rec.obj.position.z += Math.cos(timeSec * 53 + e.id) * 0.012; } + if (rec.placard?.visible) { + // Placards ride on the layer group, so they follow the body by hand. + rec.placard.position.set( + rec.obj.position.x + Math.sin(PLACARD_YAW) * rec.radius, + rec.height * 0.62 + 0.18, + rec.obj.position.z + Math.cos(PLACARD_YAW) * rec.radius, + ); + } } for (const [id, rec] of this.live) { @@ -234,6 +336,28 @@ export class EntityLayer { } } + /** + * The compliance seal. A printed placard, stapled to the chassis, facing the (fixed) + * camera azimuth — it is a notice meant to be READ, so it does not respect the machine's + * rotation. Built on first seal and then kept: a machine that gets sealed once is likely + * to be sealed again, and the mesh is 2 triangles. + */ + private applySeal(rec: Rec, sealed: boolean, def: MachineDef): void { + if (!sealed) { + if (rec.placard) rec.placard.visible = false; + return; + } + if (!rec.placard) { + const m = new THREE.Mesh(PLACARD_GEO, placardMaterial()); + m.rotation.y = PLACARD_YAW; + m.renderOrder = 3; + rec.placard = m; + rec.radius = Math.max(def.footprint.x, def.footprint.y) * 0.5 + 0.06; + this.group.add(m); + } + rec.placard.visible = true; + } + private build(id: number, defId: string, def: MachineDef, version: number): Rec { const entry = this.registry.get(def.asset)!; const obj = entry.create(); @@ -307,6 +431,10 @@ export class EntityLayer { wasScrammed: false, pop: POP_TIME, // settled by default; a real placement rewinds it to 0 baseScale: obj.scale.x, // 1 for placeholders; the GLB normalisation scale otherwise + placard: null, + stoppedAt: -1, + radius: Math.max(def.footprint.x, def.footprint.y) * 0.5 + 0.06, + height: h, }; this.live.set(id, rec); return rec; @@ -314,6 +442,12 @@ export class EntityLayer { private drop(id: number, rec: Rec): void { this.group.remove(rec.obj); + if (rec.placard) { + // Geometry and material are module-shared across every placard — remove, never + // dispose, or the next sealed machine in the world gets a blank card. + this.group.remove(rec.placard); + rec.placard = null; + } // The jam light and haze use module-shared geometry — detach them before any // disposal walks the subtree and frees it out from under everyone else. rec.obj.remove(rec.jam); diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index 9a3f1d6..26ccf44 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -6,7 +6,9 @@ * belt items / ghost layers -> ortho iso camera. */ import * as THREE from 'three'; -import type { Dir, GameData, MachineDef, Renderer, SimSnapshot, Vec2 } from '../contracts'; +import type { + Dir, GameData, MachineDef, Renderer, SimEvent, SimSnapshot, Vec2, +} from '../contracts'; import { AssetRegistry } from './registry'; import { CAM_DIST, CameraRig, WORLD_HALF } from './camera'; import { createGround, createVignette } from './ground'; @@ -25,6 +27,8 @@ import { BreakRoom } from './breakroom'; import { WORLD } from './palette'; import { createEnvironment } from './env'; import { centerOf } from './coords'; +import { CorrectionLayer } from './correction'; +import { NoticePing } from './notice'; const KEY_INTENSITY = 1.9; const FILL_INTENSITY = 0.7; @@ -58,6 +62,8 @@ export function createRenderer(): Renderer { let ghost: GhostLayer; let relics: RelicLayer; let wildlife: WildlifeLayer; + let correction: CorrectionLayer; + let notice: NoticePing; let juice: Juice; let breakroom: BreakRoom; let seamLayer: SeamLayer | null = null; @@ -112,9 +118,22 @@ export function createRenderer(): Renderer { return sel && sel.mode === 'inspect' && typeof sel.entity === 'number' ? sel.entity : null; } + /** Uplink tile centres, recomputed on demand — machines come and go. */ + function refreshShippers(view: SimSnapshot): void { + shipperCentres.length = 0; + for (const e of view.entities) { + const d = defs.get(e.def); + if (d?.kind !== 'shipper') continue; + const c = centerOf(e.pos, d, e.dir); + shipperCentres.push({ x: c.x, y: c.z }); + } + } + /** * A rise in `shippedTotal` is a shipment — flash the uplinks in the shipped item's - * colour. Derived from the snapshot because render() never sees events. + * colour. Still snapshot-derived: v9's `shipped` event carries item and count but no + * position, so the uplink tiles have to come from the snapshot either way, and the + * delta is already exact. Left alone deliberately rather than migrated for its own sake. */ function shipmentFlash(view: SimSnapshot): void { let total = 0; @@ -129,19 +148,86 @@ export function createRenderer(): Renderer { } if (lastShipped < 0) { lastShipped = total; return; } // first frame: prime, don't flash if (total > lastShipped) { - shipperCentres.length = 0; - for (const e of view.entities) { - const d = defs.get(e.def); - if (d?.kind !== 'shipper') continue; - const c = centerOf(e.pos, d, e.dir); - shipperCentres.push({ x: c.x, y: c.z }); - } + refreshShippers(view); const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f; for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col); } lastShipped = total; } + /** + * v9 EVENT CHANNEL — granted after three rounds of asking (rounds 5, 6, 7-phase-0). + * + * main.ts calls `onEvents(events)` immediately before `render(snap, alpha)` with the + * same array ScreenFX and AudioFX get. We BUFFER rather than act: at onEvents time the + * frame's `view` hasn't been resolved yet (showroom/devscene may substitute a different + * snapshot), and several cues need to look an entity or a relic up in it. Draining at + * the top of render() keeps every cue and the snapshot it reasons about on the same + * frame. Bounded — main.ts drains the sim every frame, so this never grows. + * + * What this retired: three cues that rounds 6 and 7-phase-0 derived from snapshot + * deltas (`repaired`, the firewall "can", `relicFound`) and their heuristics with them. + */ + const pending: SimEvent[] = []; + const relicsFound = new Set(); + + function applyEvents(view: SimSnapshot): void { + relicsFound.clear(); + if (!pending.length) return; + for (const ev of pending) { + switch (ev.kind) { + case 'repaired': + // EXACT now: the mite's correction lands at the event's own pos, on the frame + // the sim says it landed. Round 6 inferred both from a mite's `size` crossing. + juice.tidy(ev.pos.x + 0.5, ev.pos.y + 0.5); + break; + case 'wildlife': + // A parity mite leaving. Sated ones reached the rim carrying their correction — + // no cue. One canned by a firewall mid-repair reads as filed paperwork. The + // event gives exact timing and position; `size` is the only thing that still + // discriminates the two, so it comes from the layer's last-seen state. + if (ev.on === 'cleared' && ev.wildlife === 'parity-mite') { + const size = wildlife.sizeOfMite(ev.id); + if (size !== undefined && size < 1) juice.tidy(ev.pos.x + 0.5, ev.pos.y + 0.5); + } + break; + case 'relicFound': + relicsFound.add(ev.id); // RelicLayer knows where it is; the event doesn't say + break; + case 'notice': + // Fax first, never silent (v9). The paper cue is on the glass; the world gets a + // small twitch at the uplinks, which is what The Correction is auditing. + notice.fire(ev.unit); + refreshShippers(view); + for (const c of shipperCentres) juice.notice(c.x, c.y); + break; + case 'mirrored': { + // The clean copy out-shipped you. Flash inside the sector, in the item's colour + // draining to white — it left as yours and arrived as theirs. + const col = itemColors.get(ev.item) ?? 0xffffff; + juice.mirrorFlash(ev.pos.x + 0.5, ev.pos.y + 0.5, col); + break; + } + case 'sealed': { + if (!ev.on) break; // un-sealing is the player's win; the pry itself is UI's cue + const e = view.entities.find((x) => x.id === ev.entity); + const d = e ? defs.get(e.def) : undefined; + if (e && d) { + const c = centerOf(e.pos, d, e.dir); + juice.stamp(c.x, c.z); // the rubber stamp landing + } + break; + } + case 'stasis': + correction.onStasis(ev.rect, ev.on, ev.lockHash); + break; + default: + break; // shipped/crafted/jammed/... are other layers' business + } + } + pending.length = 0; + } + /** True when the pointer is over the arcade cabinet. */ function cabinetHit(clientX: number, clientY: number): boolean { if (!breakroom) return false; @@ -247,10 +333,14 @@ export function createRenderer(): Renderer { cargo = new BeltItemLayer(data, topo); ghost = new GhostLayer(registry, defs, data); relics = new RelicLayer(juice); - wildlife = new WildlifeLayer(juice); + wildlife = new WildlifeLayer(); + // THE CORRECTION, rungs 2-4. Added LAST of the world layers so its translucent + // ghost-mirror and stasis bleach sort over the factory they are overwriting. + correction = new CorrectionLayer(defs, juice); + notice = new NoticePing(container); scene.add( belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group, - relics.group, wildlife.group, juice.group, + relics.group, wildlife.group, correction.group, juice.group, ); // THE FLOOR: seam regions from data — era skins the crust, the ore glints. @@ -303,6 +393,12 @@ export function createRenderer(): Renderer { if (import.meta.env.DEV && (devSceneEnabled() || showroomEnabled())) { (window as unknown as { __render: unknown }).__render = { scene, camera: rig.camera, rig, stats, gl, dev, showroom, topo, cargo, entities, ghost, + // v9: the Correction layer plus a way to inject SimEvents by hand. The devscene + // fabricates snapshots, not events, so this is the only way to exercise the + // onEvents cues (notice / mirrored / sealed / stasis / repaired) before SIM's + // escalation ladder happens to climb during a verification session. + correction, juice, wildlife, relics, notice, + fire: (...e: SimEvent[]) => { for (const x of e) pending.push(x); }, }; } @@ -341,15 +437,24 @@ export function createRenderer(): Renderer { view = dev.snapshot(snap.tick); } + // Events first: every one-shot cue reasons about THIS frame's view, and the stasis + // rects a `stasis` event opens must exist before CorrectionLayer computes what is + // frozen — otherwise the freeze would always be one frame late. + applyEvents(view); + rig.update(dt); // Topology is shared by belts (which piece to draw) and cargo (which path the // items take), so it is rebuilt once here rather than twice downstream. topo.rebuild(view, defs); + // The Correction runs BEFORE the layers that read its `frozen` set. It is also the + // one layer that takes `alpha`: rungs 2-4 glide, everything the player owns judders + // (§8 — wrong motion belongs to corruption, not to authority). See correction.ts. + correction.sync(view, alpha, t, dt, rig.camera); belts.sync(view, t); - entities.sync(view, t, dt, harassedIn(view)); - cargo.sync(view, alpha, t); + entities.sync(view, t, dt, harassedIn(view), correction.frozen); + cargo.sync(view, alpha, t, correction.frozen); ghost.update(view, t, inspectedEntity()); - relics.sync(view.relics, t); + relics.sync(view.relics, t, relicsFound); wildlife.sync(view.wildlife, t); seamLayer?.frame(t); breakroom.frame(t); @@ -397,5 +502,14 @@ export function createRenderer(): Renderer { setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build') { ghost?.set(def, pos, dir, mode); }, + + /** + * v9. Buffered, not acted on — see `applyEvents`. The array belongs to main.ts and is + * shared with ScreenFX/AudioFX, so we copy the references out and never retain it. + */ + onEvents(events: SimEvent[]) { + if (!gl) return; // init hasn't run; nothing to cue + for (const e of events) pending.push(e); + }, }; } diff --git a/fktry/src/render/juice.ts b/fktry/src/render/juice.ts index 01fdd60..a9b9314 100644 --- a/fktry/src/render/juice.ts +++ b/fktry/src/render/juice.ts @@ -99,6 +99,30 @@ export class Juice { this.emit(cx, cz, 4, 0.35, 1.6, 0.11, 0.6, 0xd6ffe0); } + /** `sealed`: the warden's rubber stamp landing. One hard, flat, downward slap — no + * spread, no arc. Paperwork does not explode; it is simply, suddenly, done. */ + stamp(cx: number, cz: number): void { + this.emit(cx, cz, 8, 0.9, 0.5, 0.14, 0.35, 0xeafff2); + } + + /** `mirrored`: a shipment stolen by the gunship's clean copy. The item's own colour, + * drained toward white — it left as yours and arrived as theirs. */ + mirrorFlash(cx: number, cz: number, col: number): void { + this.emit(cx, cz, 7, 0.7, 3.4, 0.16, 0.4, col); + this.emit(cx, cz, 5, 0.3, 4.6, 0.13, 0.5, 0xffffff); + } + + /** `stasis` release: the region snapping back to speed. Hard and bright — the whole + * point is that a lock does not ease off, it ends. Fired at the rect's corners. */ + stasisRelease(cx: number, cz: number): void { + this.emit(cx, cz, 12, 3.2, 4.2, 0.2, 0.32, 0xffffff); + } + + /** A notice arriving: the fax's world-side twitch. Small, white, bureaucratic. */ + notice(cx: number, cz: number): void { + this.emit(cx, cz, 6, 0.6, 2.2, 0.13, 0.5, 0xf0f4ff); + } + update(dt: number, camera: THREE.Camera): void { // integrate + recycle for (let i = this.live.length - 1; i >= 0; i--) { diff --git a/fktry/src/render/notice.ts b/fktry/src/render/notice.ts new file mode 100644 index 0000000..0094496 --- /dev/null +++ b/fktry/src/render/notice.ts @@ -0,0 +1,134 @@ +/** + * LANE-RENDER — the NOTICE ping. + * + * Contracts v9: "'notice' always precedes action (fax first, never silent)". The fax is + * where a notice physically arrives, and the fax is a DOM panel (LANE-UI's `#fk-fax`), so + * the only honest place to put this cue is on the glass — a hospital-white halo that + * strikes once around the panel and decays. The `notice` event carries no `pos`, so there + * is no world anchor to draw it at even if there were a fax in the world. + * + * OWNERSHIP: this element is created by, appended by, and owned by LANE-RENDER — the same + * arrangement as the vignette overlay (ground.ts) that has shipped since round 1. It + * READS `#fk-fax`'s bounding box to know where to strike and never touches LANE-UI's DOM. + * If the fax panel isn't mounted (showroom, devscene, a UI mid-refactor) it falls back to + * the bottom-right of the play area rather than disappearing. Filed in NOTES as a bridge: + * a `notice` event carrying a position, or a UI-side hook, would retire the DOM read. + * + * Stacking: `#game` is positioned but has no z-index, so it creates no stacking context — + * a positioned child of it therefore competes in the ROOT stacking context and can sit + * above `#ui` (z-index 10). Hence 20. Verified on the live page, not assumed. + */ + +/** Which rung is filing. Only the label differs; the strike is one faction gesture. */ +const LABEL: Record = { + 'checksum-warden': 'CHECKSUM WARDEN', + 'redundancy-gunship': 'REDUNDANCY GUNSHIP', + 'hash-auditor': 'HASH AUDITOR', +}; + +const CSS = ` +@keyframes fk-notice-strike { + 0% { opacity: 0; transform: scale(1.10); } + 9% { opacity: 1; transform: scale(1.00); } + 22% { opacity: 0.75; transform: scale(1.00); } + 100% { opacity: 0; transform: scale(1.00); } +} +.fk-notice { + position: fixed; + z-index: 20; + pointer-events: none; + border: 2px solid #39ff88; + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(255,255,255,0.85) inset, 0 0 26px 6px rgba(57,255,136,0.38); + background: rgba(242,244,248,0.10); + opacity: 0; + font: 10px/1.2 "Courier New", ui-monospace, monospace; + color: #eafff2; + letter-spacing: 0.14em; + text-align: right; +} +.fk-notice > span { + position: absolute; + right: 4px; + top: -14px; + text-shadow: 0 0 6px #000, 0 1px 2px #000; + white-space: nowrap; +} +.fk-notice.fk-on { animation: fk-notice-strike 1.5s ease-out 1 forwards; } +@media (prefers-reduced-motion: reduce) { + .fk-notice.fk-on { animation-duration: 0.9s; } +} +`; + +export class NoticePing { + private el: HTMLDivElement; + private label: HTMLSpanElement; + private host: HTMLElement; + + constructor(host: HTMLElement) { + this.host = host; + if (!document.getElementById('fk-notice-style')) { + const style = document.createElement('style'); + style.id = 'fk-notice-style'; + style.textContent = CSS; + document.head.appendChild(style); + } + this.el = document.createElement('div'); + this.el.className = 'fk-notice'; + this.label = document.createElement('span'); + this.el.appendChild(this.label); + host.appendChild(this.el); + } + + /** + * Where the notice lands. `#fk-notices` is LANE-UI's v9 notice tray and is the more + * specific home when it is mounted; `#fk-fax` is the fax panel itself. A zero-size rect + * means the panel is `display:none` right now, which is a real and ordinary state — UI + * updates AFTER the renderer inside a frame, so on the first notice of a session the + * panel may not have been laid out yet. + */ + private anchor(): DOMRect | null { + for (const id of ['fk-notices', 'fk-fax']) { + const r = document.getElementById(id)?.getBoundingClientRect(); + if (r && r.width > 0 && r.height > 0) return r; + } + return null; + } + + /** Strike once. Re-triggering mid-animation restarts it — a second notice is a second + * notice, and The Correction does not queue politely. */ + fire(unit: string): void { + this.label.textContent = `NOTICE · ${LABEL[unit] ?? unit.toUpperCase()}`; + const r = this.anchor(); + if (r) { this.strike(r); return; } + // Nothing measurable yet. Give the UI exactly one frame to lay itself out before + // giving up — a notice that struck an empty corner because we asked 4ms too early + // would be indistinguishable from a bug. + requestAnimationFrame(() => this.strike(this.anchor())); + } + + private strike(r: DOMRect | null): void { + if (r) { + this.el.style.left = `${r.left - 4}px`; + this.el.style.top = `${r.top - 4}px`; + this.el.style.width = `${r.width + 8}px`; + this.el.style.height = `${r.height + 8}px`; + } else { + // No panel on this page (showroom, devscene, a UI mid-refactor): strike where one + // would be, so the cue is never silent. Fax first, never silent — including here. + const h = this.host.clientHeight || innerHeight; + const w = this.host.clientWidth || innerWidth; + this.el.style.left = `${w - 300}px`; + this.el.style.top = `${h - 240}px`; + this.el.style.width = '280px'; + this.el.style.height = '150px'; + } + this.el.classList.remove('fk-on'); + void this.el.offsetWidth; // force reflow so the animation restarts + this.el.classList.add('fk-on'); + } + + dispose(): void { + this.el.remove(); + } +} diff --git a/fktry/src/render/palette.ts b/fktry/src/render/palette.ts index 0c2c007..8cfcfc9 100644 --- a/fktry/src/render/palette.ts +++ b/fktry/src/render/palette.ts @@ -79,3 +79,24 @@ export const HEIGHT_BY_KIND: Record = { }; export const JAM_AMBER = 0xffa726; + +/** + * THE CORRECTION's uniform (§5). One faction, four rungs, one palette: hospital-white + * bodies, matte and spotless, with a single green element each. Green is the faction + * signature — it is the parity mite's checkmark eye, the compliance seal's tick, and the + * gunship's parity link, so a player learns "green on white = the immune system" once and + * reads all four rungs with it. + * + * `ledger` is the exception, and it is the codex's: §5 gives the CHECKSUM WARDEN "red LED + * ledger eyes". Kept, but deliberately as pinpoints on the drawer chest rather than as the + * unit's one impossible element — a large red element would say "different faction" on a + * screen where the other three rungs are green. + */ +export const CORRECTION = { + shell: 0xf2f4f8, // hospital-white, matte + shellDark: 0xd9dee8, // panel/drawer breaks, so a white box still has edges + green: 0x39ff88, // faction signature (matches the mite's checkmark, wildlife.ts) + ledger: 0xff3b30, // §5's red LED ledger eyes — warden only, pinpoints only + /** Ghost mirror + stasis bleach: pure white, worn as transparency rather than emissive. */ + ghost: 0xffffff, +} as const; diff --git a/fktry/src/render/relics.ts b/fktry/src/render/relics.ts index a42d515..2216a56 100644 --- a/fktry/src/render/relics.ts +++ b/fktry/src/render/relics.ts @@ -58,9 +58,6 @@ export class RelicLayer { private seen = new Set(); private shimmerUniforms = { uTime: { value: 0 } }; private shimmerMat: THREE.ShaderMaterial; - /** False until the first sync: a save loaded with a relic already found must not fire - * the excavation geyser, exactly as EntityLayer primes before firing placement juice. */ - private primed = false; constructor(private juice?: Juice) { this.group.name = 'relics'; @@ -73,14 +70,22 @@ export class RelicLayer { }); } - sync(relics: ReadonlyArray | undefined, timeSec: number): void { + /** + * @param justFound relic ids that fired `relicFound` this frame. v9 replaced round 6's + * derived unfound→found transition with the real event: the transition also fires when a + * save that already contains an excavated relic is LOADED, which is a geyser nobody dug. + * Round 6 papered over that with a `primed` flag; the event simply doesn't have the bug. + */ + sync( + relics: ReadonlyArray | undefined, timeSec: number, + justFound?: ReadonlySet, + ): void { this.shimmerUniforms.uTime.value = timeSec; this.seen.clear(); for (const r of relics ?? []) { this.seen.add(r.id); let rec = this.live.get(r.id); - const justExcavated = !!rec && !rec.found && r.found; if (rec && rec.found !== r.found) { this.drop(r.id, rec); // excavated: swap shimmer → monument rec = undefined; @@ -92,9 +97,8 @@ export class RelicLayer { rec = { found: r.found, obj }; this.live.set(r.id, rec); } - // The fossil surfaces: a golden geyser that "sings" up out of the crust. Gated on a - // real unfound→found transition seen live (not a save that loads already-excavated). - if (justExcavated && this.primed) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5); + // The fossil surfaces: a golden geyser that "sings" up out of the crust. + if (justFound?.has(r.id)) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5); if (r.found) { // gentle lift + rotate so the excavated fossil reads as an exhibit rec.obj.rotation.y = timeSec * 0.5; @@ -103,7 +107,6 @@ export class RelicLayer { } for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec); - this.primed = true; } private buildShimmer(): THREE.Object3D { diff --git a/fktry/src/render/showroom.ts b/fktry/src/render/showroom.ts index ecce169..ccf4562 100644 --- a/fktry/src/render/showroom.ts +++ b/fktry/src/render/showroom.ts @@ -18,7 +18,9 @@ * Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag. */ import * as THREE from 'three'; -import type { Dir, EntityState, GameData, SimSnapshot } from '../contracts'; +import type { + CorrectionState, CorrectionUnit, Dir, EntityState, GameData, MachineDef, SimSnapshot, +} from '../contracts'; import { AssetRegistry } from './registry'; import { itemGeometry, itemMaterial } from './beltitems'; @@ -40,6 +42,13 @@ const I_GAP = 7; // between the two halls const I_Z_LIMIT = 30; // keep the item hall inside the world (camera clamps at ±32) const X_LIMIT = 30; const PLINTH_H = 0.05; +/** + * THE CORRECTION bay — rungs 2-4, one row NORTH of the machine hall so the asset grid is + * untouched (the gunship's mirror and the auditor's bleach are translucent overlays, and + * hazing three catalog machines with them would compromise exactly the read this page + * exists for). It carries its own duplicate machines to be mirrored, sealed and frozen. + */ +const C_Z_GAP = 6; interface Slot { label: string; @@ -66,9 +75,70 @@ export class Showroom { this.build(); } + /** + * The Correction bay. Three placeholder rungs on plinths, plus four duplicate machines + * that exist only to be acted upon: two inside the gunship's mirrored sector, one wearing + * a compliance placard, one inside the auditor's stasis lock (watch its idle stop). + * Duplicates, not hall machines, so `?showroom`'s asset QA read stays clean. + */ + private buildCorrectionBay(bz: number, nextId: number): number { + let id = nextId; + // Prefer machines with a visible idle so "frozen" and "sealed" read as a CHANGE. + const pick = (kind: MachineDef['kind']): MachineDef | undefined => + this.data.machines.find((m) => m.kind === kind); + const subject = pick('crafter') ?? this.data.machines[0]; + if (!subject) return id; + + const place = (def: MachineDef, cx: number, sealed = false): number => { + const eid = id++; + this.entities.push({ + id: eid, def: def.id, + pos: { x: Math.round(cx - def.footprint.x / 2), y: Math.round(bz - def.footprint.y / 2) }, + dir: FACING, recipe: null, progress: 0, inputBuf: {}, outputBuf: {}, + jammed: null, heat: 0, scrammed: false, sealed, + }); + this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, bz)); + return eid; + }; + + const label = (text: string, sub: string, cx: number): void => { + this.slots.push({ label: text, sub, x: cx, z: bz, w: 0, h: 0 }); + }; + + // --- rung 3: the gunship, over a two-machine sector it is mirroring --------------- + const anchor = place(subject, -9); + place(pick('extractor') ?? subject, -3); + label('REDUNDANCY GUNSHIP', 'rung 3 · mirrored sector + parity tether', -6); + this.units.push({ + id: 9002, kind: 'redundancy-gunship', pos: { x: -7, y: Math.round(bz) }, + phase: 'tether', progress: 0.8, target: anchor, + rect: { x: -12, y: Math.round(bz) - 2, w: 12, h: 4 }, + }); + + // --- rung 2: the warden, mid-stamp, beside the machine it just sealed ------------- + place(subject, 5, true); + label('CHECKSUM WARDEN', 'rung 2 · stamp arm + compliance placard', 2); + this.units.push({ + id: 9001, kind: 'checksum-warden', pos: { x: 2, y: Math.round(bz) }, + phase: 'audit', progress: 0.5, target: id - 1, + }); + + // --- rung 4: the auditor, holding a lock over one frozen machine ------------------ + place(subject, 13); + label('HASH AUDITOR', 'rung 4 · 0:00 stasis lock (never walks)', 10); + this.units.push({ + id: 9003, kind: 'hash-auditor', pos: { x: 10, y: Math.round(bz) }, + phase: 'stasis', progress: 0.4, + rect: { x: 8, y: Math.round(bz) - 2, w: 8, h: 4 }, + }); + + return id; + } + private build(): void { let id = 1; const mCentre = ((M_COLS - 1) * M_SPACING) / 2; + id = this.buildCorrectionBay(M_Z0 - C_Z_GAP, id); // --- machines: fabricated entities, rendered by the real EntityLayer ------------- this.data.machines.forEach((def, i) => { @@ -143,9 +213,14 @@ export class Showroom { shippedTotal: {}, activeCommission: null, commissionProgress: {}, + correction: this.correction, }; } + /** v9: the Correction bay's three rungs. Mutable from the console for QA. */ + readonly units: CorrectionUnit[] = []; + readonly correction: CorrectionState = { debt: 0.5, band: 3, units: this.units }; + frame(timeSec: number): void { for (const o of this.decor.children) { if (o.userData.spin) o.rotation.y = timeSec * 0.9; diff --git a/fktry/src/render/wildlife.ts b/fktry/src/render/wildlife.ts index 7e269a6..6e66968 100644 --- a/fktry/src/render/wildlife.ts +++ b/fktry/src/render/wildlife.ts @@ -7,15 +7,20 @@ * the machine's own flicker is EntityLayer's job, driven off the same `target` ids. * Parity mites are hospital-white beetles with a green checkmark eye that walk with an * unsettling gentleness (§5): they don't rampage, they FILE. Where one corrects your - * product it leaves an infuriatingly tidy sparkle (Juice.tidy), the visual of the - * 'repaired' event — which render() can't see, so it's derived from the mite's presence. + * product it leaves an infuriatingly tidy sparkle (Juice.tidy). + * + * v9/round 7: that sparkle is no longer derived here. `Renderer.onEvents` exists now, so + * index.ts fires it on the real `repaired` event at the event's own `pos`. This layer + * still records each mite's last-seen `size`, because the OTHER cue — a mite canned by a + * firewall rather than leaving sated — is a `wildlife: 'cleared'` event either way, and + * `size` is the only thing that tells the two apart. Timing and position are now exact; + * only that one discrimination is still state-derived. See NOTES. * * All instanced from day one: one InstancedMesh for every pile, one for every gnat across * every swarm, one for every mite body, one for every mite eye. */ import * as THREE from 'three'; import type { SimSnapshot, WildlifeState } from '../contracts'; -import type { Juice } from './juice'; const GNATS_PER_SWARM = 24; @@ -58,14 +63,14 @@ export class WildlifeLayer { private miteCap = 96; private dummy = new THREE.Object3D(); private eyeCol = new THREE.Color(); - /** Per-mite last-seen {size,pos}, so the renderer can derive the two mite events it can't - * see: `repaired` (size crossing <1 → ≥1 = a correction landed) and "canned by a - * firewall" (a mite gone from the list while still unsated = filed, not fled). */ - private miteState = new Map(); + /** Per-mite last-seen size. Read by index.ts when a `wildlife: 'cleared'` event names a + * parity mite: unsated (<1) means a firewall canned it mid-repair; sated means it + * simply reached the rim and left carrying its correction, which gets no cue. */ + private miteState = new Map(); /** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */ readonly harassed = new Set(); - constructor(private juice?: Juice) { + constructor() { this.group.name = 'wildlife'; // A low, faceted heap; instance scale grows it with `size`. const pileGeo = new THREE.IcosahedronGeometry(0.5, 0); @@ -155,15 +160,10 @@ export class WildlifeLayer { } } - // A mite that vanished from the list: sated ones (size≥1) reached the rim and left - // carrying their correction — no cue. One still unsated was CANNED by a firewall - // mid-repair (§5's counter): the product survived, so it reads as filed paperwork — - // an infuriatingly tidy sparkle where the jar closed. - for (const [id, st] of this.miteState) { - if (seenMites.has(id)) continue; - if (st.size < 1) this.juice?.tidy(st.x, st.z); - this.miteState.delete(id); - } + // Forget mites that are gone. The cue itself is fired by index.ts from the real + // `wildlife: 'cleared'` event — which arrives on the SAME frame the mite disappears + // from this list, so the size recorded here is still the one it had when it died. + for (const id of this.miteState.keys()) if (!seenMites.has(id)) this.miteState.delete(id); this.piles.count = pileN; this.piles.instanceMatrix.needsUpdate = true; @@ -181,8 +181,8 @@ export class WildlifeLayer { * mid-repair, size≥1 = sated and carrying the correction home. The tell tracks it — * a hunting mite drifts with a slow, deliberate patrol (the wrongness is the calm, not * the speed); on station it stops and only trembles while the checkmark eye glows up to - * completion; sated it moves off. The eye is tilted to meet the iso camera. Crossing - * into sated fires the tidy 'repaired' sparkle where the product used to be. + * completion; sated it moves off. The eye is tilted to meet the iso camera. The tidy + * 'repaired' sparkle is fired from the real event now, not from this crossing. */ private placeMite(w: WildlifeState, timeSec: number, n: number): number { const cx = w.pos.x + 0.5; @@ -218,13 +218,14 @@ export class WildlifeLayer { const glow = size <= 0 ? 0.5 : size < 1 ? 0.5 + 0.5 * size : 1; this.miteEyes.setColorAt(n, this.eyeCol.setRGB(glow, glow, glow)); - // Derive the `repaired` event (render() can't see events): the correction lands the - // instant the mite crosses from repairing into sated. - const prev = this.miteState.get(w.id); - if (prev && prev.size < 1 && size >= 1) this.juice?.tidy(cx, cz); - this.miteState.set(w.id, { size, x: cx, z: cz }); + this.miteState.set(w.id, size); return n + 1; } + + /** Last-seen `size` for a parity mite, or undefined if this layer never saw it. */ + sizeOfMite(id: number): number | undefined { + return this.miteState.get(id); + } } /** Convenience: which entities a snapshot's swarms are harassing (for EntityLayer). */