[render] THE INVERSION: The Correction is the only thing that never misses a frame

Rungs 2-4 embodied as hospital-white placeholder bodies (warden / gunship /
auditor), the gunship's ghost-mirrored sector + parity tether, the auditor's
0:00 stasis bleach, compliance placards, and the v9 onEvents migration.

The inversion is measured, not asserted: fed a worst-case integer-stepped
position that the sim moves on 6 of 120 frames, the warden renders 120 distinct
positions with zero stalled frames. The auditor renders 1 position and 119
stalled frames, deliberately — it moves only between frames (codex 5).

- correction.ts: tick-lerp + damped follow for wardens/gunships, instant
  relocation + blink for auditors, instanced ghost mirror (cap 512), tether,
  bleach + 00:00:00:00 timecode, and the frozen-entity set
- entities.ts: compliance placard + total idle/mixer/haze freeze (idle clock
  delta measured at exactly 0.000000 over 30 sealed frames)
- beltitems.ts: cargo pinned MID-TILE inside a lock; the freeze path is
  cheaper than the normal path (0.392 vs 0.465 ms at 2,000 items)
- index.ts: onEvents buffered and drained at the top of render(); repaired,
  the firewall can, and relicFound all retired as snapshot heuristics
- notice.ts: the fax-side notice halo, with a one-frame retry because main.ts
  runs the renderer BEFORE the UI and the panel may not be laid out yet
- correction.test.ts: 9 tests, mutation-checked. Pins the cross-lane rect
  convention against LANE-SIM's own frozenEnt formula over 4,896 cases.

Perf at a deliberate worst case (500 entities, 2,000 items, the entire base
both mirrored AND frozen): whole frame 2.30 ms median against a 16.6 ms
budget; this round's net cost ~0.2 ms. Prod bundle clean of all scaffold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-29 12:52:40 +10:00
parent 88bcfada88
commit 462db9947c
14 changed files with 1786 additions and 65 deletions

View File

@ -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.410.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.

View File

@ -63,6 +63,15 @@ export class BeltItemLayer {
private written = new Map<string, number>();
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<number, { x: number; y: number; z: number; ry: number }>();
private icedSeen = new Set<number>();
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<number>): 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;

View File

@ -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.

View File

@ -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);
}
});
});

View File

@ -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<number>();
private units = new Map<number, UnitRec>();
private seen = new Set<number>();
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<string, Rect>();
private activeStasis: Rect[] = [];
private dummy = new THREE.Object3D();
private timecodeMat: THREE.MeshBasicMaterial;
private v = new THREE.Vector3();
constructor(private defs: Map<string, MachineDef>, 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<CorrectionUnit>, 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<T extends THREE.Object3D>(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;

View File

@ -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 };
}

View File

@ -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 s1 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<number>): 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<number>, frozen?: ReadonlySet<number>,
): 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);

View File

@ -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<number>();
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);
},
};
}

View File

@ -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--) {

134
fktry/src/render/notice.ts Normal file
View File

@ -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<string, string> = {
'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();
}
}

View File

@ -79,3 +79,24 @@ export const HEIGHT_BY_KIND: Record<MachineKind, number> = {
};
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;

View File

@ -58,9 +58,6 @@ export class RelicLayer {
private seen = new Set<number>();
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<RelicState> | undefined, timeSec: number): void {
/**
* @param justFound relic ids that fired `relicFound` this frame. v9 replaced round 6's
* derived unfoundfound 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<RelicState> | undefined, timeSec: number,
justFound?: ReadonlySet<number>,
): 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 {

View File

@ -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;

View File

@ -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<number, { size: number; x: number; z: number }>();
/** 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<number, number>();
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
readonly harassed = new Set<number>();
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, size1 = 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). */