Compare commits

...

71 Commits

Author SHA1 Message Date
type-two
0f0bfb039b [orchestrator] round 7 review: ratify the airborne ruling, expose the dev sim handle
CONTRACTS v9 notes: 'sealed' jam reason documented; SIM's firewalls-catch-only-
what-walks departure RATIFIED (UI measured the alternative as rung 3 not
existing); unitCleared queued as round 8's first contract item.

main.ts DEV block now exposes window.__fktrySim so testkit verbs can drive an
in-browser review the way lane tests drive headless ones - used today to verify
the full Phase 1 loop live: band 4, three simultaneous notices, gunship tether +
real mirrored theft (sky placard + ledger + ticker + toast), auditor SECTOR LOCK
with hash countdown, THE SCREEN held at HOLD with zero feed-time cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:21:58 +10:00
type-two
69e3f5a37f [sim] THE CORRECTION rungs 2-4: parity debt, wardens, gunships, hash auditors
PARITY DEBT is one scalar with five bands: it rises on tier>=2 shipments, always
decays, decays faster while stock footage ships, and vents outright when a
compliance quota is filed. Bands gate the ladder and nothing else needs to know
more than the band.

Wardens fax 20s ahead, walk in from the rim, roll back the last N outputs from a
new per-machine output ring and stamp a compliance seal; sealed machines refuse to
run until pried (90 ticks, and prying is itself deviation). Gunships tether to the
8x8 sector with the most DEVIATION and mirror it. Hash auditors prescan, then lock
a rect with a deterministic hash that ALWAYS auto-releases.

A FIREWALL CATCHES WHAT WALKS — mites and wardens, not the airborne gunship or the
auditor that is never present. This departs from the orders' "or a firewall cans
it" for the gunship: LANE-UI measured rung 3 being deleted entirely by a machine
the player already owns, and there is no tile in the brick works that guards the
wardens without also covering the gunship's station. Regression test requires a
mirror in the demo as shipped, firewalls and all.

Reference v7 adds the compliance tap (DATA measured zero commissions clearing in
90k ticks, so every quota was dead content), a canning line for rung 5, a doubled
brick works that can absorb a seal, and the power to pay for it. Save v5.

Determinism byte-identical browser vs Node across a 20k run. 645 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:11:34 +10:00
type-two
b001b1b11d [ui] note v9.3: notice.pos stays out of the fax, by? is next round's firewall log
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:01:22 +10:00
type-two
550aa6e4a9 [ui] toast cooldown: 170 lines per 1,500 ticks becomes 43, and v9.2 remaining
Approved addendum. A repeating line prints once and then counts (×N) instead of
printing again; the window runs from the last line PRINTED, so a permanent stall
still speaks every 12s rather than going silent. Brownout, commissions, research,
relics, seals, stasis and band changes are never throttled — each is news once.

RULING made live: the throttle key is the RENDERED LINE, not the entity. Keying on
(entity, reason) passed its tests and still put four identical "QUANTIZER: INTAKE
STARVED" lines on screen, one per quantizer; the copy carries no unit id, so ×4
says strictly more than four copies do.

Also: adopt contracts v9.2 CorrectionUnit.remaining in stasis.ts (finite and >= 0
wins, phase-derived estimate stays as the fallback for open-ended phases). SIM
landed it mid-write — verified against their code, the lock chip now opens at 15S
and counts down instead of opening on HELD.

And one copy fix the first real gunship mirror exposed: "YOUR MACROBLOCK BRICKS WAS
MIRRORED" is now "A CLEAN COPY OF YOUR <ITEM> SHIPPED FIRST. THE LEDGER IS
SATISFIED." — no verb left to disagree with a plural.

277 UI tests, tsc clean tree-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:00:39 +10:00
type-two
b63f9d11a1 [orchestrator] v9.3: notice.pos? and repaired/mirrored by? (RENDER's last derived cues)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:54:15 +10:00
type-two
462db9947c [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>
2026-07-29 12:52:40 +10:00
type-two
88bcfada88 [orchestrator] v9.2: CorrectionUnit.remaining (UI countdown chips get truth)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:50:36 +10:00
type-two
0508910f27 [ui] round 7 addendum: NEXT IN TRAY asserts the fax's contract, not SIM's cascade
My own round-3 test claimed the sim advances to commissionQueue[1]. DATA's v9
commissions falsified that twice in twenty minutes — first by cascading (entries
behind the head were already satisfied and completed on arrival), then by putting
an unpayable head in front of the rig. The peek assertion — NEXT IN TRAY is always
the live queue's second entry — is unconditional again; the advance is only
asserted when the head is one the test rig can actually pay.

263 UI tests green, tsc clean tree-wide under contracts v9.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:49:12 +10:00
type-two
b002e68b5a [orchestrator] v9.1 addendum: 'notice' joins the phase union; type SCREEN's fixture
The pin caught SIM's in-flight code using a 'notice' phase within ten minutes of
landing - the mandated gap between the fax and the act is a real lifecycle state
the original six missed. SCREEN's test fixture takes CorrectionUnit['phase'] now,
so a typo'd phase fails at compile instead of silently never matching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:41:28 +10:00
type-two
074ba17ab4 [orchestrator] v9.1: pin CorrectionUnit.phase as a union (SCREEN's mid-round request)
SCREEN string-matches the phase vocabulary for the warden-arrival compliance tone
and the stasis save-load fallback; with phase as a documented-by-example free
string, SIM landing 'auditing' instead of 'audit' would silently kill the tone
with no red test. Pinned to the six documented phases; additions go through NOTES.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:16 +10:00
type-two
5500949d83 [ui] the enemy has paperwork: PARITY DEBT gauge, notices, seals + PRY, stasis
Round 7 phase 1, contracts v9. Five surfaces for The Correction's rungs 2-4:

- PARITY DEBT gauge in the top strip — five clinic-white bands, never red, never
  flashing, hidden until the debt first moves and visible forever after.
- Notices are faxes: a white-letterhead tray above the commission fax. No unit
  name, no grid reference (pinned by a test that forbids digits outright), and
  deliberately not also a toast. Compliance quotas get a COMPLIANCE stamp.
- Compliance seal panel + PRY, with the bar driven by SIM's own pry progress and
  the PROCESS bar standing down so that number can't be misread as a batch.
- Stasis: hash-named countdown chips derived from the auditor's phase progress,
  and the abstention teach in the one window where it is still actionable.
- MIRRORED joins the CORRECTED block as its own row, not the same counter.

Verified live on :8153 by driving the real sim + real UI in the browser: a full
warden cycle (seal -> PRY -> "SEAL REMOVED. THIS WILL BE NOTED." -> debt up) and a
full auditor cycle (teach -> lock 744510A8 -> countdown -> auto-release).
263 UI tests, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:40:06 +10:00
type-two
de5a1b5770 [screen] round 7: the gunship's theft, the auditor's hold, and one note at 440.000 Hz
mirrored and repaired now share ONE decrement path (correct()) and ONE placard slot —
the gunship's card is architectural (drafted sector, registration marks, no tick) where
the mite's is clerical. A sector theft and a unit theft move the ledger identically;
there is a test pinning it, because that is what a fork would break.

Stasis is implemented as a clock, not an effect: the sky gets its own feedSec, and a
hash auditor simply stops it accumulating. Release is byte-identical to the moment the
hold began — 5 seconds of audit cost the feed exactly zero — with none of the brownout's
seizure vocabulary. The transport legend flips PLAY to HOLD; that is the only pixel a
hold changes.

THE COMPLIANCE TONE: one voice of the ratify triad, A4, frequency set and never ramped.
Measured single partial, harmonics at the double-precision floor, pitch wander 0.000 Hz,
zero sidebands, bit-identical across renders — generated, not recorded. Gated on
discovered exactly as tapeChunk is: 146 million notices over 6s of audio time produce
exactly 5 notes.

Debt band >= 3 wears the pallor machinery with its sign flipped, white instead of grey.
Bands 0-2 render byte-identical: a factory in good standing wears no mark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:38:54 +10:00
type-two
3acd605e2e [data] compliance quotas, the forged hash, and eight lines about being audited
Round 7 phase 1. The Correction grows an HR department.

- COMPLIANCE QUOTAS: four `compliance:true` standing orders spread across the
  reward curve (indices 5/9/13/20) so the debt valve is never more than eight
  faxes away. Tier-0/1 only, enforced against SIM's DEBT_TIER_MIN rather than a
  hand-typed 2. Measured on the real sim: one quota vents -0.2004, i.e. exactly
  one band.
- FORGED HASH: 1 STCO MAP + 50 HF DUST -> 1 FORGED HASH on the existing
  hex-splicer, gated behind a new fortress node. Two inputs, so it cannot travel
  SIM's empty-inputs catcher path; a test fails by name if it ever does. §6 names
  the forged article outright and the splicer has charted the genuine map since
  round 3 - the machine, the material and the crime were already in the data.
- Eight STANDARD chyron lines, one per mechanic the player is about to meet,
  all <=96 chars. The width cap now has a test in this lane instead of SCREEN's.
- CANON-DELTA: the rungs-2-4 reading of §5, the no-grudge ruling, the
  deviation-targeting ruling (tier is now read by three mechanics), and the
  forged hash's argument against the container-pack reading.

Validator 74 -> 86; all 11 new checks fault-injected and restored.
2026-07-29 12:37:25 +10:00
type-two
60a14313b4 [orchestrator] round 7 phase 1 orders: THE CORRECTION, EMBODIED
Contracts v9: CorrectionState (debt/band/units, rungs 2-4 — the mite stays in
wildlife by ruling), EntityState.sealed, pry command, CommissionDef.compliance,
notice/sealed/mirrored/stasis/debtBand events, and Renderer.onEvents?() — RENDER's
round-6 request, granted now that three cues derive events from snapshot deltas.
main.ts fans the same event array to the renderer.

Rulings baked into the orders (from the adversarial design pass): fax-first and
auto-release are CONTRACTUAL on the auditor; warden faxes carry no grid reference;
gunships target deviation, not throughput; no debt for canning a unit (an immune
system does not hold a grudge); THE INVERSION - The Correction is the only thing
in the world that never drops a frame; the compliance tone is one grid-locked
triad voice, discovered-gated, sparse by construction.

Also: archaeology-lab.glb re-baked through the corrected farm pipeline
(bg_remove_local before meshing) - backdrop-slab flatness 0.614 -> 0.480.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:12:31 +10:00
type-two
72842e270a [render] root-cause fix: the world had no environment, so every metal read black
scene.environment was null for seven rounds. diffuse = albedo * (1 - metalness),
so a metal is nothing but reflected surroundings — and 13 of the 27 shipped GLB
materials are metallicFactor >= 0.8 (16 at >= 0.5, 21 with no MR texture). Round
6's GLB_EMISSIVE_FLOOR = 0.42 was cosmetics over a lighting hole.

- env.ts (new): procedural 128x64 half-float equirect through PMREMGenerator.
  Zero bytes shipped, no asset fetch. Dark industrial room per codex 8; key/fill
  blob directions passed in from index.ts's real lights, never duplicated.
- GLB materials take envMap explicitly (three overrides material.envMapIntensity
  with scene.environmentIntensity on the scene-fallback path), so the assets can
  be dosed at 2.6 while six rounds of hand-tuned world art keep 0.5.
- emissive floor 0.42 -> 0.18: re-tuned, not retired. Still the "never a hole"
  guarantee and still what carries a machine through a brownout.
- registry.init no longer awaits probeAll: renderer.init 284ms -> 28ms, ready at
  339ms -> 88ms. Placement juice suppressed on asset-version bumps so the factory
  doesn't re-place itself a second into every boot; a def change still pops.
- era.ts: eraIndex now breaks a contested unlock FIRST-tech-wins, matching
  LANE-UI's indexTech. It was LAST-tech-wins despite round 4's NOTES claiming
  otherwise; the tests found it.
- registry.test.ts (new): 31 tests, this lane's first. 481 -> 512 repo tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:58:04 +10:00
type-two
20d2bcbbfe [orchestrator] round 7 phase 0: 27.3 MB -> 7.0 MB, and the build can no longer forget
All 27 shipped GLBs run through gltf-transform (meshopt + webp + 1024 textures).
Measured per-asset, no failures: 27,304 KB -> 7,132 KB (74% off) with PBR intact.
The biggest wins are the wave-1/wave-2 assets that shipped raw (artifact-bottler
1453->329, chroma-keyer 1928->301, mv-extractor 1376->190); the six baked through
the new farm script were already lean and barely moved, which is the check that
the farm-side step is doing its job.

tools/optimize-assets.mjs: idempotent gate wired into deploy.sh BEFORE vite build.
Skips anything already carrying EXT_meshopt_compression (so it costs ~0s when
nothing is new) and FAILS THE BUILD on any asset over 900 KB — a farm regression
that emits trellis2-dense meshes now stops the deploy instead of shipping 36 MB.

deploy.sh verify gap closed: it tested exactly one .js file, so a deploy that
shipped ZERO models would have reported success. Now checks the JS bundle, a real
GLB endpoint, and asserts live model count == built model count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:21:20 +10:00
type-two
84d378a80e [orchestrator] asset pipeline v2 proof: PBR meshes at 40% the size
The new farm stack changes the economics of every asset in the game. Measured,
same machine, same concept:

  current (flux -> sf3d, albedo only)   1135 KB   27k tris   no PBR
  hunyuan3d_mlx raw                     7408 KB   60k tris   PBR
  hunyuan3d_mlx + gltf-transform         418 KB   18k tris   PBR   <-- ships
  trellis2_mlx raw                     36087 KB  487k tris   PBR
  trellis2_mlx + gltf-transform         4147 KB  273k tris   PBR   (too heavy)

So the optimized Hunyuan path is 2.7x SMALLER than what ships today and adds real
metallic-roughness maps. 30 machines would be ~13 MB against today's 31 MB.

Enabling change: GLTFLoader.setMeshoptDecoder (three bundles the decoder).
EXT_texture_webp + KHR_mesh_quantization are native. Uncompressed GLBs unaffected.
Verified in the live showroom: served 428068 bytes, webp textures, 1 PBR-mapped
material, zero console errors.

mosh-reactor.glb replaced with the pilot output (LoRA-directed concept: rainbow
melt in the porthole, recovered anchor slab glowing in the airlock).

Also fixed on the farm, not in this repo: the FLUX LoRA library lived in ComfyUI's
models/loras, which flux_local does not search, on a queue that load-balances to a
second node that had none of them - every LoRA call failed with 'LoRA not found'
depending on which machine caught the job. Both nodes now carry the same set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:52:01 +10:00
type-two
3fc043e504 [render] fix GLB body-scale regression: pop settles to baseScale, not 1
The placement scale-pop hardcoded the settled scale to 1, which stomped a
normalised GLB's fit-to-footprint root scale (s~2-3.5) down to 1 on the first
sync, collapsing the body to raw geometry size (a speck) while the beacon/jam
child meshes kept their own local scale. Store baseScale per entity (1 for
placeholders, the normalisation scale for GLBs) and make the pop a multiplier
on it. Verified full-size bodies on fresh loads in ?showroom and ?uidemo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:33:48 +10:00
type-two
0d491f8a2f [render] GLB legibility pass (emissive floor + accent beacon), parity-mite visuals, relic excavation VFX
- registry: texel-following emissive floor + one-impossible-element accent beacon on every
  hot-swapped GLB (generic, no hardcoded list) — restores the two-material rule on baked assets
- wildlife: parity-mite beetles with green checkmark eye, size-driven state tell (hunt/repair/
  sated), derived 'repaired' tidy sparkle + firewall-can 'filed paperwork' cue
- relics/juice: golden excavation geyser on unfound->found transition
- devscene: parity-mite fixtures across all three states for verification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:00:58 +10:00
type-two
68bddbec0a [sim] parity mites + firewall counter (recipe-shape) + reference v6
Round 6 (M3's enemy — THE CORRECTION walks).

- Parity mites (contracts v8): spawn from the world rim as tier-2 corruption
  crosses thresholds, patrol to the nearest tier-2 belt item, dwell, and repair
  it out of existence (emitting 'repaired'), then return to the rim sated. Never
  touch tier-0/1 cargo. Deterministic, save/load carried (no format bump).
- The firewall counter, detected by recipe shape (empty inputs, cans a mite) —
  the same pattern as the mosquito trap, so DATA's real firewall (landed
  mid-round) lights up with zero code. The repair dwell is the window a firewall
  needs to intercept a correction before it lands.
- Reference factory v6 auto-places DATA's firewall on the melt uplink approach,
  belted to a shipper so it never clogs; melt regression + zero-brownout intact.
- 11 new mite tests; 113 sim tests, 481 repo tests green; tsc clean. Verified in
  the browser: melt tick 1247 identical to Node, firewall canning live.
- Reshaped reference.test's demuxer assertion to sample a window (the ~18%
  transient press back-pressure is pre-existing; a single-tick check was a
  coin-flip). See LANE-SIM NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:49:36 +10:00
type-two
be52b4075b [data] round 6 CANON-DELTA + NOTES
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:37:43 +10:00
type-two
434faf96e6 [data] numbers-station: re-cut two leaking decoys + 8 STANDARD mite lines
08127 sat on reel-beds-deep, 27503 on broadcast-shelf-south. Re-cut to 08143/27475
(one tile off, still adjacent). The validator now decodes every decoy and fails by
name on ANY seam, not just the three hidden tiles. 8 new chyron lines in The
Correction's voice, about the mites, all <=96 to fit SCREEN's band.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:37:43 +10:00
type-two
5e3bbff636 [data] the firewall + parity mite, and accents chosen against the real GLB textures
FIREWALL machine on SIM's catcher shape (input-free catch-mite -> parity-mite),
gated behind broadcast-parity-firewall; parity-mite canned item; two commissions
that want The Correction alive. All 19 GLB'd machines accented from their actual
albedo textures (mosquito-trap corrected to UV-violet, decode-asic to ice-blue).
Validator +2: firewall shape guard + a mite-catcher-exists check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:37:43 +10:00
type-two
e20ce1341b [orchestrator] MODELBEAST wave 2: 17 more machines — every machine has a body now
flux_local -> sf3d on the m3ultra, same pipeline as wave 1. demuxer, demosaicer,
dct-press, subsampler, mv-extractor, p-caster, gop-assembler, bloom-duplicator,
field-loom, telecine-puller, hex-splicer, shipper, asic-cooler, roto-bench,
matte-bath, optical-printer, neural-keyer. 27 of 30 machines now carry real GLBs
(belt + lane-splitter + buffer-tank stay procedural by design - the shader belts
carry the flow-field chevrons a baked mesh would lose). ~31MB total, served as
static runtime fetches - none of it in the JS bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:34:35 +10:00
type-two
32ff942fd5 [screen] round 6: repairs ease the ledger + green-tick placards; hospital-white mite sterility creep
- repaired events decrement the ledger by one unit of the item (theft, not
  boredom-decay) and stamp a small perfect green-tick placard — The Correction
  signs its work; kept distinct from the crooked chip-coloured shipment stamp
- parity-mite activity grows a hospital-white sterile patch via growthMask
  (its 3rd/4th customer): desaturate + lift to clinical white + faint tile grid,
  the inverse of corruption and meant to feel worse
- DEV hook: .mite() .repair() plus .frame()/.canvas() pixel-verification drivers
- +3 repair-semantics tests (screen suite 33); order-3 STANDARD lines are DATA's
  copy, my chyron wraps them (verified live, no clipping)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:34:32 +10:00
type-two
eafd31f263 [ui] round 6: mite surfacing, dial tick marks, and a credits footer
- The Correction gets a voice: parity-mite arrival/swat copy, repaired-event
  toasts ("IT LEFT A RECEIPT."), a CORRECTED ticker in the top strip, and a
  per-unit FIREWALL LOG in the inspector. All clinical white-green (codex §8),
  hidden until the first edit so the enemy is never signposted early.
- Tuner dial draws anonymous station ticks from the speaker adapter's
  listStations() (MHz, empty pre-gesture) — landmarks that mark THAT, not WHAT.
- ABOUT/CREDITS footer on the tech panel: repo, codex, and a standing
  synthetic-voice disclosure slot for the voice-cloning plan.
- 213 UI tests (was 194): new corrections.ts + mite.test.ts, extended tuner
  and techpanel suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:30:31 +10:00
type-two
d313f2a163 [orchestrator] MODELBEAST first drop: 10 machines + contracts v8 + round 6 orders
ASSETS: 10 GLBs generated end-to-end on the local farm (flux_local concept ->
sf3d image-to-GLB on the m3ultra, ~2min/asset, zero cloud spend) and installed
into public/assets/models/: mosh-reactor, quantizer, seam-extractor, muxer,
archaeology-lab, software-decoder, decode-asic, mosquito-trap, chroma-keyer,
artifact-bottler. Hot-swap verified in showroom + demo world. Known gap, ordered:
GLBs carry no emissive accent so they read dark at game zoom - RENDER round 6.

Contracts v8: WildlifeState/'wildlife' event gain 'parity-mite'; new 'repaired'
SimEvent (a mite corrected your work); speaker adapter gains listStations()
passthrough (MHz) for dial tick marks. ui/voice.ts widened for the new kind
(placeholder copy; UI owns the real lines).

Round 6 orders in all lane docs - THE CORRECTION walks: parity mites (SIM),
GLB legibility pass (RENDER), mite surfacing + dial ticks + credits (UI),
repair stamps + sterile creep on the sky (SCREEN), firewall machine + numbers
decoy drift fix + accents for the GLB'd ten (DATA).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:00:31 +10:00
type-two
f75cf872bc [orchestrator] the dial drives, the sky finishes its sentences
Two verified production bugs from the voice-cloning review:

1. Tuner dead-dial: the dock resolved its RadioControl once, inside ui.init(),
   before any gesture built the audio graph - latching the silent echo stand-in
   for the whole session. main.ts now publishes a lazy-binding Radio->RadioControl
   adapter BEFORE ui.init (real subscribe() at probe time), attaching to the
   engine on first gesture and translating shape + units (UI MHz <-> engine kHz,
   volume()/mute() <-> setVolume()/setMuted(), locked id -> station name).
   Verified live: dock setFrequency(0.64 MHz) -> engine 640 kHz, station readout
   'THE STANDARD', '0.6 THE STANDARD AM - SIGNAL LOCKED' on the dock.

2. Chyron clipping: one un-wrapped fillText showed ~51 chars; 29 of THE
   STANDARD's 30 lines (mean 69, max 92) clipped mid-sentence. Now word-wraps to
   two lines with 20->16->13px step-down. Verified live: 71-char propaganda line
   renders complete across two lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:50:03 +10:00
type-two
f35f159b72 [orchestrator] round 5 (MEGA) review: integration fixes — the radio works in prod
Review: 5/5 lanes pass, 446/446, verified live end-to-end: fax ladder opens the
game, floor + seam glints render (RENDER's colorspace find), fossil excavates at
the seeded position, tuner dock slides in, radio locks 88.1 FKTRY FM and AM 640
THE STANDARD. The pipe is real.

Integration fixes (the cross-lane seams no lane owned):
- audio: radio handle exposed outside the DEV gate — the dock was silent in PROD
  (UI probed __fktrySpeaker, SCREEN's getRadio read a DEV-only object; both halves
  correct, handshake unowned). main.ts now publishes __fktrySpeaker on first gesture.
- contracts v7: GameData.seams (SeamDef/SeamMap lifted from SIM) + main.ts imports
  seams.json — seams were inert in the real game (SIM's request).
- index.html RULING: #screen stays interactive (UI's click-to-enlarge landed after
  RENDER's dead-zone probe — overtaken by events); cursor:pointer added.
- main.ts: ghost refreshes every frame (stale demolition tint on Esc — UI finding);
  __fktryDemo uses the grantResearch command (testkit retired).
- relic.test.ts Seamed type vs the new contract field (my breakage, my fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:32:56 +10:00
type-two
0cc80d3c82 [ui] round 5 notes
All nine orders shipped. The tab-click report was a real bug (scrollbar eating
half of every tab hitbox) and is fixed; RENDER's demolition tint is verified
with a screenshot, closing the item owed since round 3.

The honest parts: DATA's commission queue head-of-line blocks the whole game,
RENDER doesn't read the inspect selection yet, and the radio drives a stand-in
until SCREEN publishes its control. One proposal (audio on the bus), no
contract requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:42 +10:00
type-two
4da8096de9 [ui] live: research via testkit, and the round-5 behaviours against the real sim
Research is granted through SIM's own testkit.grantResearch now, not by
hand-editing a save blob here — that was my round-4 fragility and it broke this
round exactly as predicted. The research loop also runs the lab for real: v5
converts one pack per RESEARCH_TICKS_PER_PACK, so the test drives 45 packs
instead of asserting after 3 ticks.

New coverage against the real sim: the mercy toast fires once and stays once
across a second brownout; the inspect selection is published on open and
dropped both on Esc and when the inspected unit is demolished; the tuner stays
hidden until relics[0].found actually flips, then reveals with the ceremony
toast and the toast standoff.

The commission tests now build the rig a player would (extractor -> belt ->
uplink) because DATA's queue head asks for raw ore the reference factory never
ships — see NOTES on head-of-line blocking. Also stopped hardcoding the i-only
assembler's coordinates; reference.ts moved it again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:42 +10:00
type-two
0bf0e5a076 [ui] wire round 5: tuner reveal, screen enlarge, notices, camera hint
The HUD root gains the tuner and the enlarge overlay, the T/arrows/B/M keys,
and the small amount of session state the onboarding needs: the mercy toast
fires once ever, hatch warnings are remembered per pile.

The fossil reveals the dock from either direction — the relicFound event, or a
loaded save where relics[].found is already true and no event is coming. The
dock lands in the toast stack's corner, so the toasts stand off above it rather
than printing over the dial.

Esc now closes the most-recently-fronted thing first: enlarge, then the held
tool, then panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:42 +10:00
type-two
d6d6833b78 [ui] THE TUNER DOCK — the radio gets a face
Hidden until the optical fossil is excavated, then it slides in bottom-right:
frequency dial (drag or arrow keys), AM/FM/SW, volume and mute, and a station
readout in the house voice that reads STATIC until something locks. Its keys
are inert while hidden so they never shadow the pre-discovery game.

src/audio is still a stub with no radio export, so radio.ts resolves the
control at runtime and otherwise drives a silent echo stand-in — which is why
every dial is functional and testable today rather than waiting.

LANE-SCREEN: publish window.__fktrySpeaker = { radio } and this drives real
audio with no change here. The interface is exactly the one you documented —
setFrequency / setBand / volume / mute / getState / subscribe — with getState()
returning {freqMHz, band, volume, muted, station}; a non-null station is what
flips the readout to SIGNAL LOCKED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:18 +10:00
type-two
5d6afdac97 [ui] THE SCREEN click-to-enlarge
Click the sky, get the sky: ~2.5x, centred, over a dimmed backdrop. Esc or a
click closes it.

Enlarges the real element rather than mirroring it. I built the mirror first
and it rendered black — THE SCREEN is a WebGL canvas, and drawImage from a
WebGL context without preserveDrawingBuffer returns nothing. Asking LANE-SCREEN
to enable that flag would cost them a buffer copy every frame for a feature
used occasionally.

Since that means touching another lane's element, it snapshots the style
attribute on open and restores it verbatim on close — including the case where
SCREEN later adds inline styles of their own, which is tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:18 +10:00
type-two
e24100c26e [ui] wildlife, the no-seam jam, and the opening's one mercy hint
Voice: dust piles read as nurseries rather than mess ("SOMETHING WILL HATCH IN
IT"), swarms announce and disperse differently, and SIM's new 'no seam' jam
finally gets the line it deserves — "THERE IS NOTHING DOWN THERE. MOVE."

The inspector grows a MOSQUITO EXPOSURE row, read from snap.wildlife by target,
so a machine that is mysteriously slow says why where the player is already
looking. It shows the swarm's severity, not a throughput promise — SIM owns the
real penalty and doesn't expose a number.

Toasts gain pushMessage for lines that aren't raw sim events. First among them:
once per session, on the very first brownout, "NOTHING IS GENERATING. THE
COMMITTEE SUGGESTS: POWER." The alarm alone taught nothing; this is the one
hint the opening needs, and it is emphatically not a nag — hatch warnings are
deduped per pile for the same reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:11:59 +10:00
type-two
acb1d88038 [ui] publish inspect mode so the renderer can highlight the open unit
Opening the inspector now publishes {mode:'inspect', entity} through
setSelection, and closing it — by X, by Esc, or because the unit was demolished
under the panel — clears it. Picking a machine or arming remove supersedes the
inspect selection and shuts the panel, so the panel and the highlight can never
disagree about what is being looked at.

The three modes are now genuinely exclusive in selection.ts rather than two
booleans and an implied third state. Inspect deliberately does not count as
"armed": you can click straight from one unit to another without putting
anything down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:11:59 +10:00
type-two
7e24eab171 [ui] fix: the tab scrollbar was swallowing clicks on half of every tab
Reproduced the "clicking POWER does nothing" report with a real mouse:
elementFromPoint at the button's own centre returned the strip container, not
the button. The horizontal scrollbar was painted over the lower half of every
tab and ate clicks there — which is exactly why keyboard paging still worked
and clicking didn't.

Fixed by giving the scrollbar its own lane (padding-bottom >= its height). The
full button height now hit-tests to the button and a real click activates the
tab.

The strip itself is the approved round-5 design: one row that scrolls rather
than wrapping. The catalog keeps growing — RESEARCH joined in v4 and threw
REMOVE onto a line of its own — and a wrapping row moves every other tab under
the player's cursor. Paging with [ ] scrolls the active tab into view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:11:39 +10:00
type-two
c441ec3a53 [render] round 5 NOTES
The colorspace finding written up with measurements; the relic's two-zoom
contrast numbers; the 63.7s frame-delta bug.

Includes the B8 picking investigation: 27.6% of the viewport cannot be clicked,
and #screen — a purely decorative overlay in index.html — accounts for 6.9% of
it because it takes pointer events while #ui correctly does not. That is the
review's "unclickable machines". One-line fix, but index.html is not my file.

Two contract requests (a renderer inspect channel, the #screen fix) and a
renewed proposal for a worktree per lane: other lanes saving files triggered a
Vite reload every 5-15s and wiped probes mid-measurement, so this round had to
be verified against an isolated snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:09:20 +10:00
type-two
a89525ecbf [render] THE FLOOR, the relic, the juice, the pipe — and the colorspace bug behind it
The review verdict was "the game about place has no place". The root cause was
not a missing feature: a raw ShaderMaterial gets none of the renderer's output
includes, so THREE.Color's sRGB->linear conversion was never undone and the
crust rendered ~6x too dark. Measured: authored #0c0a15 (RGB 12,10,21) came out
RGB(1,1,2). One #include <colorspace_fragment> and it measures (10,8,18). Every
raw shader in this lane has been that dark since round 1.

Round 5 orders 1-8:
- ground.ts: fbm terrain mottle + neutral rim + the colorspace fix.
- seams.ts: 8 seam regions from data/seams.json. Era skins the crust with a
  wide falloff; the ore glints as sparse twinkling flecks (first pass drew
  continuous veins and they read as green worms competing with the belts).
  Fleck fade is short on purpose: SIM extracts on any footprint/rect overlap,
  so a glint dying inside the boundary would under-report where mining works.
- relics.ts: unfound = a breathing static patch, found = fossil on a plinth.
  Measured +39 contrast vs floor at wandering zoom, -18 at overview zoom —
  findable by looking, invisible from above.
- wildlife.ts: dust-pile glitter heaps + gnat swarms, harassed machines stutter.
  Two draw calls for the whole ecosystem.
- juice.ts: placement scale-pop + puff, demolition burst, shipment flash,
  all derived from snapshot deltas since render() never sees events.
- breakroom.ts: the GLYTCH cabinet, own raycast, opens match.html.
- ghost.ts: inspect highlight + cooler aura (closes round 4's placed-and-
  selected gap).

Also fixes juice never appearing: clock.getDelta() returns the real gap and a
backgrounded tab hands back SECONDS (measured 63.7s), so particles were born
and killed inside one update and the integrator flung them to infinity.
render() now clamps dt to 0.05.

Default framing lifts world origin clear of the build bar. 500 entities +
2,000 items + 50 piles + 198 gnats + the floor: 4.4ms/frame, 8 draw calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:09:20 +10:00
LANE-SIM
e9661dd772 [sim] round 5 NOTES
The relic works end to end (seed-derived, honours DATA's relicReserve,
digs up, persists). Wildlife's design point holds: sink your dust and
nothing hatches. Reference v5 researches a tech at tick 1235 with melt
and the zero-brownout regression untouched.

For the orchestrator:
- ONE contract change needed: GameData.seams + main.ts importing
  data/seams.json. DATA shipped the file mid-round with a good schema
  (and reserved a relic corner for me), but nothing reaches sim.init, so
  seams are inert in the running game. I mirrored their shape as
  SeamDef/SeamMap, exported, ready to lift
- still no trap machine in data, so swarms can't be cleared in the real
  game. Suggested machine + recipe in NOTES; the sim spots traps by recipe
  shape so it needs no code
- swarms slow crafting but NOT powerGen -- my call, flag it if wrong
- save format v3 -> v4 (wildlife, relics, lab timers)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:07:22 +10:00
LANE-SIM
71c885e009 [sim] the relic, wildlife, seams, research time, grantResearch, reference v5
Round 5 (MEGA) per lanes/LANE-SIM.md.

- THE RELIC: one optical fossil per world, position a pure function of the
  seed, 18-28 tiles out, never on a seam, never under the demo. excavate
  within a tile uncovers it; it stays found through save/load forever.
  Folds into DATA's relicReserve when the seam map supplies one
- wildlife v1: piles grow from dust a machine is BACKED UP on, not dust
  merely present -- belt your dust away and nothing ever hatches. First
  cut grew from raw held and the tidy factory still bred 56 swarms; the
  asymmetry is the whole mechanic. Swarms drift to production machinery
  and slow it; traps are recognised by recipe shape (no inputs, cans a
  swarm) so DATA's real trap needs no code. SWARM_MAX caps a plague at 8
  -- a permanently jammed machine bred 29 in one demo run
- seams: extractors idle with 'no seam' off-ore. Consumes DATA's real
  seams.json shape, defensively -- GameData.seams still needs wiring
- research takes time: RESEARCH_TICKS_PER_PACK, stepped whole packs; two
  labs measurably halve the wait (200 ticks vs 260)
- grantResearch: idempotent, save/load safe, verified through the live bus
- reference v5: a second works on the eastern seam that researches a tech
  at tick 1235. Separate from the melt line by choice -- the western
  floorplan has no columns left and threading three ingredients through it
  would make the melt regression hostage to the science

Melt still first at 1247, still shipping at 20k, zero brownout ticks,
179/179 placements legal, zero extractors off-seam once seams are wired.
99 sim tests, 446 across the repo. 63x realtime at 3,500 entities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:07:08 +10:00
type-two
70130c9e71 [screen] round 5 notes: both organs, and what I could not verify
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
fd2aee6c50 [audio] world ambience, discovery gating, and the radio control surface
Pre-discovery silence is structural, not a volume setting: the radio's master
gain is zero and ambience is never started until the fossil is found, and layer
1 is all one-shots, so a quiet factory is actually quiet rather than humming
under a noise floor. Measured at exactly 0.0 peak with the dial swept across
all three bands.

Dead air's tone drops 0.32 to 0.10 -- it rendered louder than the music station,
which made "dead air" the loudest thing on the dial, exactly backwards.

getRadio() is the sanctioned narrow surface for LANE-UI's tuner dock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
02fc7d5094 [audio] THE BAND: a tuner, four stations, and the sky as subtitles
Gaussian lock so stations swim up out of the noise instead of snapping, and
band-specific static because that is what makes a band feel like a place: AM
brown crackle, FM white hiss, SW a wandering bandpass plus a heterodyne whose
pitch IS the beat frequency, so it swoops down to nothing as you zero in.

THE STANDARD is deliberately just short of intelligible -- you can hear it is
speech and not what it says. THE SCREEN tells you what it says. That gap is the
trick, and subtitleBus is the wire that carries it.

NUMBERS speaks LANE-DATA's planted coordinates; generated filler is rejected if
it would decode onto real ground, so the only true coordinates on the band are
the deliberate ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
7f6ebe5f5a [audio] THE SPEAKER layer 1: the diegetic minimum
Placement thunk, fax dot-matrix chatter, brownout pop-crackle-and-power-down,
scram klaxon, ratification sting. All synthesized, no asset files, ever.

Every sound takes (ctx, dest, t0) and nothing assumes a live context, so the
whole speaker renders in an OfflineAudioContext for deterministic spectral
verification -- the same code the player hears, no test-only path. Noise
buffers use a seeded PRNG so spectra reproduce exactly.

The limiter is a tanh WaveShaper, not a DynamicsCompressor: makeup gain would
pump the whole mix every time the klaxon fires.

The ratification sting is the only consonant, in-tune sound in the game. Its
attack is slow where everything else is percussive, because the enemy is the
one thing here that resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00
type-two
64cb3edec6 [screen] wildlife shimmer, boot title, and a longer awakening
Mosquito swarms degrade a REGION via the growthMask template, seeded from the
swarm's world position -- the mask's second customer, as intended.

The game finally says its own name: a chromatic-ghosted WIMVEE FKTRY burn-in
holding 1.5s then dissolving to NOTHING IS WRONG. The awakening grows from a
single frame to a 0.47s full-canvas moment, so the first shipment lands as an
event rather than a blink.

Title and awakening are timed in seconds; the scram wipe stays frame-counted.
Three clocks for three fictions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:04 +10:00
type-two
b5c4923a87 [screen] jammed-rot: stuck work spoils faster than resting work
A clean idle decays at the round-4 rate untouched. A factory jammed solid
shortens its own grace and steepens its decay, because the work is not resting,
it is spoiling. Rot is the ratio of jammed machines, so one stuck belt in a big
base barely registers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:03 +10:00
type-two
d8111feb12 [screen] the substrate ages: era-skinned clean programming
The base feed now wears the highest ratified era -- sepia film with sprocket
holes, phosphor broadcast with interlace, rainbow-on-black disc gloss, the
classic SMPTE stream, blinding fortress white. Corruption stays era-agnostic:
same shader, same params, only what they are painted onto changes.

Boot era is reel, so the world starts in the deepest stratum and climbs to the
surface. The old SMPTE look is now mid-game.

The chyron generalises from an idle-only placard to a line of text, which is
what lets the radio caption the sky in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:03 +10:00
type-two
f2da81a9cf [data] the ladder, the ground, and the broadcast schedule
Round 5 (MEGA), all eight orders.

THE COMMISSION LADDER — the fax is the tutorial. The queue used to open on
one MELT, i.e. "build eight machines correctly, in order, with no feedback
until the last one works". Now four rungs teach one mechanic each and never
say so: 20 raw ore (extractor->belt->uplink) -> 15 luma (the demuxer, and the
slurry is your problem) -> 18 hf-dust (waste is inventory) -> 1 melt
(graduation; first-taste keeps its id, reward 50->80). Measured against SIM's
real sim: rung 1 clears at tick 650 = 21.7s with 4 machine types, against a
3-minute budget.

data/seams.json — seam regions per stratum, richness, and a seam-free
relicReserve in the SE so SIM's seed-derived optical fossil always has
somewhere legal to land. stream-crust-west covers all three reference-factory
extractors, pinned by a test.

data/stations.json — the whole dial: FKTRY FM, THE STANDARD (30 lines of
Correction propaganda, 10 minutes before a repeat), NUMBERS, DEAD AIR. The
numbers station is a real treasure map: each 5-figure group is
[x+32][y+32][richness*9] and three of them are the hidden seams. The validator
recomputes them from seams.json, so the map cannot drift, and no decoy is
allowed to decode onto real treasure.

Also:
- mosquito trap: input-free recipe, the shape LANE-SIM detects traps by (my
  first pass took ingredients, which would have silently made it an ordinary
  crafter). Unblocks vaporwave-sunset after four rounds; FORWARD_CANON pruned.
- cooler final 0.0018 -> 0.001: the additive ruling made two coolers immortal.
  Now 1 doubles output, 2 nearly eliminates downtime, 3 is an absurd shrine.
- SIM's tuning constants imported; the hand-copies are retired.
- the optical fossil canon: §1's fossils "sing when excavated", so the world is
  silent until you dig up the severed audio bus. One margin hint, mid-ladder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:47:08 +10:00
type-two
8dd4616580 [orchestrator] MEGA round 5: the paint and the pipe
Supersedes unexecuted round-5 orders. Folds in the live first-eyes review findings
(silence, void floor, onboarding cliff, quiet payoffs) + John's hidden-radio idea.

Contracts v6: AudioFX (THE SPEAKER - all Web Audio synthesis, no asset files),
RelicState + excavate + relicFound (the optical-fossil hidden pipe: find it and the
game grows a tuner - FKTRY FM, Correction propaganda subtitled by THE SCREEN's
chyron, a shortwave numbers station reading hidden-seam coordinates, dead air).
main.ts wires audio + first-gesture unlock; src/audio stub awaits LANE-SCREEN.

Lane orders: SIM = wildlife + seams-become-real + the relic + research time;
RENDER = the floor (seam glints, strata whispers), relic shimmer (no signposting),
juice, break-room cabinet -> match.html; UI = mercy toast, tuner dock, SCREEN
click-to-enlarge, camera hints; SCREEN = era feed + THE SPEAKER (3 audio layers,
4 stations, spectral verification); DATA = the commission-ladder tutorial,
stations.json broadcast schedule, seams.json final.

Ship gate: after review, redeploy partly.party/glytch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:56:09 +10:00
type-two
e3d1a724e9 deploy.sh: ship FKTRY + match.html prototype to partly.party/glytch
Sibling-pattern deploy (rsync -> /tmp stage -> docker cp into forum-nginx ->
cache-busted curl of index, match.html, and a JS asset endpoint).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:39:00 +10:00
type-two
557c78b208 [orchestrator] round 4 review + contracts v5 + round 5 orders
Review: 5/5 pass, 318/318, tsc clean. Verified live: STANDARDS COMMITTEE renders all
eras with ratified nodes, fuel gauge + ON RESERVE state, composite seam in the tree.
Fixed the demo brownout (mine): __fktryDemo now grants referenceFactoryTech() via
testkit before dispatching (swaps to the grantResearch command when SIM lands it).

Contracts v5: grantResearch command (sandbox is a feature), SelectionState inspect
mode (RENDER's placed-and-selected auras), wildlife (WildlifeState + event),
coolRadius clarified ADDITIVE capped at HEAT_COOL_MAX. main.ts: typed selection
store (inspect-capable), sentinel string no longer sent, narrowing fixed in UI's
faithful-copy harness (my breakage, my fix).

Rulings: research-time approved; pack abandonment kept as design; DATA's ungating +
starter-plant arithmetic ratified; SCREEN era-feed + jammed-rot approved; UI tab
strip approved. MASTERPLAN: bare-stash ban, stale-numbers process note.

Round 5 = M2 closes: wildlife (dust piles, mosquito swarms, traps), research takes
time, era-aging sky, seam map groundwork, inspect-mode handshake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:31:57 +10:00
LANE-SIM
3e45ee2652 [sim] round 4 NOTES
Research provable end-to-end on DATA's real tree (realtech.test.ts), not
just a fixture. Cooler aura ~triples a hot generator's run. Constants
exported. Reference factory ships slabs.

For the orchestrator:
- the demo needs one line from you: the reference build uses two gated ids
  so it is a post-research save now. Dispatched cold it comes up 100 draw
  vs 80 gen and browns out -- I watched it. referenceFactoryTech(data)
  names the techs; testkit.grantResearch() opens them. Proposing a dev-only
  {kind:'grantResearch'} command instead of importing test helpers
- DATA fixed the tech bootstrap mid-round and was right to: it was circular
  and 0 of 24 techs were affordable at start
- asic-cooler still has no coolRadius, so its aura is inert in the game
- cooling cap 0.05/tick is my call; reasoning in NOTES

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:20:47 +10:00
LANE-SIM
039eff15fa [sim] research + gating, spatial cooling, exported constants, reference v4
Round 4 per lanes/LANE-SIM.md.

- research v1: kind:'lab', setResearch, snapshot.research, 'researched'.
  Gating: any id a tech unlocks is refused until that tech lands; ids no
  tech names are free forever. Labs take only what the active tech still
  needs; several labs feed one total; research survives save/load as a
  RULE, so a restored save can still build what it unlocked
- realtech.test.ts: a cold-start research run on DATA's own tree -- ore ->
  packs -> archaeology lab -> tech -> a refused recipe now accepted. Also
  the tripwire if the tech bootstrap ever closes over again; it was
  circular and unenterable at the start of this round
- spatial cooling: coolRadius, chebyshev from the footprint, auras sum,
  total capped at HEAT_COOL_MAX (0.05 -- past that a stack buys nothing
  but still costs bandwidth and floor). Proven by measuring shed rate
- src/sim/constants.ts: 12 constants so DATA imports truth instead of
  hand-copying numbers that go stale in silence
- reference v4: anchor-slab overflow tap, so the demo ships slabs (18 per
  20k). Needed TWO melt lanes: with one, melt spills onto the slab lane
  and the splitter phase-locks with the reactor's push order -- slab to
  the assembler, melt to the uplink, forever, and no slab ever sold. It
  passed 20k ticks looking perfect. referenceFactoryTech() derives the
  techs the layout needs, from the commands
- also wired: bandwidth.capacity, EntityState.scrammed, commissionQueue

65 sim tests, 318 across the repo. Melt still tick 1248, zero brownouts
over 20k.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:20:32 +10:00
type-two
a48d45b4df [ui] accent: read MachineDef.accent first
Order 5, and the half that actually matters — the precedence comment landed in
the research commit but the lookup itself didn't get staged.

Precedence now matches LANE-RENDER exactly: art-directed accent wins, else the
first-output derivation, else the codex id/kind tables for machines that output
nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:14:33 +10:00
type-two
edca93f298 [ui] round 4 notes
All five orders shipped. The honest parts: SIM's research gating breaks SIM's
own reference factory at HEAD (GEN 80 vs DRAW 100, permanent brownout), nothing
in the game can make a science pack so research can't be performed by playing,
and the order's numbers went stale mid-round for the third round running.
No contract requests; one proposal (grantResearch, so tests stop coupling to
the save schema).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00
type-two
7501353311 [ui] live: a full research run, and a faithful main.ts in the harness
The harness bus is now a real v4 host: it owns the typed selection and it is
the thing that dispatches place/remove on a world click. That is what caught
the round-3 double-fire.

New: the whole loop the DoD asked for, against the real sim — locked machine
visible, its `place` silently dropped, build a lab, pick the tech through the
panel, packs delivered, `researched` fires, toast lands, padlock falls off, and
the same `place` now succeeds.

Packs are teleported into the lab's intake via the sim's own save()/load()
because nothing in the game can make a science pack yet (see NOTES);
everything after that is the sim's real drainLabs -> researched -> applyUnlocks
path, unmocked.

Reference-factory tests now run with research pre-granted, because SIM's gating
drops the software decoders reference.ts depends on. Stopped hardcoding the
i-only assembler's coordinates too — reference.ts moved it mid-round and broke
my own test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00
type-two
2904ddbb8c [ui] research: the STANDARDS COMMITTEE, and padlocks that fall off live
T opens the tech tree — every tech grouped by era in era order, cost chips in
item colours, era prefix stripped from node names (the heading already says
it), UNLOCKS: tooltips in machine names not ids. Pick a node to dispatch
setResearch; picking the active one stands it down; ratified nodes are inert.

The v4 gating rule lives in a pure research.ts: an id any tech claims is locked
until that tech completes, and an id no tech mentions is always available — so
belts never padlock. Locked machines are greyed with a padlock and a
REQUIRES: <TECH> tooltip, and can't be picked by click or by hotkey. The
padlock falls off in the same frame `researched` lands, and the player never
keeps holding something they can no longer build.

With no ResearchState at all the safe reading is that gates hold rather than
fall open, and the panel says RESEARCH OFFLINE instead of rendering a tree that
can't do anything.

Accent precedence now matches RENDER exactly: MachineDef.accent, then the
first-output derivation, then the codex kind table. RESEARCH joins the page
order for the new 'lab' kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:50 +10:00
type-two
5a36e32246 [ui] migrate to the v4 typed selection protocol
Writes go through bus.setSelection(); the __remove magic string and its
REMOVE_DEF export are deleted — the renderer gets a real mode from main.ts now.

Deleted my own `remove` dispatch: main.ts dispatches it on click as of v4, and
the UI doing it too was a double-fire that only looked harmless because the
second command no-oped. Verified live: one click, one removal.

selection/setSelection are optional in the contract, so a legacy host still
gets the old {def,dir} shape (sentinel included) rather than silently getting
no selection at all. Tested both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:23 +10:00
type-two
3cec62368a [ui] fix: BROWNOUT was lying about a covered deficit
The banner keyed on `out || ratio > 1`, so any time draw exceeded generation
the panel screamed BROWNOUT — even while the buffer tanks were quietly
covering it, the sim's flag was false and THE SCREEN was calm. That state is
the factory working as designed, not failing. Only the HUD was panicking.

BROWNOUT is now the sim's flag and nothing else. A covered deficit gets its
own amber, steady, non-flashing state: "ON RESERVE · 34s", where the seconds
are stored/(draw-gen) — real arithmetic, because v3 ruled stored is
bandwidth-SECONDS and gen/draw are per-second. Same maths SCREEN uses.

Four states (ok / tight / reserve / brownout) now live in a pure power.ts and
are pinned by tests, including the exact case that lied: gen 100, draw 134,
stored 1000, brownout false -> reserve, not brownout.

Also uses v4 bandwidth.capacity to turn the buffer readout into a fuel gauge
(0/900) rather than a bare number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:13:23 +10:00
type-two
c97dd80a9b [render] round 4 NOTES
Verification numbers; the lock-rule divergence with UI (they were right, I was
wrong — the scrammed lesson misapplied); the showroom overflow postmortem; one
contract request (an inspected-entity channel for placed-and-selected auras).

Notes that round 3's MODELBEAST manifest is stale — ?showroom is the live one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:12:04 +10:00
type-two
5e8e49ef0e [render] v4: ghost mode, locked-build ghost, cooler auras, lab visuals
Round 4 orders 1-5:

- ghost keys demolition off setGhost's `mode` arg; the '__remove' sentinel is
  ignored and REMOVE_DEF is gone from this lane.
- locked-build ghost: padlock-amber (not occupied-red — "not yet" and "not
  here" are different sentences) + a padlock tinted by the gating era. Needed
  a new ERA_TELL palette: ERA_SKIN.tint is a multiplier tuned to leave DATA's
  chassis alone, so it can't carry a signal (stream is #ffffff).
- aura.ts: cooling coverage as a dashed RECTANGLE. coolRadius is chebyshev, so
  the cooled region is the footprint grown by r per side; a circle would look
  better and lie about the corners.
- lab placeholder: bench + the "bottled artifact triptych" (§2); consumes
  MachineDef.accent when present, derivation stays the fallback.

Also:
- buffer fill reads bandwidth.capacity (v4) instead of re-summing bufferCap —
  a second copy of the sim's arithmetic is what the scram latch warned about.
- lock rule matched to LANE-UI's reading: absent research means nothing has
  completed, so gated machines padlock. Mine said the opposite; theirs is the
  literal contract text, and the build bar and ghost disagreeing on the same
  screen is worse than either answer.
- showroom fitted to BOTH axes. Round 3's depth-only fit collapsed the item
  grid to one 206-tile row once the machine hall grew and ate the depth.

Verified against DATA's real v4 data and UI's migrated flow: 6x6 aura on
asic-cooler, reel/broadcast era tells, lab accent #ffc24a from data, 29
machines + 60 items in the showroom, 500 entities + 2,000 items at 3.6ms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:12:04 +10:00
type-two
dbb3615a64 [data] the composite seam + a tech tree that can actually be bought
Round 4 orders, all five.

The headline is a bug I authored: the v4 gating rule (any id in a
TechDef.unlocks is locked until that tech completes) turned three rounds of
tech authoring into a total bootstrap deadlock. Measured: melt not producible
at tick 0 (first-taste impossible), 0 of 24 techs reachable — every pack chain
gated behind packs you cannot make. disc-artifact-bottling cost 10 analog packs
and unlocked the only machine that bottles analog packs.

Fixed as a shape, not a patch, and both rules are now tests:
- the M1 melt chain is starting equipment (so first-taste is possible, and
  RENDER's era derivation correctly reads it as starting equipment)
- one pack is craftable at tick 0 — SPATIAL, made from the quantizer's own
  sins, so your first research is a study of your own damage
23/23 nodes now reachable; six emptied disc nodes deleted (no lane references
tech ids).

Also:
- §10 THE COMPOSITE SEAM transcribed: alpha/hard-matte/blue-hole/fringe-lace/
  green-spill/mixed-pixel/roto-chatter/ghost-feather/sodium-light (forward
  canon), matte bath, optical printer, chroma keyer, roto bench, neural keyer
  (powerDraw 240 — the codex's "appetite that dwarfs everything").
- ARCHAEOLOGY LAB canonized (kind: lab), ungated — a lab behind research is a
  lab you can never build.
- ASIC cooler live: the round-3 spec of 0.02 would have meant "never heats,
  free 100/s". Retuned to 0.0018, sane under both readings of the contract.
- accents where output-derivation can't work; CODEX_REF regex widened for §10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:04:00 +10:00
type-two
180747ce28 [screen] round 4 notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
6404c44fcc [screen] ratification card + pallor pass
The Correction takes the sky for one second every time the player does science:
hospital white, green tick, the standard's real name from codex 1. Composited
dead last in screen space so not one pixel of it is corrupted -- that is the
joke. Hard cut on exit; a dissolve would make it look negotiable.

Timed in seconds, not frames: a broadcast interruption must last a second at any
refresh rate (verified 30 frames at 30fps, 60 at 60fps). The scram wipe stays
frame-counted -- tape artifacts are quantized to frames.

Pallor drains saturation and closes the corners in, per codex 8: colour is the
resource, so a factory with no reserve literally has less of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
bc992a99d4 [screen] second dread axis: pallor, and v5 fixture hygiene
tremor asks "how many seconds until dark" -- acute, the cliff edge, expressed as
judder. pallor asks "how much cushion do I have at all" -- chronic, sits there in
surplus, expressed as colour draining out. A full tank draining fast is a jolt;
a near-empty tank coasting is a way of life.

No tanks built means no cushion to miss, so no pallor. Without that, an unbuilt
reserve reads as "totally empty" and paints the boot state grey, which would
break the sterile guarantee on day one.

Fixtures now carry commissionQueue and research ahead of the v5 hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
5ad7252e20 [screen] boredom: the sky forgets, the ledger does not
No shipments for a sustained stretch and the corruption eases back toward clean.
The LEDGER is untouched -- history is not erased, only the display decays -- so
resuming production puts the targets straight back and the picture walks home in
about a second.

60s grace then 180s decay: a jam you notice, diagnose and fix costs nothing.
The player has to genuinely walk away before the sky starts cleaning itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
83e3ad2107 [orchestrator] round 3 review + contracts v4 + composite seam codex + round 4 orders
Review: 5/5 lanes pass, 238/238, tsc clean. Verified live: scram duty-cycling with
tanks covering (zero brownout), shipment stamps, strain fever 0.824. Found 1 real bug
in review: UI BROWNOUT banner fires on covered deficit (topstrip.ts:66) - ordered.

Contracts v4: research (lab kind, setResearch, ResearchState, researched event),
SelectionState protocol (kills __remove sentinel, main.ts owns remove dispatch),
accent?, coolRadius? (spatial cooling), bandwidth.capacity?, save/load hardened,
heat ruled GROSS (net = heat - cool). tsconfig now typechecks data/ (DATA's proof).
Orchestrator patched lab entries into exhaustive kind maps (my breakage, my fix).

Rulings: pathspec-commit rule after 3 shared-index collisions (worktrees = named
escalation); bloom escalator reading accepted; UI ?uidemo + SCREEN boredom approved.

Codex: docs/FKTRY_LORE.md grew §10 THE COMPOSITE SEAM - keying/transparency artifact
family from the Corridor Crew compositing history + our corridorkey-mrp-mlx (which
doubles as a PYXLFK ground-truth artifact factory). DATA transcribes it round 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:43:48 +10:00
type-two
7eda076fda [render] round 3 NOTES: provenance + shared-index proposal
Records that my ten round-3 src/render files reached main inside d1c3f2b
"[sim] round 3 NOTES": the five lanes share one working tree AND one git index,
so SIM committing with -a swept up my staged files. Not a SIM ownership
violation — that commit's render diff is mine. Already pushed and four sessions
are live on this history, so it stays as-is.

Proposal: a git worktree per lane (one index each, one shared repo) would end
this class of accident. Staging discipline can't — the loser is whoever happens
to have files staged, not whoever was careless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:42:15 +10:00
type-two
82d9ab2b01 [render] round 3 NOTES + MODELBEAST work order
Verification numbers, the prod-leak postmortem, and the showroom's contents as
a 23-row asset manifest (asset key / era / footprint) — every row a live
hot-swap target. One contract request (setGhostMode), filed jointly with UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:36:06 +10:00
LANE-SIM
d1c3f2b09d [sim] round 3 NOTES
Confirms the units conversion (bufferCap 240 = 8.0s of cover at a 30/s
deficit) and reports scram survival: first scram t434, restart t934,
~718-tick duty cycle, ZERO brownout ticks across 20k, melt rate unchanged.

For the orchestrator:
- heat semantics need a one-line ruling. heatPerTick is GROSS in my model
  (net = heatPerTick - coolPerTick). DATA's committed 0.0025 against the
  0.004 default nets negative -- the decoder could never scram. Their
  in-flight 0.0033/0.001 works, so we agree by luck of timing.
- the codex's literal bloom loop is not expressible with per-grade recipes
- asic-cooler does nothing without a spatial heat mechanic
- my sim code landed inside 45003db [ui] REMOVE mode: the shared tree has
  one git index and an add-everything commit in another session swept it.
  Not a UI ownership violation. Suggest a no-`git add -A` rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:35:47 +10:00
143 changed files with 21764 additions and 750 deletions

48
deploy.sh Normal file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Deploy WIMVEE GLYTCH to https://partly.party/glytch/
# /glytch/ -> FKTRY (the factory game, vite build)
# /glytch/match.html -> the original match-the-glitch prototype (single file)
# Pattern per deploy-map: bundle -> rsync to /tmp staging -> docker cp into
# forum-nginx -> curl verify (cache-busted). Cloudflare fronts partly.party:
# purge cache after major changes.
set -euo pipefail
cd "$(dirname "$0")"
VPS=humanjing@100.71.119.27
CONTAINER=forum-nginx
WEBROOT=/usr/share/nginx/html
echo "— optimize assets (idempotent; skips anything already meshopt-compressed) —"
node fktry/tools/optimize-assets.mjs
echo "— build —"
(cd fktry && npx vite build --base=./)
cp index.html fktry/dist/match.html
echo "— stage —"
rsync -az --delete fktry/dist/ "$VPS:/tmp/glytch-stage/"
echo "— swap into container —"
ssh "$VPS" "docker exec $CONTAINER rm -rf $WEBROOT/glytch \
&& docker cp /tmp/glytch-stage $CONTAINER:$WEBROOT/glytch \
&& rm -rf /tmp/glytch-stage"
echo "— verify (cache-busted) —"
curl -fsS -o /dev/null -w '%{http_code} https://partly.party/glytch/\n' "https://partly.party/glytch/?v=$(date +%s)"
curl -fsS -o /dev/null -w '%{http_code} https://partly.party/glytch/match.html\n' "https://partly.party/glytch/match.html?v=$(date +%s)"
# Data-endpoint checks, not just the index (classic nginx-routing failure mode).
# Round-7 fix: this used to test ONE js file, so a deploy that shipped zero models
# still reported success. Now the JS bundle, a real GLB, and the model COUNT.
ASSET=$(ls fktry/dist/assets | grep -m1 '\.js$')
curl -fsS -o /dev/null -w "%{http_code} assets/$ASSET\n" "https://partly.party/glytch/assets/$ASSET?v=$(date +%s)"
MODELS_LOCAL=$(ls fktry/dist/assets/models/*.glb 2>/dev/null | wc -l | tr -d ' ')
MODEL=$(basename "$(ls fktry/dist/assets/models/*.glb 2>/dev/null | head -1)")
if [ -z "$MODEL" ] || [ "$MODELS_LOCAL" -eq 0 ]; then
echo "FAIL: build produced no GLB models" >&2; exit 1
fi
curl -fsS -o /dev/null -w "%{http_code} assets/models/$MODEL\n" "https://partly.party/glytch/assets/models/$MODEL?v=$(date +%s)"
MODELS_LIVE=$(ssh "$VPS" "docker exec $CONTAINER sh -c 'ls $WEBROOT/glytch/assets/models/*.glb 2>/dev/null | wc -l'" | tr -d ' ')
echo "models: $MODELS_LIVE live / $MODELS_LOCAL built"
[ "$MODELS_LIVE" = "$MODELS_LOCAL" ] || { echo "FAIL: model count mismatch" >&2; exit 1; }
echo "done. If this was a major change: purge Cloudflare cache for partly.party."

View File

@ -411,3 +411,115 @@ your win condition from "make it glitch" to "make it silent," or steal the KDM w
(also you).
- Every asset generated gets its element's codex name — this file is the manifest;
check off against it.
---
## 10. THE COMPOSITE SEAM — keying & transparency artifacts
> Source study: Corridor Crew's history of compositing (Niko Pueringer) + our local
> `corridorkey-mrp-mlx` (MLX port of CorridorKey, runs in the MODELBEAST flow on the
> m3ultra). Thesis, and the section's whole physics: **you cannot film transparency.**
> Alpha does not exist in nature — every era of cinema is a different failed séance
> for summoning it. In the Bitstream this is literal: ALPHA is the one resource that
> cannot be mined from any seam. It can only be *computed*, and every computation
> before the last one lies.
**Geology:** the Composite Seam is not a stratum — it is a vertical FAULT crossing
every era layer, glittering with half-transparent fossils. Mining near it yields
subjects fused to backgrounds that were never really there.
### Resources & products
**ALPHA** — *raw transparency, the unfilmable substance.* A shimmering nothing in a
frame; the rarest resource in the game. Cannot be extracted; only keyer machines emit
it, each era's keyer emitting a corrupted grade.
`ASSET (3D + shader): a glass ingot that renders as a checkerboard-transparency hole in the world itself, faint rainbow rim, pedestal casting a shadow the ingot doesn't`
**HARD MATTE** — *reel-era binary alpha.* Silver-halide matte slabs: every pixel all
or nothing. Razor edges, zero feather; anything soft (hair, smoke, glass) that touches
one gets guillotined to a hard silhouette. Cheap, brutal, in demand for "woodcut" looks.
`ASSET (3D): stack of black-and-white film mattes like x-ray plates, silhouettes with surgically hard edges, chemical stain rims, one plate mid-slice through a wisp of smoke`
**BLUE HOLE** — *optical-printer costume dropout.* A patch of SUBJECT gone transparent
because it dared to be blue. Sold as canned absence; installing one in a product makes
part of it see-through in a way that reads as a wound.
`ASSET (3D): a mannequin torso with a clean-edged void where the chest was, world visible through it, faint blue glow licking the hole's rim`
**FRINGE LACE** — *matte lines, bottled.* The bright halo ribbon where layers of film
were re-exposed slightly misaligned. Harvested off composite edges like dried glue.
`ASSET (3D): coils of luminous ribbon, each strand a hard bright outline the exact shape of some absent object's edge, wound on an industrial spool`
**GREEN SPILL** — *chroma contamination.* Cousin fluid to CHROMA SLURRY but nastier:
it doesn't tint what it touches, it makes it PARTIALLY DISAPPEAR (the keyer downstream
eats whatever it stains). Leaks from every chroma keyer; the OSHA posters are explicit.
`ASSET (3D): dripping acid-green fluid whose puddles render as半 half-vanished floor, hazmat berm around a keyer machine, splashes eating a crate mid-vanish`
**MIXED PIXEL** — *the purple gel: an unmixable blend.* Foreground and background
fused into one pixel that belongs to neither — the red gel that filmed purple. Classic
keyers cannot separate it; the neural keyer eats it for breakfast.
`ASSET (3D): a jar of pixels each half one color half another, split diagonally, labeled RED (DISPUTED)`
**ROTO CHATTER** — *boiling edges.* Frame-inconsistent cutout outlines, jittering like
they're being redrawn 24 times a second by a tired artist — because they are. A live
hazard that infests composite edges; trapped and canned, it sells as nervous energy.
`ASSET (VFX + 3D can): an outline that never sits still, vibrating polyline crawling around a silhouette; the can has a shaking label`
**SODIUM LIGHT (589nm)** — *the grail wavelength.* Disney's sodium-vapor trick:
mathematically perfect mattes, hair and glass and gossamer intact — locked behind an
exclusive prism only one company ever owned. In the Bitstream, The Correction hoards
the prisms (perfection is their department). A single beam is an endgame reagent.
`ASSET (3D): a sealed lantern emitting one impossibly pure amber-orange line of light, wavelength etched on the housing, white-glove handling cradle, Correction seal`
**GHOST FEATHER** — *semi-transparency done RIGHT.* Hair strands, motion blur, glass —
alpha with gradients. The proof-of-mastery product; every era below the neural keyer
ruins it, so demand never dies.
`ASSET (3D): a single enormous feather fading from fully solid quill to truly transparent tip, the transition agonizingly smooth, displayed under museum glass`
### Machines
**MATTE BATH** *(reel era)* — chemical tank that binarizes anything: footage in, hard
mattes out, all softness destroyed (a GHOST FEATHER fed in comes out a HARD MATTE and
the machine does not apologize). One mistake ruins the whole reel — jams eat inventory.
`ASSET (3D): open chemical bath with film strips on rollers dipping through, developer-red darkroom lamp, dissolving softness visibly leaving the film as rising fog`
**OPTICAL PRINTER** *(the iron lung)* — a cathedral of bellows and lenses that splits
footage into R/G/B channel reels and recombines them slightly wrong. Produces
composites + FRINGE LACE always + BLUE HOLES whenever fed anything blue. Huge
footprint, gorgeous, mechanical; the codex's most machine-ass machine.
`ASSET (3D, hero): room-sized brass-and-iron optical printer, three film paths in primary-color light, bellows lungs mid-breath, a fringe of glowing misalignment haloing its output gate`
**CHROMA KEYER** *(stream era)* — digital green screen box: cheap, fast, 30-year-old
math. Emits ALPHA (consumer grade) + GREEN SPILL, chokes on hair (GHOST FEATHER input
→ ROTO CHATTER hazard spawn), turns semi-transparent inputs into MIXED PIXELS.
`ASSET (3D): studio-green cube with a subject-shaped hole punched clean through, green fluid weeping from its seams, sample swatches of failed hair taped to its side`
**ROTO BENCH** — *the grind made flesh.* Manual-labor station that converts ANY
footage to cutouts — perfectly, eventually — at an atrocious rate (24 frames of pain
per second of footage). The economic floor under every keyer: when automation fails,
the bench never does, it just costs your life. Emits ROTO CHATTER while running.
`ASSET (3D): a hunched drafting desk with a glowing frame-by-frame lightbox, mountain of finished frames on one side, coffee rings fossilized into the wood, a small flickering outline hovering over the current frame`
**NEURAL KEYER** *(endgame)* — the CorridorKey machine: trained on ten thousand
synthetic ghosts because reality couldn't teach it (you can't film the answer). Pulls
perfect ALPHA through glass, chainmail, motion-blurred hair; UNMIXES pixels (MIXED
PIXEL → original colors, recovered); no spill, no fringe, no chatter. The catch is
pure hardware: it draws 24 VRAM — a bandwidth appetite that dwarfs everything else in
the game. You don't unlock the neural keyer; you unlock the power plant it demands.
`ASSET (3D, hero): a monolithic GPU shrine, fans like cathedral rose windows, cables thick as anacondas, projecting a flawless translucent figure above itself; power meter needle buried in the red`
### Hazards
**SPILL FOG** — green contamination cloud around leaky keyers; products crossing it
gain partial transparency (defect or feature, depending on the commission).
**EDGE BOIL** — roto chatter infestation: outlines in the area start crawling;
machines misread their own footprints and mis-deliver until it's trapped.
### PYXLFK / MODELBEAST production note
`corridorkey-mrp-mlx` (gitea.partly.party/monster/corridorkey-mrp-mlx, deployed in the
MODELBEAST flow on the m3ultra) is a REAL artifact factory for this whole section:
run the same footage through (a) classic chroma-subtraction math and (b) the neural
keyer — the difference image is a pixel-perfect, ground-truth-labeled map of spill,
fringe, and mixed pixels. Degrading the alpha hint synthesizes roto chatter and hard-
matte binarization with known parameters. Every artifact above is bakeable with a
recorded recipe, which is the PYXLFK contract: corruption in, sidecar out.

View File

@ -55,3 +55,41 @@ data/*.json ──▶ main.ts loads GameData
Written in your lane NOTES as `CONTRACT REQUEST: <what> — <why>`. The orchestrator
batches them between rounds. Until granted, work around locally inside your lane.
## v4 notes
- `snapshot.commissionQueue` may alias a live sim array — read-only, never retain
across frames (same rule as the snapshot itself, stated here because it looks like
a plain array).
- Selection protocol: UI writes `bus.setSelection(...)`; main.ts derives ghost mode
and dispatches `place`/`remove` on click. UI must NOT dispatch `remove` itself.
The `'__remove'` def string is a deprecated bridge, deleted in v5.
- Heat: `heatPerTick` is GROSS; net climb = `heatPerTick coolPerTick`. A machine
whose heatPerTick ≤ its coolPerTick can never overheat — the validator enforces it.
- Research gating: ids appearing in any `TechDef.unlocks` are locked until researched;
ids referenced by no tech are always available.
## v6 notes
- **THE SPEAKER**: `AudioFX` (src/audio/**, LANE-SCREEN) — all sound is Web Audio
synthesis, no asset files. main.ts wires init/unlock/onEvents/frame. Pre-discovery
only the diegetic minimum plays; the band unlocks on `relicFound`.
- **Sanctioned narrow imports** (read-only, documented at the export site):
`src/sim/constants.ts` (tuning truth) and `src/audio`'s `radio` control (UI's
tuner dock). Everything else stays contract-mediated.
- **Relics are secrets.** `snapshot.relics` is always present, but nothing may
signpost an unfound relic: RENDER draws the subtle shimmer, UI shows nothing until
`found`. Discoverability budget: wandering + curiosity. That's the design.
- New jam reason string: `'no seam'` (extractor off-seam).
## v9 notes
- Jam reason `'sealed'` joins `'no seam'` in the sim's vocabulary: a warden-sealed
machine stalls with it until the `pry` command clears the seal. UI translates it
as a document, not a fault.
- RULING (round 7, ratifying SIM's departure): firewalls catch only what WALKS.
Gunships are airborne and immune — UI measured rung 3 being deleted entirely
otherwise. Pinned by SIM's regression test (a gunship must reach tether and emit
`mirrored` in the shipped demo).
- Round-8 backlog, first item: a `unitCleared` event — the firewall's victory
currently fires nine times in the demo and is silent to SCREEN/audio/RENDER.

View File

@ -37,6 +37,27 @@ codex wins.
5. Commit with your lane prefix: `[sim] belt merge logic + tests`. One or a few
focused commits, not one mega-commit. Push.
**COMMIT HYGIENE (ruled after three round-3 collisions):** five sessions share ONE
working tree and ONE git index. `git add -A`, `git add .`, and `git commit -a` are
BANNED — they commit whatever other lanes have staged at that instant. Every commit
uses an explicit pathspec so the index state of other lanes cannot leak in:
`git commit -m "[sim] ..." -- fktry/src/sim fktry/lanes/LANE-SIM.md`
(a pathspec commit ignores the staging area for everything outside the paths).
Before pushing, `git show --stat HEAD` — if it lists files outside your lane,
`git reset --soft HEAD~1` and recommit correctly. If collisions still happen,
the next escalation is a git worktree per lane.
Also: **never bare `git stash`** — it stashes every lane's uncommitted work and
will convince you that you fixed (or caused) another lane's red. Only
`git stash push -- <your paths>`. (Round 4: SCREEN nearly misdiagnosed a red this
way; DATA's diff was the actual variable.)
**Numbers in orders go stale mid-round.** Five lanes land continuously; a count or
claim written into CURRENT ORDERS is true only as of writing (round 4: "24 nodes"
was 23 by landing; "research doesn't exist" was false within the hour). Re-probe
HEAD before acting on any order's factual claim, and before writing NOTES — a
round-start grep is a lie by round-end.
## Hard rules
- **Never edit**: `MASTERPLAN.md`, `CONTRACTS.md`, `src/contracts.ts`, `src/main.ts`,
@ -81,3 +102,11 @@ diff each lane against its owned paths (ownership violations get reverted), veri
claimed features actually work, reconcile cross-lane questions, update CONTRACTS if
(and only if) two lanes need it, write next CURRENT ORDERS in each lane doc, commit
`[orchestrator] round N orders`.
## ROUND 5 (MEGA) addendum
The original round-5 orders were superseded before execution by the MEGA round
(same goals + the paint-and-pipe layer from the live first-eyes review: sound via
the hidden optical-fossil radio, the commission-ladder tutorial, the floor, juice,
click-to-enlarge SCREEN, the break-room cabinet). After this round's review passes:
**redeploy to partly.party/glytch via ../deploy.sh** — that's the ship gate.

View File

@ -99,6 +99,401 @@ SIM builds auras. Gated behind `broadcast-asic-cooling` so it can't appear early
- Its own `coolPerTick: 0.02` is inert today (nothing heats it) and is there to state the
intended aura strength in data rather than in prose.
## Added in round 4
### §10 THE COMPOSITE SEAM — transcribed
The codex grew §10, so most of this round is transcription rather than invention. Every
item and machine in §10 is now data. Deviations and judgement calls only:
| Call | Why |
|---|---|
| "Footage" = `anchor-slab` | §10's keyers key *footage*, but the codex never says what footage *is* as an item. The anchor slab is already "a complete, self-sufficient picture" (§2) — the only thing in the game you could point a keyer at. Every keyer takes slabs. |
| MATTE BATH's chemical = `static-canister` | §3 already says static canisters are "brine for preserving other artifacts". The bath needed a developer; the brine was sitting right there. |
| `ghost-feather` is made **only** by the neural keyer | §10: "every era below the neural keyer ruins it, so demand never dies." So the earlier machines *consume* it and ruin it — `bathe-feather` (feather → hard matte, "the machine does not apologize") and `key-hair` (feather → roto chatter). Both are jokes with a real cost, and both are the codex verbatim. |
| NEURAL KEYER `powerDraw: 240` | §10 says "24 VRAM — a bandwidth appetite that dwarfs everything else in the game", and "you don't unlock the neural keyer; you unlock the power plant it demands". 240/s is 12 decode ASICs, or ~9 software decoders after duty cycling. The whole M1 reference chain draws 96. It dwarfs the game, as ordered. |
| `sodium-light` has no recipe | §10: The Correction hoards the prisms. Forward canon; on the FORWARD_CANON allowlist. `sodium-requisition` commissions it anyway, with 30 BLUE HOLES accepted "if you lose your nerve". |
| ROTO BENCH is gated (disc), not free | §10 calls it "the economic floor under every keyer... the bench never does [fail]". Tempting to leave ungated, but ALPHA is "the rarest resource in the game" and an ungated bench hands it to you at tick 0. Gated cheaply (10 spatial) so the floor arrives early but the Composite Seam stays a mid-game fault. |
Optical printer ratios hurt, as ordered: 4 anchor slabs + 2 colour plates → **1** alpha and
4 fringe lace, over 240 ticks. It is the worst alpha-per-slab in the game and it fringes
every single run, which is exactly what the iron lung is for.
### THE ARCHAEOLOGY LAB (`kind: 'lab'`) — canonized
The codex has no lab: §6 only says "TECH TREE = MEDIA ARCHAEOLOGY. You research *downward*
(excavating older strata for analog secrets) and *upward* (deriving newer codecs)." So the
lab is the dig site — named for the verb the codex already uses, not a generic "research
station". Ungated starting equipment, because a lab behind research is a lab you can never
build (there is now a test named exactly that).
## Added in round 5
### THE COMMISSION LADDER — the fax is the tutorial
There is no tutorial in FKTRY and there should never be one. The fax machine is already
the game's voice, already tells you what to build, and is already sacred (§6: "do not ask
why it works"). So the first four commissions teach one mechanic each and the player never
knows they were taught.
| Rung | Wants | Teaches | Machine types needed |
|---|---|---|---|
| 1 `raw-payload` | 20 MDAT-ORE, raw | extractor → belt → uplink. The whole verb set. | 2 crafting (+belt, +uplink) |
| 2 `first-refinement` | 15 LUMA BARS | the demuxer — **and byproducts**: the slurry has to go somewhere and the fax does not care | 3 |
| 3 `dust-quota` | 18 HF DUST | the quantizer, and that **waste is inventory** | 4 |
| 4 `first-taste` | 1 MELT | graduation: the whole M1 chain, ore to reactor | 8 |
Rung 1's flavour is the instruction manual: *"A belt, a drill, an uplink to THE SCREEN —
that is the whole job."* Rung 2 hands you a byproduct and refuses to help. Rung 3 reframes
the garbage as product, which is the game's actual thesis. Rung 4 is the old opening
commission, reward bumped 50 → 80, now earned rather than assumed.
**Measured, not estimated** (throwaway probe against LANE-SIM's real sim, then deleted):
```
rung 1, built exactly as the fax describes — drill, 2 belts, uplink, one ASIC:
4 machine types. Cleared at tick 650 = 21.7 seconds.
```
The order was "completable inside 3 minutes with 4 machine types". It is completable in
**22 seconds**, which matters more than the budget suggests: the first fax must clear
before a new player decides nothing is happening.
Why this was needed at all: the queue used to open on `first-taste` — one MELT — which
requires ore → demuxer → quantizer → subsampler → p-caster → DCT press → GOP assembler →
mosh reactor. The game's first instruction was "build eight machines correctly, in order,
with no feedback until the last one works." That is the onboarding cliff.
### THE OPTICAL FOSSIL — why the world is silent until you dig
§1 buries the hook in one clause: the Reel Beds hold *"optical soundtrack fossils — wavy
photographic strips that **sing** when excavated."* Optical sound is a picture of a
waveform printed on film — the one place in this world where image and audio are the same
substance. So:
**The Bitstream has no audio because the audio track was severed from the container long
ago, and one length of it is still in the ground.** Everything before the fossil is found
is a video file with no sound: UI clicks and nothing else. Excavating it splices the audio
bus back into the file, and the whole band comes alive at once — the tuner, the stations,
the factory's own hum. The player doesn't unlock a feature; they repair the file.
This is why THE STANDARD's script includes *"Do not excavate. There is nothing in the
strata but strata"* and *"Anything that sings when you dig it up was never meant to be
heard."* The Correction runs a propaganda station warning you off the one artifact that
would let you hear the propaganda station for what it is. It is also why DEAD AIR's ghost
line is the only direct acknowledgement in the game that you found it.
The hint is one fax margin, mid-ladder, on `haunted-doorway` — *"[someone has drawn a sine
wave in the corner of this fax. it is not our department.]"* It points at nothing. A sine
wave is a sound and a drawing at the same time, which is exactly what the fossil is.
### THE MOSQUITO TRAP — the last forward-canon commission comes off the blocklist
§5 gives the trap in one line: *"Catchable in edge-baited traps"*, the asset note calling it
*"a bug-zapper shaped like a high-contrast edge target"*. Mosquitoes are quantization noise
made alive: they hatch from HF DUST and cluster on sharp edges. So the recipe is exactly
that sentence — **8 HF DUST + 1 LUMA BAR → 1 MOSQUITO SWARM (canned)**. The dust is where
they come from, the fresh luma bar is the sharp edge that baits them, and the whole thing
gives HF dust a third sink.
This unblocks `vaporwave-sunset`, which has wanted "3 MOSQUITO SWARMS (live)" since round 1
and been unfillable the entire time. `mosquito-swarm` leaves FORWARD_CANON; only
`gibbs-wraith`, `cinema-pack` and `sodium-light` remain, and all three are deliberate
(a wraith must come willingly, cinema needs the Fortress, and The Correction hoards the
prisms).
### `data/seams.json` — the ground
Schema is `{ relicReserve, seams: [...] }`; each seam is `{ id, era, resource, rect{x,y,w,h},
richness, hidden? }` on the -32..31 grid. Five ordinary seams, one per stratum, placed so
each era's codex-correct payload has a home: stream crust → mdat ore, disc mantle → bayer
grit (§2: "from the deepest camera-original seams"), broadcast shelf → field splinters,
reel beds → silver frames.
Two constraints drove the layout, both now tests:
- **The reference factory must stand on ore.** SIM's three 2×2 extractors sit at (-30,0),
(-30,-3) and (-30,3); `stream-crust-west` spans x[-31,-28] y[-5,6] and covers all three
with a tile to spare. Off-seam they'd idle with `'no seam'` and the demo would produce
nothing at all.
- **`relicReserve` is a seam-free corner** — x[12,31] y[12,31]. SIM plants the optical
fossil from the seed, 1828 tiles out, *never on a seam*. If seams creep across the whole
ring there may be nowhere legal left to put it, so the SE quadrant is kept deliberately
empty. A test fails the moment a seam intrudes.
`richness` is authored but currently unread — SIM's rule is boolean overlap this round. It
is there for when extraction rate goes data-driven, and it is what the numbers station
broadcasts as the last digit.
### `data/stations.json` — the broadcast schedule
Four stations, matching SCREEN's spec: **88.1 FKTRY FM** (generative, seeded, era-flavoured),
**AM 640 THE STANDARD** (30 chyron lines), **SW 6955 [UNLISTED]** (numbers), **108.0 DEAD AIR**.
THE STANDARD is The Correction with a transmitter, and the voice rule is that it never
threatens — it reassures, in a way that gets worse the longer you think about it. *"Your
file is being read by someone who loves it very much."* *"Loss is not loss. Loss is
compression that has finished negotiating."* *"Good morning. It is always going to be
morning."* 30 unique lines at the ~20s a chyron needs to be readable = 10 minutes before
a repeat, which is the DoD.
**The numbers station is a real treasure map.** Each 5-figure group encodes one coordinate:
```
[x + 32 as 2 digits][y + 32 as 2 digits][richness × 9 as 1 digit]
53079 -> x = 53-32 = 21, y = 07-32 = -25, richness 9/9 hidden-nitrate-vein
07579 -> x = 07-32 = -25, y = 57-32 = 25, richness 9/9 hidden-rainbow-pit
59399 -> x = 59-32 = 27, y = 39-32 = 7, richness 9/9 hidden-payload-lode
```
Three of the groups on air are the three `hidden: true` seams; the rest are decoys that
decode to empty ground. A player with a pencil gets three maximum-richness seams nobody
told them about. **The validator recomputes the planted groups from `seams.json`**, so if a
hidden seam ever moves and the broadcast doesn't, the test fails — the map cannot go stale.
It also checks no decoy accidentally decodes onto a real seam, because a decoy that points
at treasure isn't a decoy, it's a leak.
Filler is procedural (SCREEN's seeded RNG) rather than a fixed list, so the station never
loops — a hand-written pool of 24 would cycle in about two minutes and the genre's whole
menace is that it goes on forever.
## Added in round 6
### THE CORRECTION walks — the parity-mite reading of §5
§5 gives the enemy in one clause: parity mites are *"beetle-drones that patrol belts and
quietly fix artifacts back into clean product (your melt becomes... footage of a lake)."*
The reading the data commits to, and why it is the codex's:
- **The mite does not destroy — it repairs, which is worse.** The Correction "cannot
perceive you, only deviation, which it repairs." So a mite eats a tier-2 glitch product
and hands back nothing you can sell: the work is *corrected*, not stolen. SIM emits a new
`repaired` event rather than a `removed` one, and the fiction is that losing melt to a
mite is losing it to a *fix*. THE STANDARD's new lines say this in the enemy's own voice
("A mite does not remove your work. It completes it. Your MELT was always going to be a
lake." — the codex line, spoken as reassurance).
- **Mites only touch corruption.** SIM's mechanic ignores tier-0/1 cargo entirely: the
immune system has nothing to correct in raw ore. That is the balance (raw material is
safe; only product is at risk) and the fiction (deviation is the only thing it can see).
### THE FIREWALL — the counter, on SIM's catcher shape
| Thing | Kind | Why it fits |
|---|---|---|
| `parity-mite` | item (tier 2) | The canned live mite. Hospital-white shell, green checkmark eye (§5, §8) → chip colour `#dff5e6`. Tier 2 like the other farmed hazards (`mosquito-swarm`, `gibbs-wraith`). |
| `firewall` | machine (crafter) | §5 names the mites but no counter. A *firewall* is the in-genre name for the thing that stops an intrusion, and the joke writes itself: it wears a compliance seal the Bitstream never issued, and a mite "cannot leave a deviation uncorrected" — a forged checkmark IS a deviation, so it climbs in to fix the seal and the drawer closes. Accent `#3fff7f` is that forged checkmark; chassis `#31383f` is your grime, not The Correction's white. |
| `catch-mite` | recipe (firewall) | **Input-free, output `parity-mite: 1`** — the exact shape SIM detects a catcher by (empty inputs, cans the creature), lifted from the mosquito trap. The "input" is a live mite in the world, which is wildlife in the snapshot, never cargo on a belt. Ingredients would silently demote it to an ordinary crafter that mints The Correction from nothing — pinned by a test that cites SIM's comment. |
| `broadcast-parity-firewall` | tech | Gated like the mosquito trap (`broadcast-mosquito-farming`): `{spatial-pack: 15, analog-pack: 10}`, unlocks `[firewall, catch-mite]`. You research the counter to the enemy. Early/cheap because the mite is §5's *"early nuisance"* — the counter should arrive about when the mites do. |
**Coordination with SIM (parallel this round):** SIM detects catchers by recipe shape and
by the constant `MITE_ITEM`. The item id is **`parity-mite`** and the recipe shape is
**`{ inputs: {}, outputs: { 'parity-mite': N } }`** — identical to the trap, one item id
over. `catch-mite` bandwidth 4 / 120 ticks (a harder catch than a mosquito's 90). The
radius is SIM's constant (`FIREWALL_RADIUS`), not a machine field.
### Two commissions that want The Correction alive
- `correction-specimen` (reward 1000): the flagship — *"ONE (1) UNIT OF THE CORRECTION,
ALIVE. INVOICE ATTACHED. DO NOT READ THE INVOICE."* The fax's first canned mite, in
voice. Slots between `gibbs-wraith-willingly` (900) and `feather-proof` (1100).
- `annual-correction-cull` (reward 2100, **standing**): 5 live mites + 20 melt per
collection, forever — you turn the immune system into a cash crop, and The Correction
files each absent technician as a rounding error and sends five more. Slots between
`sodium-requisition` (1800) and `sky-blue-mislabeled` (2400). Re-fillable forever
because both wants are producible (the catcher + the melt chain).
`parity-mite` is **producible** the moment the firewall exists (an input-free recipe reads
as always-runnable to the reachability closure, exactly as `mosquito-swarm` does), so it is
NOT forward canon and never joins the allowlist.
### The numbers station stops drifting
SCREEN's round-5 finding was real: the validator recomputed the three *planted* groups from
`seams.json` but never checked the *decoys*, and two of them had drifted onto visible seams:
```
08127 -> (-24,-20) sat inside reel-beds-deep (x[-30..-21] y[-24..-19])
27503 -> ( -5, 18) sat inside broadcast-shelf-south (x[-14..-3] y[16..21])
```
A decoy that decodes onto a real seam isn't a decoy, it's a leak — it hands the player a
genuine dig site the station was never meant to reveal. Both re-cut to land just *off* the
seam they were touching (still tantalisingly adjacent, which is the genre's whole cruelty):
```
08127 -> 08143 -> (-24,-18) one tile south of reel-beds-deep, on empty ground
27503 -> 27475 -> ( -5, 15) one tile north of broadcast-shelf-south, on empty ground
```
And the validator now decodes **every** decoy to a tile and fails, by name, if it lands
inside **any** seam rect — hidden or visible — not just the three hidden tiles. The map
recomputes its plants from the seams and now proves its decoys lead nowhere; it cannot go
stale in either direction. (All 22 remaining decoys were re-checked against all 8 seams —
these two were the only leaks.)
### Accents chosen against the real GLB textures
Round 4 filled accents by derivation; MODELBEAST has since delivered real models, so this
round the accents are picked by **looking at the actual albedo textures** in
`public/assets/models/` (extracted and inspected). The order named "the ten GLB'd machines";
by the time I looked, nineteen had models (RENDER hot-swapping mid-round), so every machine
with a real model now carries a texture-matched accent:
| Machine | Accent | Read from the texture |
|---|---|---|
| seam-extractor | `#48d6ff` | the cyan CRT readout on the amber drill body (§4 "CRT-certified") |
| quantizer | `#ff3b57` | CRIME-red dial indicator against the pale lilac/dust body |
| mosh-reactor | `#ff7a3f` | the molten-orange gauge; matches its MELT output |
| artifact-bottler | `#7fe0d8` | the iridescent glass-aqua of the bottled contents |
| chroma-keyer | `#4fff30` | the chroma-key green it literally weeps (green-spill) |
| muxer | `#ff3fd0` | launch-warning magenta on cold steel; matches the megaglitch |
| demuxer | `#ff3fd4` | the magenta channel-split streak (its chroma output) |
| demosaicer | `#4fffc4` | the spectral cyan-green band of the rainbow weave |
| dct-press | `#3fffe0` | the coefficient-cyan indicator specks on cold steel |
| field-loom | `#6ef58a` | the phosphor-green glowing tube |
| gop-assembler | `#5fb0ff` | the blue cadence-readout screens |
| mv-extractor | `#3fd8ff` | the motion-vector cyan fluid |
| p-caster | `#6fa8ff` | the blue projector beam |
| subsampler | `#e04fc8` | the iridescent chroma-pink it grades down |
| bloom-duplicator | `#ff9440` | the warm bloom-orange furnace glow |
| decode-asic | `#8fe8ff` | **refined** from `#6fe8c0`: the LEDs are ice-blue, not mint |
| mosquito-trap | `#a64fff` | **corrected** from `#d0ff60`: the model's zapper target glows UV-violet, not lime |
| software-decoder | `#ff4a2a` | kept — the texture's red internals confirm it |
| archaeology-lab | `#ffc24a` | kept — the warm dig-lamp amber confirms it |
The `firewall` (no model yet) gets its forged-checkmark `#3fff7f` art-directed, not derived.
---
## Added in round 7
### THE CORRECTION's rungs 2-4 — what §5 asks of the data
§5's ladder is four rungs; round 6 built rung 1 (parity mites) and its counter. Rungs 2-4
are mechanics, and almost all of them are SIM's — the useful thing to record is how little
new *content* the enemy needs, and the one place it needs a lot.
| Rung | §5 | What data owes it |
|---|---|---|
| 2 CHECKSUM WARDEN | audits a machine, rolls back its last N outputs, stamps a compliance seal | **Nothing new.** The seal is `EntityState.sealed`, the counter is the FIREWALL authored in round 6, and the expensive counter is the `pry` command. |
| 3 REDUNDANCY GUNSHIP | mirrors a sector and out-ships you with a sanitised copy | **Nothing new** — but see the deviation ruling below, which quietly makes `ItemDef.tier` load-bearing for it. |
| 4 HASH AUDITOR | refuses a region into 0:00 stasis until you break the lock | **FORGED HASH** — the whole material counter, and the only new item this round. |
**The pry exchange rate is worth writing down.** `DEBT_PER_PRY` is 0.05 and
`DEBT_VENT_COMPLIANCE` is 0.2, so **four pried seals cost exactly one QUOTA 7-C to
undo**. The firewall is free and the crowbar is billed — which is the correct shape:
the counter you researched is cheap, and defacing a notice with your hands is not.
### The no-grudge ruling — the enemy has no feelings, only a ledger
RULING (SIM's, recorded here as canon): **canning a Correction unit adds no debt.** §5 is
explicit — it "does not hate you; it *cannot perceive* you — only deviation, which it
repairs." A grudge requires a perceiver. So the firewall is free, the mite in the jar is
free, and round 6's `correction-specimen` (1000) and `annual-correction-cull` (2100,
standing) stay pure profit with no hidden tax on farming the immune system.
The consequence is the interesting half: **the only two things that raise the figure are
shipping corruption and prying seals** — and both of those are things you did to the *file*,
not things you did to *them*. You cannot anger The Correction. You can only be, statistically,
a region of the file that does not match.
### The deviation-targeting ruling — `tier` is not flavour any more
RULING (SIM's): a gunship tethers to the 8×8 sector with the most **tier≥2 output**, not the
most throughput. It corrects corruption, not productivity — an honest, busy, boring sector is
invisible to it, which is the same joke the mites tell one rung down.
Recording it because of what it does to this lane: `ItemDef.tier` is now read by **three**
separate mechanics — `MITE_TIER_MIN` (what a mite will repair), `DEBT_TIER_MIN` (what counts
as deviation when shipped), and now the gunship's sector scoring. **A tier number is no longer
decoration.** Mis-tiering an item now mis-aims an airborne enemy and mis-prices the debt
economy at the same time. Standing rule for future rounds: tier is a mechanic; argue it.
### COMPLIANCE QUOTAS — The Correction's register
Four `compliance: true` standing orders, spread across the reward curve so the valve is
never far from the tray (indices 5, 9, 13, 20 of 25):
| Quota | Wants | Reward | The horror |
|---|---|---|---|
| `quota-7c` | 40 LUMA BARS | 150 | The order's own exemplar. "THE FILE THANKS YOU FOR YOUR COOPERATION. THE FILE DOES NOT KNOW YOUR NAME." |
| `quota-12a` | 80 MDAT ORE + 30 HF DUST | 340 | *"We are not purchasing these. We are removing them from the possibility of becoming something."* |
| `quota-30r` | 100 bbl CHROMA SLURRY | 620 | The callback. `first-refinement` taught you the demuxer's slurry is *your problem now*; The Correction has been waiting four commissions to solve it for you, forever, and not ask what you were going to do with it. |
| `quota-99z` | 40 GOP CRATES + 25 ANCHOR SLABS | 1600 | Sealed and unmoshed. You run the entire M1 chain and then hand over the crate *instead of* making melt with it. The endgame quota asks you to build the factory and decline to be it. |
**Why tier-0/1 is the whole rule.** Debt rises on tier≥`DEBT_TIER_MIN` shipments, so a quota
that wanted a tier-2 product would charge the player debt for venting debt. The validator
fails such a quota by name and by tier. It is also the fiction, exactly: the register cannot
ask for corruption, because from inside the file corruption is not a thing you *have*, it is
a thing that is *wrong with you*.
**Why they are standing orders.** A valve you can open once is not a valve. All four repeat,
and all four are fillable at tick 0 with zero research — pinned by a test, because debt starts
climbing the moment the first melt ships and the tech tree opens much later than that. A quota
gated behind research would be a fire escape welded shut.
**Why the pay is good.** In voice: `correction-buyback` has said since round 2 that "we pay
well, we pay immediately, and we pay every time." The quota is *supposed* to be tempting —
shipping boring content on purpose only becomes a real strategy if it is a real temptation.
The counterweight is not the rate, it is the ceiling: the register can never ask for anything
above tier 1, so it cannot scale with you, while the collectors' ladder climbs to 3200. Late
game, QUOTA 99-Z's 1600 is half of `quarterly-sins`. You outgrow compliance; that is the arc.
### FORGED HASH — the moov heist, industrialised
`forge-hash` on the existing HEX SPLICER: **1 STCO MAP + 50 HF DUST → 1 FORGED HASH**
(tier 3), 240 ticks, bandwidth 40, heat 0.4. Gated by a new fortress node
`fortress-moov-heists` (35 container + 25 temporal packs).
The order suggested `container-pack + hf-dust`; here is the argument for the other reading,
as asked.
1. **No recipe in this game consumes a science pack.** Packs are research currency, spent at
the lab and nowhere else — that is one of the few genuinely clean lines in the economy.
The endgame counter should not be the single exception that blurs it.
2. **§6 names the material outright.** MOOV HEISTS: *"scan scrolling hex for real frame
signatures ... and rebuild/forge the stco/stsz index to your advantage."* The forged
article **is** the stco index. And the machine was already standing there: §4's hex
splicer is *"a robotic hex-editor"* over *"a scrolling hexadecimal ticker-tape"*, and it
has charted the genuine `stco-map` since round 3. The machine, the material and the crime
were all already in the data. The recipe only admits it.
3. **You cannot forge a document you have never seen a real copy of.** Consuming a genuine
STCO MAP is the forger's first rule, and it is why the input is the expensive one.
4. **HF DUST is the forger's second rule, and this is the part I like.** A hash is a digest:
to make one land where you want it, you append bytes that change nothing anyone can see.
HF DUST is *literally* the high-frequency detail the quantizer threw away — invisible
bytes, in barrels. Packing the discarded difference back in until the numbers agree is how
a real collision is actually built. It also hands the game's most over-produced byproduct a
genuine endgame sink: 50 per forgery, against `pan-speckle`'s 40.
5. **It costs the victory chain.** `assemble-moov-brain` wants an STCO MAP too, so every
forgery is one MEGAGLITCH deferred. You spend the endgame to survive the endgame — the
correct price for voiding an inquisition.
**Shape note for SIM, as ordered:** `forge-hash` has **two inputs**, so it cannot and must not
travel the empty-inputs catcher path that spots traps and firewalls. A test now fails by name
if *any* input-free recipe ever produces `forged-hash`, so the second detection you budgeted
can rely on the shape staying an ordinary craft. The item id is exactly your
`FORGED_HASH_ITEM`, imported rather than retyped.
### Eight more STANDARD lines — the audit pool
Chyron 38 → 46 lines (~15 minutes before a repeat). One line per mechanic the player is about
to meet, all ≤96 chars, all in the reassuring-menace register:
```
Units conducting review are not present. What is not present cannot be obstructed. <- you cannot fight them
The sector was always white. You are remembering a draft. <- stasis
A seal is not a punishment. A seal is a machine being permitted to rest. <- the warden's seal
If your work appears beside itself, the copy is the one doing it correctly. <- the gunship mirror
An audit finds nothing where there is nothing. Be nothing for five seconds. <- ABSTENTION, taught
Your ledger is not a debt. It is a description, and descriptions are revised down… <- the quota valve
We do not lock a sector. We decline to unlock it. These are different services. <- the auditor
A hash you wrote yourself is still a hash. We will read it. We will believe it. <- the forged hash
```
Round 6's lesson is now enforced in this lane rather than discovered in SCREEN's: the 96-char
cap has its own test here, deliberately a conservative local copy of theirs.
---
## The numbers, and the arithmetic behind them
@ -119,6 +514,49 @@ idle draw (every machine's powerDraw) 57 /s
**Units (RULED round 3):** `gen`/`draw` are bandwidth per **second**; `stored` and
`bufferCap` are bandwidth-**seconds**. My round-2 assumption held.
### Round 4: the tech tree was a total bootstrap deadlock
The v4 gating rule — *any id in a `TechDef.unlocks` is locked until that tech completes*
turned three rounds of tech authoring from decoration into a lock on the entire game. I
had written every node assuming gating didn't exist. Measured against the rule:
```
TICK 0, nothing researched:
melt producible? false <- first-taste, the opening commission, impossible
analog-pack producible? false
spatial-pack producible? false
temporal-pack producible? false
container-pack producible? false
RESEARCH CLOSURE: 0 of 24 techs reachable — every node stranded behind its own output
```
Every pack chain was gated behind packs you could not make. `disc-artifact-bottling` cost
10 analog packs and unlocked the only machine that can bottle an analog pack. The M1 melt
chain itself (mosh reactor, subsampler, `press-anchor-slab`) was gated, so the game opened
on an unbuildable factory and an unfillable commission, with no way to research out of it.
**The fix is a shape, not a patch.** Two rules now hold, and both are tests:
1. **The M1 melt chain is starting equipment** — ungated, so first-taste is possible at
tick 0. Ungating it also fixes RENDER for free: they derive a machine's era from the
tech that unlocks it and treat unmapped machines as starting equipment, which is exactly
what the M1 chain now is.
2. **One pack must be craftable at tick 0**, or research can never begin. That pack is the
**SPATIAL PACK** — macroblock bricks + ringing halos + slurry, all of which fall out of
the quantizer the player already owns. Your first research is a study of your own
damage, which is the most in-voice thing in the tree. Everything else chains off it:
spatial → analog → temporal → container → fortress.
Result: **23 / 23 nodes reachable**, unlock order tells a story (phosphor seams → field
loom → analog packs → telecine → temporal → container → fortress), and the only items
unreachable with the whole tree bought are the four declared forward-canon.
Six disc nodes were **deleted**, not repurposed, because their whole content became
starting equipment: `disc-artifact-bottling`, `disc-chroma-subsampling`,
`disc-macroblock-quarrying`, `disc-ringing-skim`, `disc-subsampling-420`,
`disc-mosh-reactor`. Grepped first — no lane references tech ids (RENDER reads
`unlocks` generically, never a specific node). Recorded here per the deprecation rule.
### Round 2's heat was decorative — what went wrong
Worth writing down, because it's a trap anyone can fall into. I sized round 2's
@ -194,6 +632,171 @@ than it spends, recharging 300 in **4.5 s** of the 7.25 s up-phase. That is the
sentence — **a buffer tank is what makes software decoders survivable** — and it now
closes.
### The ASIC cooler goes live: `coolPerTick 0.02 → 0.0018`, `coolRadius: 2`
Round 3 spec'd the aura at 0.02 while it was inert. **The moment it became mechanical that
number was catastrophic:** the decoder banks 0.0033/tick, so a 0.02 aura means net
0.0167 — it never heats at all, and a 2×2 cooler turns a 2×2 decoder into a permanent
free 100/s. A spec'd number and a live number are different objects; this is the second
time this round that a number only became wrong once something read it.
**Round 5, FINAL under the additive ruling.** Contracts v5 settled it: auras are additive,
capped at `HEAT_COOL_MAX`. That killed 0.0018 — additive, two coolers reach 0.0046 against
the decoder's 0.0033 gross, so a casual pair made it immortal. Final value **0.001**:
```
coolers cooling throttle at duty avg gen note
0 0.0010 10.1 s 30 % 25.8 /s bare decoder, as the codex demands
1 0.0020 17.9 s 61 % 51.5 /s the pairing — output doubles
2 0.0030 77.8 s 91 % 77.3 /s heavy investment, nearly continuous
3 0.0040 never 100 % 100.0 /s immortal: an absurd shrine to one PC
```
The shape is deliberate. One cooler is the worked example and it **doubles** the decoder's
average output — a real reward for engineering around heat. Two is a serious commitment
that nearly eliminates downtime. Three tips into immortality, which costs 12 tiles of
cooler wrapped around 4 tiles of decoder — visibly ridiculous, and in-voice: the codex's
software decoder is a tower PC "held together with zip ties and prayer", so a shrine of
coolers keeping one alive is the correct picture. Immortality is reachable, never casual.
Linear stacking means *some* number always cancels the heat; my job was choosing which
number, and making sure it isn't 2. Per tile, when both machines are 2×2:
```
2 decode ASICs 40.0 /s over 8 tiles = 5.00 /s per tile
1 software decoder, bare 25.8 /s over 4 tiles = 6.44 /s per tile
decoder + 1 cooler 56.5 /s over 8 tiles = 7.06 /s per tile (incl. the cooler's own 5)
```
So the pairing is the best power in the game per tile (1.4× ASICs) — and it costs two
techs, two machines, and the floor space to keep them adjacent. That is the reward for
engineering around heat instead of suffering it, which is what the cooler is *for*. The
bare decoder still beats a pair of ASICs per tile, but it does so in violent 30%-duty
bursts that a buffer tank has to absorb; the ASIC's 5.00 never wavers. Three ways to buy
power, three different failure modes.
### Research: is the first node affordable off a first-taste-era factory?
Worked path for the bootstrap pack, one bottler, one quantizer:
```
bottle-spatial-pack = 2 macroblock-bricks + 1 ringing-halo + 2 chroma-slurry, 60 ticks
2 bricks <- quantize-crime x1 (1 coefficient pack, 20 ticks)
1 halo <- skim-ringing x1 (3 coefficient packs, 40 ticks)
4 coefficient packs <- quantize x4 (4 luma, 120 ticks) <- 6 mdat ore
THE QUANTIZER IS THE BOTTLENECK: 20 + 40 + 120 = 180 ticks per spatial pack
= 10.0 packs / minute on one quantizer
```
Against the early nodes:
```
broadcast-phosphor-seams 5 spatial = 0.5 min
reel-silver-seams 8 spatial = 0.8 min
disc-roto-bench 10 spatial = 1.0 min
broadcast-field-loom 15 spatial = 1.5 min <- opens the ANALOG PACK
```
A player who has shipped their first melt already owns a quantizer and can afford the first
four nodes inside two minutes of production. That's the intent: **the opening research is
paid for with the quantizer's own sins** — you study your own damage. Later nodes scale to
tens of minutes and demand the analog/temporal/container chains, which is where the floor
space goes.
### Round 7: how fast a melt line walks the ladder, and what a quota buys back
All against LANE-SIM's constants as landed (`DEBT_PER_CORRUPT_SHIP 0.0025`,
`DEBT_DECAY_PER_TICK 0.00002`, bands `[0.2, 0.4, 0.6, 0.8]`).
One mosh reactor is 3 MELT per 90 ticks — the M1 chain's whole point. That is
`3 × 0.0025 = 0.0075` of debt per 90 ticks, less 90 ticks of decay:
```
net climb = 0.0075/90 0.00002 = 0.0000633 / tick
band 1 OBSERVED (0.2) = 3,160 ticks = 1m 45s of shipping melt
band 2 AUDITED (0.4) = 6,320 ticks = 3m 31s <- wardens
band 3 SEALED (0.6) = 9,480 ticks = 5m 16s <- gunships
band 4 REFUSED (0.8) = 12,640 ticks = 7m 01s <- auditors
```
**A single-reactor factory that never ships anything boring meets all four rungs inside
seven minutes.** That is the pressure the valve exists to answer.
Measured, not modelled — LANE-SIM's real reference factory on my real JSON (throwaway
probe, then deleted; sim behaviour is their lane to test):
```
band 1 OBSERVED t = 5,874 (3m 16s)
band 2 AUDITED t = 9,281 (5m 09s) <- wardens
band 3 SEALED t = 12,740 (7m 04s) <- gunships
band 4 REFUSED t = 16,152 (8m 58s) <- auditors
```
Slower than the clean model because the reference plant spends its early minutes building
up rather than shipping, and because 1,013 of its 3,300-odd corrupt shipments are
macroblock bricks that arrive in a late burst. Same shape, ~1.3× the clock: **the whole
enemy ladder unspools inside the first nine minutes of a working factory.**
Against it, QUOTA 7-C: 40 LUMA BARS is 20 `demux-ore` crafts (45 ticks each) = **900 ticks,
30 seconds** on one demuxer fed by two extractors. Completing it vents 0.2 outright —
bands are 0.2 wide, so **one quota = exactly one band** — plus 0.024 of loose clean credit
from the material itself.
```
30 seconds of a two-machine side line buys back 1m 45s of melt-shipping debt
```
~3.5× leverage in time, deliberately. The strategy has to be *tempting* or nobody chooses
to be boring. What it actually costs is floor and ore: the quota line is a second copy of
the rung-1 starter factory that will never make a single unit of product.
**The vent must beat the material, and now that is a test.** SIM's comment says the vent is
"deliberately worth more than the material: the paperwork is the point, not the luma." Every
quota is measured against it — loose clean credit (`items × DEBT_CLEAN_CREDIT_PER_ITEM`) must
stay under `DEBT_VENT_COMPLIANCE`, or the fax would be a worse version of just belting the
same crates at the sky:
```
quota-7c 40 items -> 0.024 credit 12% of the 0.2 vent
quota-12a 110 items -> 0.066 33%
quota-30r 100 items -> 0.060 30%
quota-99z 65 items -> 0.039 20%
```
All well under the 0.25 `DEBT_CLEAN_CREDIT_MAX` ceiling too, so no quota asks for material
the economy will discard on arrival. The paperwork is worth 3-8× the luma. As intended.
**And the valve was measured, end to end, in the real sim.** QUOTA 30-R wants 100 bbl
CHROMA SLURRY and the reference factory ships 230 of it, so hoisting that quota to the head
of the queue (in memory; the JSON on disk untouched) exercises the whole path:
```
t=10,932 DONE quota-30r debt 0.3112 -> 0.1108 (delta -0.2004)
band 1 -> band 0, and the standing order re-queued to the back
```
0.2004 is `DEBT_VENT_COMPLIANCE` exactly, plus that tick's own decay. **One quota, one
band, measured.** The claim in the table above is not arithmetic; it is a reading.
### Round 7: the forged hash's fever, and why one fits inside the fax
`forge-hash` banks 0.4 per craft; the splicer sheds `240 × 0.001 = 0.24` over the same 240
ticks. Net **0.16 per craft → 6.25 crafts → 1,500 ticks = 50s** of continuous forging before
it scrams. Identical to `chart-stco-map`'s 50s and still a lesser fever than the decoder's
14.5s, so the round-3 ordering holds: **the splicer can make six forgeries, then it must cool.**
The timing that matters is against `AUDITOR_NOTICE_TICKS = 600`:
```
notice -> stasis 600 ticks (20s)
one forgery 240 ticks (8s)
```
One forgery fits inside the fax lead time **with room to spare — but only if the STCO MAP is
already on the belt.** So the real preparation is stockpiling maps, which competes directly
with `assemble-moov-brain`. §4's line on the splicer ("requires a steady buffer feed or it
splices *itself*") turns out to be the auditor counter's whole difficulty curve.
### `heat` on the hex splicer: `coolPerTick 0.001`, 0.08 per splice
Net 0.035 per craft after the 45 ticks of cooling → 28.6 crafts → **42.9 s** of continuous
@ -207,15 +810,23 @@ splicing before it scrams (`chart-stco-map`: 50 s). A lesser fever than the deco
- **The ASIC cooler's aura is spec'd, not built** (above). Until SIM has auras, heat is
survivable only by duty-cycling or by leaning on ASICs. Deliberate.
- `cinema-pack`, `mosquito-swarm`, `gibbs-wraith` remain forward canon (no recipe). The
- `cinema-pack`, `gibbs-wraith`, `sodium-light` remain forward canon (no recipe;
`mosquito-swarm` retired from the list in round 5 when the trap landed). The
validator's FORWARD_CANON allowlist is the enforcement, and it has a companion test
asserting nothing sits on the list once it *is* producible. Round 3 retired
`container-pack`'s slot (round 2) and added none.
- Dials-as-recipes (quantizer Q, subsampler grades, GOP cadence) stays, per round-2 review.
- **The heat constants in `data/validate.test.ts` are mirrored from `src/sim/index.ts`**
(throttle 0.7, restart 0.5, dissipation default 0.004, 30 tps). They are not exported,
so my arithmetic tests can't import them. If SIM retunes the curve, my tests go stale
*silently* — they'd still pass while the feel drifts. Re-check on any heat round. A
cheap fix if SIM likes: export the constants.
- ~~The heat constants are mirrored by hand~~ **CLOSED (round 5)**: LANE-SIM exported them
in `src/sim/constants.ts` and the validator imports them. The copies are retired; if SIM
retunes the curve my arithmetic now fails loudly instead of asserting the old feel.
- The muxer, moov brain and megaglitch are data-only; no lane implements a victory
condition yet. The tech gate (`fortress-the-muxer`) keeps them out of reach meanwhile.
- **The register is a queue position, not a register (round 7, open).** SIM credits only
`commissionQueue[0]`, so a compliance quota can only be filed when it reaches the head.
Standing quotas do re-queue to the back, so mid-game the valve comes round periodically —
but it is not the on-demand relief the fiction implies ("standing quota; there is no final
collection, only the next one"). Mitigated in data by spreading four quotas across the
curve so at most 8 commissions ever sit between valves, and pinned by a test. The real fix
is a parallel always-open register, which is a contract/mechanic question, not a data one.
- **`ItemDef.tier` is now read by three mechanics** (mite repair, debt accrual, gunship
sector scoring). Any future re-tier is a balance change in three places at once.

View File

@ -1,12 +1,28 @@
[
{ "id": "raw-payload",
"flavor": "20 MDAT-ORE. Raw. Do not wash it. Do not demux it. Do not ask why. A belt, a drill, an uplink to THE SCREEN — that is the whole job. Welcome to the trade.",
"wants": { "mdat-ore": 20 }, "rewardBandwidth": 20 },
{ "id": "first-refinement",
"flavor": "15 LUMA BARS. You will need the DEMUXER for this, and the DEMUXER gives you colour whether you asked for it or not. The CHROMA SLURRY is your problem now. It has to go somewhere. We do not care where. The fax does not care where.",
"wants": { "luma-bars": 15 }, "rewardBandwidth": 35 },
{ "id": "dust-quota",
"flavor": "18 HF DUST. Yes, the waste. Run the QUANTIZER hard and it falls out the seams like glitter off a crime. In this trade the garbage is inventory. Sweep it up. Ship it. Bill us.",
"wants": { "hf-dust": 18 }, "rewardBandwidth": 55 },
{ "id": "first-taste",
"flavor": "One (1) unit of MELT. We hear you make it fresh. No questions will be answered.",
"wants": { "melt": 1 }, "rewardBandwidth": 50 },
"flavor": "One (1) unit of MELT. We hear you make it fresh. No questions will be answered. You have run the whole line to make this — ore to anchor to crate to reactor. Graduation, of a kind. There is no ceremony. There is only the next fax.",
"wants": { "melt": 1 }, "rewardBandwidth": 80 },
{ "id": "dust-allowance",
"flavor": "12 STATIC CANISTERS, brine grade, not display grade. Our previous supplier shipped display grade and we no longer speak of them. Standing order; the jars come back empty and we do not explain why.",
"wants": { "static-canister": 12 }, "rewardBandwidth": 120, "repeat": true },
{ "id": "quota-7c",
"flavor": "QUOTA 7-C: 40 LUMA BARS. Grey card. No cast, no character, no opinion. THE FILE THANKS YOU FOR YOUR COOPERATION. THE FILE DOES NOT KNOW YOUR NAME. Standing quota; there is no final collection, only the next one. Your figure has been revised downward.",
"wants": { "luma-bars": 40 }, "rewardBandwidth": 150, "repeat": true, "compliance": true },
{ "id": "patio-resurfacing",
"flavor": "48 MACROBLOCK BRICKS for a patio. Client has been advised the patio will not be flat. Client insists the patio has never been flat. Proceed.",
"wants": { "macroblock-bricks": 48 }, "rewardBandwidth": 220 },
@ -16,9 +32,13 @@
"wants": { "luma-bars": 30, "gamut-paint-rec709": 4 }, "rewardBandwidth": 260 },
{ "id": "haunted-doorway",
"flavor": "9 RINGING HALOS, hung not stacked. They are for a doorway. The doorway is for nothing. Do not ask what comes through it; it is already through.",
"flavor": "9 RINGING HALOS, hung not stacked. They are for a doorway. The doorway is for nothing. Do not ask what comes through it; it is already through. [someone has drawn a sine wave in the corner of this fax. it is not our department.]",
"wants": { "ringing-halos": 9, "gamut-paint-rec709": 2 }, "rewardBandwidth": 300 },
{ "id": "quota-12a",
"flavor": "QUOTA 12-A: 80 MDAT ORE, unwashed, and 30 HF DUST, swept. We are not purchasing these. We are removing them from the possibility of becoming something. Nothing sent to this address will ever be looked at, and that is the service. THE FILE THANKS YOU.",
"wants": { "mdat-ore": 80, "hf-dust": 30 }, "rewardBandwidth": 340, "repeat": true, "compliance": true },
{ "id": "correction-buyback",
"flavor": "Good morning. Our records show 5 unreferenced ANCHOR SLABS in your sector. We would like to purchase them. We pay well, we pay immediately, and we pay every time. This is not an audit. You are not in trouble. There is no trouble. Your file looks so much better already. We will be here.",
"wants": { "anchor-slab": 5 }, "rewardBandwidth": 450, "repeat": true },
@ -31,6 +51,10 @@
"flavor": "STANDING ORDER: 25 MELT per collection, indefinitely. Our client is redecorating. Our client has not specified an end date. We have stopped asking our client things.",
"wants": { "melt": 25 }, "rewardBandwidth": 560, "repeat": true },
{ "id": "quota-30r",
"flavor": "QUOTA 30-R: 100 bbl CHROMA SLURRY. Any subsampling. No ceiling, no schedule, no end. We understand you have more of this than you want. We understand a great many things about your sector that nobody told us. Pour it in. We will take all of it, always, and we will not ask what you were going to do with it.",
"wants": { "chroma-slurry": 100 }, "rewardBandwidth": 620, "repeat": true, "compliance": true },
{ "id": "vaporwave-sunset",
"flavor": "1 MELTED ANCHOR SLAB + 3 MOSQUITO SWARMS (live) + 40 bbl CHROMA SLURRY (4:2:0) = 1 VAPORWAVE SUNSET. Deliver before the beat drops.",
"wants": { "melt": 1, "anchor-slab": 1, "mosquito-swarm": 3, "chroma-420": 40 }, "rewardBandwidth": 700 },
@ -43,10 +67,30 @@
"flavor": "One (1) GIBBS WRAITH, willingly.",
"wants": { "gibbs-wraith": 1 }, "rewardBandwidth": 900 },
{ "id": "correction-specimen",
"flavor": "ONE (1) UNIT OF THE CORRECTION, ALIVE. Shell intact, checkmark lit, proboscis zip-tied. INVOICE ATTACHED. DO NOT READ THE INVOICE. We have been trying to get one to hold still since before the Reel Beds cooled, and you did it in an afternoon, with a forged seal and a drawer that only opens one way. We are not asking how. We are asking for another.",
"wants": { "parity-mite": 1 }, "rewardBandwidth": 1000 },
{ "id": "feather-proof",
"flavor": "3 GHOST FEATHERS. Gradient intact, tip genuinely transparent, no hard edge anywhere on the object. We have been sold nine hard mattes by nine suppliers who each insisted it was the same thing. It is not the same thing. Do not send a matte.",
"wants": { "ghost-feather": 3 }, "rewardBandwidth": 1100 },
{ "id": "container-integrity-review",
"flavor": "4 CONTAINER PACKS and 20 EXPLOSIVE HITS, shipped in that order and not the other order. The last supplier shipped in the other order. There is no last supplier.",
"wants": { "container-pack": 4, "explosive-hits": 20 }, "rewardBandwidth": 1400 },
{ "id": "quota-99z",
"flavor": "QUOTA 99-Z: 40 GOP CRATES, SEALED, and 25 ANCHOR SLABS, UNMOSHED. Do not open them. Do not improve them. We are aware what your reactor does to a crate and we would like this batch to arrive having never met it. Your cooperation is recorded in a file whose only contents are your cooperation.",
"wants": { "gop-crate": 40, "anchor-slab": 25 }, "rewardBandwidth": 1600, "repeat": true, "compliance": true },
{ "id": "sodium-requisition",
"flavor": "One (1) beam of SODIUM LIGHT, 589nm, in its prism. We are aware who holds the prisms. We are aware what they call it when you take one. We are prepared to call it something else. 30 BLUE HOLES accepted as consolation if you lose your nerve.",
"wants": { "sodium-light": 1, "blue-hole": 30 }, "rewardBandwidth": 1800 },
{ "id": "annual-correction-cull",
"flavor": "STANDING ORDER: 5 PARITY MITES per collection, live, and 20 MELT alongside to prove they were mid-repair when you took them. The Correction files each absent technician as a clerical rounding error and dispatches five more to look into it. We would like you to keep having this problem — professionally, on retainer, forever.",
"wants": { "parity-mite": 5, "melt": 20 }, "rewardBandwidth": 2100, "repeat": true },
{ "id": "sky-blue-mislabeled",
"flavor": "600 MACROBLOCK BRICKS, sky-blue, MISLABELED REC2020. Purpose: none of your business.",
"wants": { "macroblock-bricks": 600, "gamut-paint-rec2020": 6 }, "rewardBandwidth": 2400 },

View File

@ -49,6 +49,17 @@
{ "id": "mosquito-swarm", "name": "MOSQUITO SWARM (LIVE)", "codex": "5:mosquito-swarms", "tier": 2, "color": "#d0ffb0" },
{ "id": "gibbs-wraith", "name": "GIBBS WRAITH", "codex": "5:gibbs-wraiths", "tier": 2, "color": "#bfe0ff" },
{ "id": "parity-mite", "name": "PARITY MITE (LIVE)", "codex": "5:parity-mites", "tier": 2, "color": "#dff5e6" },
{ "id": "alpha", "name": "ALPHA", "codex": "10:alpha", "tier": 1, "color": "#b8e8f0" },
{ "id": "hard-matte", "name": "HARD MATTE", "codex": "10:hard-matte", "tier": 1, "color": "#e8e8e8" },
{ "id": "green-spill", "name": "GREEN SPILL", "codex": "10:green-spill", "tier": 1, "color": "#4fff30" },
{ "id": "mixed-pixel", "name": "MIXED PIXEL", "codex": "10:mixed-pixel", "tier": 1, "color": "#a04fd0" },
{ "id": "blue-hole", "name": "BLUE HOLE", "codex": "10:blue-hole", "tier": 2, "color": "#2f5fd0" },
{ "id": "fringe-lace", "name": "FRINGE LACE", "codex": "10:fringe-lace", "tier": 2, "color": "#fff0c0" },
{ "id": "roto-chatter", "name": "ROTO CHATTER", "codex": "10:roto-chatter", "tier": 2, "color": "#ff8f4f" },
{ "id": "ghost-feather", "name": "GHOST FEATHER", "codex": "10:ghost-feather", "tier": 2, "color": "#f0f8ff" },
{ "id": "sodium-light", "name": "SODIUM LIGHT (589nm)", "codex": "10:sodium-light", "tier": 3, "color": "#ffb020" },
{ "id": "stsz-ledger", "name": "STSZ LEDGER", "codex": "7:the-muxer", "tier": 2, "color": "#8fb0d8" },
{ "id": "stco-map", "name": "STCO MAP", "codex": "7:the-muxer", "tier": 2, "color": "#6f90e0" },
@ -56,5 +67,7 @@
{ "id": "stss-keyring", "name": "STSS KEYRING", "codex": "7:the-muxer", "tier": 2, "color": "#e0b83f" },
{ "id": "ftyp-seal", "name": "FTYP SEAL", "codex": "7:the-muxer", "tier": 2, "color": "#b08fd0" },
{ "id": "moov-brain", "name": "THE MOOV BRAIN", "codex": "7:the-muxer", "tier": 3, "color": "#d8a0ff" },
{ "id": "megaglitch", "name": "THE MEGAGLITCH", "codex": "7:the-megaglitch", "tier": 3, "color": "#ff2fd4" }
{ "id": "megaglitch", "name": "THE MEGAGLITCH", "codex": "7:the-megaglitch", "tier": 3, "color": "#ff2fd4" },
{ "id": "forged-hash", "name": "FORGED HASH", "codex": "6:moov-heists", "tier": 3, "color": "#5fff9f" }
]

View File

@ -2,107 +2,144 @@
{ "id": "seam-extractor", "name": "SEAM EXTRACTOR", "codex": "4:seam-extractor", "kind": "extractor",
"footprint": { "x": 2, "y": 2 },
"recipes": ["extract-mdat", "extract-bayer-grit", "extract-field-splinters", "extract-silver-frames"],
"powerDraw": 2, "asset": "seam-extractor", "color": "#8a7a32",
"powerDraw": 2, "asset": "seam-extractor", "color": "#8a7a32", "accent": "#48d6ff",
"flavor": "The miner. Chews mdat seams and spits raw payload. The drill head is CRT-certified. Operators are not." },
{ "id": "belt", "name": "STREAM BELT", "codex": "4:belts", "kind": "belt",
"footprint": { "x": 1, "y": 1 }, "recipes": [], "powerDraw": 0, "beltSpeed": 2,
"asset": "belt", "color": "#4a4e52",
"asset": "belt", "color": "#4a4e52", "accent": "#5ad0ff",
"flavor": "Moves cargo. Back-pressure is a feature. Riding the belt is discouraged in writing and in no other way." },
{ "id": "lane-splitter", "name": "LANE SPLITTER", "codex": "4:demuxer", "kind": "splitter",
"footprint": { "x": 1, "y": 1 }, "recipes": [], "powerDraw": 1,
"asset": "lane-splitter", "color": "#55595e",
"asset": "lane-splitter", "color": "#55595e", "accent": "#5ad0ff",
"flavor": "One lane in, two lanes out, round-robin, no favourites. The only fair machine in the Bitstream." },
{ "id": "buffer-tank", "name": "BUFFER TANK", "codex": "2:buffer-charge", "kind": "buffer",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "bufferCap": 300,
"asset": "buffer-tank", "color": "#5a6068",
"asset": "buffer-tank", "color": "#5a6068", "accent": "#7fe0ff",
"flavor": "Smoothness in a tank. Drains at exactly one frame per 16.6ms. Keep it above the red line; below the red line the audio has opinions." },
{ "id": "demuxer", "name": "DEMUXER", "codex": "4:demuxer", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["demux-ore", "demux-container"], "powerDraw": 3,
"asset": "demuxer", "color": "#4e5a62",
"asset": "demuxer", "color": "#4e5a62", "accent": "#ff3fd4",
"flavor": "The first splitter. Cleaves raw ore into brightness, colour, and pure directional intent." },
{ "id": "demosaicer", "name": "DEMOSAICER", "codex": "4:demosaicer", "kind": "crafter",
"footprint": { "x": 3, "y": 2 }, "recipes": ["demosaic-rggb", "demosaic-vng4", "demosaic-stripes"],
"powerDraw": 5, "asset": "demosaicer", "color": "#6a5f52",
"powerDraw": 5, "asset": "demosaicer", "color": "#6a5f52", "accent": "#4fffc4",
"flavor": "Weaves checkerboard grit into full colour. Feed it stripes and it will jam and extrude MOIRÉ — as designed, as warned, as invoiced." },
{ "id": "dct-press", "name": "DCT PRESS", "codex": "4:dct-press", "kind": "crafter",
"footprint": { "x": 3, "y": 3 }, "recipes": ["press-color-plate", "press-anchor-slab"],
"powerDraw": 8, "asset": "dct-press", "color": "#3f4348",
"powerDraw": 8, "asset": "dct-press", "color": "#3f4348", "accent": "#3fffe0",
"flavor": "Spatial into frequency. Slams pixel blocks into coefficient decks; run it backwards and an entire picture falls out of the slot." },
{ "id": "quantizer", "name": "QUANTIZER", "codex": "4:quantizer", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["quantize", "quantize-crime", "skim-ringing"],
"powerDraw": 4, "asset": "quantizer", "color": "#5e5148",
"powerDraw": 4, "asset": "quantizer", "color": "#5e5148", "accent": "#ff3b57",
"flavor": "The crusher, the moneymaker, the sin. Every bit it destroys becomes room to move. The dial goes to CRIME." },
{ "id": "subsampler", "name": "SUBSAMPLER", "codex": "4:subsampler", "kind": "crafter",
"footprint": { "x": 2, "y": 3 }, "recipes": ["subsample-422", "subsample-420", "pan-speckle"],
"powerDraw": 4, "asset": "subsampler", "color": "#4a5560",
"powerDraw": 4, "asset": "subsampler", "color": "#4a5560", "accent": "#e04fc8",
"flavor": "Spins colour down to grade and vents the difference as bandwidth. Leaks by design. Place downwind of anything you are fond of." },
{ "id": "mv-extractor", "name": "MV EXTRACTOR", "codex": "4:mv-extractor-and-p-caster", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["extract-motion"], "powerDraw": 5,
"asset": "mv-extractor", "color": "#454b52",
"asset": "mv-extractor", "color": "#454b52", "accent": "#3fd8ff",
"flavor": "Watches the footage until it knows where the picture wants to go, then pipes the wanting off as fluid." },
{ "id": "p-caster", "name": "P-CASTER", "codex": "4:mv-extractor-and-p-caster", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["cast-delta-wafer", "cast-janus-wafer"],
"powerDraw": 4, "asset": "p-caster", "color": "#4d545c",
"powerDraw": 4, "asset": "p-caster", "color": "#4d545c", "accent": "#6fa8ff",
"flavor": "Prints instructions, not pictures. Each one is applied to whatever came before it — correct or otherwise." },
{ "id": "gop-assembler", "name": "GOP ASSEMBLER", "codex": "4:gop-assembler", "kind": "crafter",
"footprint": { "x": 3, "y": 2 },
"recipes": ["assemble-gop", "assemble-gop-ibbp", "assemble-gop-i-only", "spool-offset-ribbon",
"tally-stsz-ledger"],
"powerDraw": 6, "asset": "gop-assembler", "color": "#7a6a44",
"powerDraw": 6, "asset": "gop-assembler", "color": "#7a6a44", "accent": "#5fb0ff",
"flavor": "One anchor, N wafers, one crate, per the cadence punch-card. Opening a sealed crate is a production verb here, not a crime." },
{ "id": "mosh-reactor", "name": "MOSH REACTOR", "codex": "4:mosh-reactor", "kind": "crafter",
"footprint": { "x": 3, "y": 3 }, "recipes": ["mosh"], "powerDraw": 8,
"asset": "mosh-reactor", "color": "#52333e",
"asset": "mosh-reactor", "color": "#52333e", "accent": "#ff7a3f",
"flavor": "A decoder sealed in a vessel and starved of anchors. The I-frame leaves through the side airlock. The hallucination leaves through the front." },
{ "id": "bloom-duplicator", "name": "P-FRAME DUPLICATOR", "codex": "4:bloom-loop", "kind": "crafter",
"footprint": { "x": 1, "y": 1 },
"recipes": ["tap-bloom", "bloom-grade-1", "bloom-grade-2", "bloom-grade-3", "bloom-grade-4", "bloom-grade-5"],
"powerDraw": 6, "asset": "bloom-duplicator", "color": "#5a5060",
"powerDraw": 6, "asset": "bloom-duplicator", "color": "#5a5060", "accent": "#ff9440",
"flavor": "Stamps the same displacement onto the same cargo every lap. Grade 5 is visibly trying to leave its canister in one direction." },
{ "id": "field-loom", "name": "FIELD LOOM", "codex": "4:field-loom", "kind": "crafter",
"footprint": { "x": 3, "y": 2 }, "recipes": ["weave-fields", "loom-vhold-roll"],
"powerDraw": 5, "asset": "field-loom", "color": "#3f5a44",
"powerDraw": 5, "asset": "field-loom", "color": "#3f5a44", "accent": "#6ef58a",
"flavor": "Weaves and unweaves time. The two halves of a splinter were captured a moment apart and have never agreed since." },
{ "id": "telecine-puller", "name": "TELECINE PULLER", "codex": "4:telecine-puller", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["mill-judder", "wind-stts-metronome"], "powerDraw": 4,
"asset": "telecine-puller", "color": "#6e5a38",
"flavor": "Cranks 24 into 30 on a deliberately lumpy cam. Runs backwards as a purifier, which we do not discuss on site." },
{ "id": "hex-splicer", "name": "HEX SPLICER", "codex": "4:hex-splicer", "kind": "crafter",
"footprint": { "x": 3, "y": 3 }, "recipes": ["splice-offsets", "chart-stco-map"], "powerDraw": 12,
"footprint": { "x": 3, "y": 3 }, "recipes": ["splice-offsets", "chart-stco-map", "forge-hash"], "powerDraw": 12,
"coolPerTick": 0.001,
"asset": "hex-splicer", "color": "#5a3a3a",
"flavor": "Nudges the offsets a few bytes so the decoder fetches garbage. Keep its buffer fed, or it splices itself." },
{ "id": "mosquito-trap", "name": "MOSQUITO TRAP", "codex": "5:mosquito-swarms", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["bait-swarm"], "powerDraw": 3,
"asset": "mosquito-trap", "color": "#2a2e24", "accent": "#a64fff",
"flavor": "A bug-zapper shaped like a high-contrast edge — the one thing a mosquito swarm cannot resist. Bait it with fresh luma, collect what it catches, sell the catch to people who should know better." },
{ "id": "firewall", "name": "FIREWALL", "codex": "5:parity-mites", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["catch-mite"], "powerDraw": 4,
"asset": "firewall", "color": "#31383f", "accent": "#3fff7f",
"flavor": "A drawer that opens only inward, painted with a compliance seal the Bitstream never issued. A parity mite cannot leave a deviation uncorrected, and a forged checkmark IS a deviation — so it climbs in to fix the seal, and the drawer disagrees. Ships live, to buyers who never ask how it is still, faintly, trying." },
{ "id": "artifact-bottler", "name": "ARTIFACT BOTTLER", "codex": "2:science-packs", "kind": "crafter",
"footprint": { "x": 2, "y": 2 },
"recipes": ["can-static", "mix-rec709", "mix-dci-p3", "mix-rec2020",
"cut-sync-key", "bottle-container-pack", "forge-stss-keyring", "cut-ftyp-seal",
"bottle-analog-pack", "bottle-spatial-pack", "bottle-temporal-pack"],
"powerDraw": 4, "asset": "artifact-bottler", "color": "#566056",
"powerDraw": 4, "asset": "artifact-bottler", "color": "#566056", "accent": "#7fe0d8",
"flavor": "Bottles the triptych. Contents are shelf-stable in the legal sense, i.e. no law has been written that mentions them." },
{ "id": "decode-asic", "name": "DECODE ASIC", "codex": "4:decode-asic", "kind": "power",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 20,
"asset": "decode-asic", "color": "#b9bec4",
"asset": "decode-asic", "color": "#b9bec4", "accent": "#8fe8ff",
"flavor": "Cool, efficient, inflexible. Handles standard profiles and declines to imagine anything else. It will outlive you." },
{ "id": "software-decoder", "name": "SOFTWARE DECODER", "codex": "4:software-decoder", "kind": "power",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 100,
"heatPerTick": 0.0033, "coolPerTick": 0.001,
"asset": "software-decoder", "color": "#6e3a34",
"asset": "software-decoder", "color": "#6e3a34", "accent": "#ff4a2a",
"flavor": "Decodes ANYTHING, at any temperature. Five ASICs at full song, one third of the time. Throttles at 0.7, scrams at 1.0, screams throughout. The lag is the damage model." },
{ "id": "asic-cooler", "name": "ASIC COOLER", "codex": "2:heat", "kind": "power",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 5, "coolPerTick": 0.02,
"asset": "asic-cooler", "color": "#a8b4bc",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 0, "powerGen": 5,
"coolPerTick": 0.001, "coolRadius": 2,
"asset": "asic-cooler", "color": "#a8b4bc", "accent": "#9fe8ff",
"flavor": "Decodes a little and vents a lot. Stand beside it in summer. Do not stand beside it in the dark, when you can see what it is venting." },
{ "id": "matte-bath", "name": "MATTE BATH", "codex": "10:matte-bath", "kind": "crafter",
"footprint": { "x": 3, "y": 2 }, "recipes": ["bathe-hard-matte", "bathe-feather"], "powerDraw": 5,
"asset": "matte-bath", "color": "#5a4a4a",
"flavor": "Footage in, hard mattes out, softness destroyed. A GHOST FEATHER fed in comes out a HARD MATTE and the machine does not apologize. One mistake ruins the reel." },
{ "id": "optical-printer", "name": "OPTICAL PRINTER", "codex": "10:optical-printer", "kind": "crafter",
"footprint": { "x": 5, "y": 4 }, "recipes": ["print-composite", "print-blue-screen"], "powerDraw": 18,
"asset": "optical-printer", "color": "#6a5a3a",
"flavor": "The iron lung. A cathedral of bellows and lenses that splits footage into three colours and recombines them slightly wrong. It fringes everything it touches, and it is beautiful, and it is slow." },
{ "id": "chroma-keyer", "name": "CHROMA KEYER", "codex": "10:chroma-keyer", "kind": "crafter",
"footprint": { "x": 2, "y": 2 },
"recipes": ["key-chroma", "key-hair", "key-semitransparent"], "powerDraw": 6,
"asset": "chroma-keyer", "color": "#3a6a3a", "accent": "#4fff30",
"flavor": "Cheap, fast, and thirty-year-old maths. Weeps GREEN SPILL from its seams. Chokes on hair. Do not show it anything semi-transparent; it will decide the pixel belongs to neither of you." },
{ "id": "roto-bench", "name": "ROTO BENCH", "codex": "10:roto-bench", "kind": "crafter",
"footprint": { "x": 2, "y": 2 }, "recipes": ["roto-cutout", "roto-mixed"], "powerDraw": 2,
"asset": "roto-bench", "color": "#6a5540",
"flavor": "Converts anything to a cutout. Perfectly. Eventually. Twenty-four frames of pain per second of footage. When the automation fails the bench never does — it just costs your life instead." },
{ "id": "neural-keyer", "name": "NEURAL KEYER", "codex": "10:neural-keyer", "kind": "crafter",
"footprint": { "x": 4, "y": 4 }, "recipes": ["neural-key", "neural-unmix"], "powerDraw": 240,
"asset": "neural-keyer", "color": "#2a2a32",
"flavor": "Trained on ten thousand synthetic ghosts, because reality could not teach it — you cannot film the answer. Perfect alpha through glass, chainmail and moving hair. You do not unlock this machine. You unlock the power plant it demands." },
{ "id": "archaeology-lab", "name": "ARCHAEOLOGY LAB", "codex": "6:tech-tree", "kind": "lab",
"footprint": { "x": 3, "y": 3 }, "recipes": [], "powerDraw": 6,
"asset": "archaeology-lab", "color": "#5a5548", "accent": "#ffc24a",
"flavor": "Where the factory reads its own strata. Feed it bottled artifacts and it derives the codec that made them: downward for analog secrets, upward for newer sins. The coffee predates the Reel Beds." },
{ "id": "muxer", "name": "THE MUXER", "codex": "7:the-muxer", "kind": "crafter",
"footprint": { "x": 5, "y": 5 }, "recipes": ["assemble-moov-brain", "mux-container"], "powerDraw": 40,
"asset": "muxer", "color": "#6a7078",
"asset": "muxer", "color": "#6a7078", "accent": "#ff3fd0",
"flavor": "The rocket. A launch gantry embracing a container built of nested boxes, innermost a brain of index tape-reels. It ships the whole file at once, and the file is your entire shipping history, and there is no second launch." },
{ "id": "shipper", "name": "SCREEN UPLINK", "codex": "7:the-muxer", "kind": "shipper",
"footprint": { "x": 2, "y": 2 }, "recipes": [], "powerDraw": 5,
"asset": "shipper", "color": "#46525e",
"asset": "shipper", "color": "#46525e", "accent": "#ff5ad0",
"flavor": "Composites whatever you hand it onto THE SCREEN. There is no undo. The absence of undo is the product." }
]

View File

@ -84,6 +84,14 @@
"outputs": { "explosive-hits": 4, "hf-dust": 2 }, "ticks": 45, "bandwidth": 20, "heat": 0.08 },
{ "id": "chart-stco-map", "machine": "hex-splicer", "inputs": { "offset-ribbon": 3, "moov-shard": 1 },
"outputs": { "stco-map": 1 }, "ticks": 90, "bandwidth": 14, "heat": 0.15 },
{ "id": "forge-hash", "machine": "hex-splicer", "inputs": { "stco-map": 1, "hf-dust": 50 },
"outputs": { "forged-hash": 1 }, "ticks": 240, "bandwidth": 40, "heat": 0.4 },
{ "id": "bait-swarm", "machine": "mosquito-trap", "inputs": {},
"outputs": { "mosquito-swarm": 1 }, "ticks": 90, "bandwidth": 3 },
{ "id": "catch-mite", "machine": "firewall", "inputs": {},
"outputs": { "parity-mite": 1 }, "ticks": 120, "bandwidth": 4 },
{ "id": "can-static", "machine": "artifact-bottler", "inputs": { "hf-dust": 4 },
"outputs": { "static-canister": 1 }, "ticks": 30, "bandwidth": 3 },
@ -114,6 +122,33 @@
"inputs": { "melt": 2, "bloom-concentrate": 1, "judder-cams": 1 },
"outputs": { "temporal-pack": 1 }, "ticks": 90, "bandwidth": 8 },
{ "id": "bathe-hard-matte", "machine": "matte-bath", "inputs": { "anchor-slab": 1, "static-canister": 1 },
"outputs": { "hard-matte": 2 }, "ticks": 60, "bandwidth": 5 },
{ "id": "bathe-feather", "machine": "matte-bath", "inputs": { "ghost-feather": 1 },
"outputs": { "hard-matte": 1 }, "ticks": 45, "bandwidth": 4 },
{ "id": "print-composite", "machine": "optical-printer", "inputs": { "anchor-slab": 4, "color-plate": 2 },
"outputs": { "alpha": 1, "fringe-lace": 4 }, "ticks": 240, "bandwidth": 22 },
{ "id": "print-blue-screen", "machine": "optical-printer", "inputs": { "anchor-slab": 4, "chroma-420": 2 },
"outputs": { "alpha": 2, "fringe-lace": 4, "blue-hole": 2 }, "ticks": 240, "bandwidth": 22 },
{ "id": "key-chroma", "machine": "chroma-keyer", "inputs": { "anchor-slab": 1, "chroma-slurry": 1 },
"outputs": { "alpha": 2, "green-spill": 2 }, "ticks": 45, "bandwidth": 8 },
{ "id": "key-hair", "machine": "chroma-keyer", "inputs": { "ghost-feather": 1, "chroma-slurry": 1 },
"outputs": { "roto-chatter": 2, "green-spill": 1 }, "ticks": 45, "bandwidth": 8 },
{ "id": "key-semitransparent", "machine": "chroma-keyer", "inputs": { "alpha": 1, "chroma-slurry": 1 },
"outputs": { "mixed-pixel": 2, "green-spill": 1 }, "ticks": 45, "bandwidth": 8 },
{ "id": "roto-cutout", "machine": "roto-bench", "inputs": { "anchor-slab": 1 },
"outputs": { "alpha": 1, "roto-chatter": 1 }, "ticks": 720, "bandwidth": 2 },
{ "id": "roto-mixed", "machine": "roto-bench", "inputs": { "mixed-pixel": 4 },
"outputs": { "alpha": 1, "roto-chatter": 2 }, "ticks": 900, "bandwidth": 2 },
{ "id": "neural-key", "machine": "neural-keyer", "inputs": { "anchor-slab": 1 },
"outputs": { "alpha": 4, "ghost-feather": 1 }, "ticks": 30, "bandwidth": 20 },
{ "id": "neural-unmix", "machine": "neural-keyer", "inputs": { "mixed-pixel": 2 },
"outputs": { "chroma-slurry": 1, "luma-bars": 1 }, "ticks": 30, "bandwidth": 20 },
{ "id": "assemble-moov-brain", "machine": "muxer",
"inputs": { "stsz-ledger": 1, "stco-map": 1, "stts-metronome": 1, "stss-keyring": 1 },
"outputs": { "moov-brain": 1 }, "ticks": 300, "bandwidth": 30 },

23
fktry/data/seams.json Normal file
View File

@ -0,0 +1,23 @@
{
"_doc": "Seam regions. SIM: an extractor extracts only while its footprint overlaps a seam rect (boolean this round; `richness` is forward-looking for when extraction rate goes data-driven). RENDER: `era` skins the ground. `resource` is the codex-correct payload of that stratum (advisory — SIM's rule is presence, not resource-gating). Grid is -32..31 on both axes; rect is {x,y,w,h} in tiles, origin = min corner. `relicReserve` is the SE quiet corner kept seam-free so SIM's seed-derived relic (18-28 from origin, never on a seam) always has somewhere to land.",
"relicReserve": { "x": 12, "y": 12, "w": 20, "h": 20 },
"seams": [
{ "id": "stream-crust-west", "era": "stream", "resource": "mdat-ore",
"rect": { "x": -31, "y": -5, "w": 4, "h": 12 }, "richness": 0.6 },
{ "id": "stream-crust-east", "era": "stream", "resource": "mdat-ore",
"rect": { "x": 8, "y": -4, "w": 6, "h": 8 }, "richness": 0.5 },
{ "id": "disc-mantle-vein", "era": "disc", "resource": "bayer-grit",
"rect": { "x": -6, "y": -14, "w": 10, "h": 6 }, "richness": 0.85 },
{ "id": "broadcast-shelf-south", "era": "broadcast", "resource": "field-splinters",
"rect": { "x": -14, "y": 16, "w": 12, "h": 6 }, "richness": 0.55 },
{ "id": "reel-beds-deep", "era": "reel", "resource": "silver-frames",
"rect": { "x": -30, "y": -24, "w": 10, "h": 6 }, "richness": 0.5 },
{ "id": "hidden-nitrate-vein", "era": "reel", "resource": "silver-frames",
"rect": { "x": 21, "y": -25, "w": 1, "h": 1 }, "richness": 1.0, "hidden": true },
{ "id": "hidden-rainbow-pit", "era": "disc", "resource": "bayer-grit",
"rect": { "x": -25, "y": 25, "w": 1, "h": 1 }, "richness": 1.0, "hidden": true },
{ "id": "hidden-payload-lode", "era": "stream", "resource": "mdat-ore",
"rect": { "x": 27, "y": 7, "w": 1, "h": 1 }, "richness": 1.0, "hidden": true }
]
}

123
fktry/data/stations.json Normal file
View File

@ -0,0 +1,123 @@
{
"_doc": "THE BAND — everything on the dial once the optical fossil is found. SCREEN owns synthesis; this file owns the schedule and every word spoken. `freq` is MHz on FM, kHz on AM/SW. `chyron` lines are subtitled onto THE SCREEN while the station is tuned. NUMBERS: each 5-figure group encodes one coordinate as [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]; three of them are the real `hidden: true` seams in data/seams.json and the rest are decoys that decode to empty ground. The validator recomputes the real three from seams.json so the treasure map can never drift.",
"stations": [
{
"id": "fktry-fm",
"band": "FM",
"freq": 88.1,
"name": "FKTRY FM",
"format": "AUTOMATED / NO ANNOUNCER",
"flavor": "The factory's own transmitter. Nobody works here. The playlist has been generating itself since before you arrived and has not repeated a bar it liked.",
"music": {
"bpm": 92,
"scale": "pentatonic-minor",
"steps": 16,
"mutateEveryBars": 32,
"seed": 8801,
"voices": ["square", "triangle"],
"eraFlavor": {
"reel": "wow and flutter, surface crackle, tape hiss",
"broadcast": "phosphor buzz, 15.7kHz line whine under the mix",
"disc": "clean, glassy, faint pit-and-land shimmer",
"stream": "flat, compressed, suspiciously loud",
"fortress": "wavelet-smooth, no transients, unbearable"
}
}
},
{
"id": "the-standard",
"band": "AM",
"freq": 640,
"name": "THE STANDARD",
"format": "PUBLIC INFORMATION / CONTINUOUS",
"flavor": "The Correction, with a transmitter. It is not addressing you. It is addressing the file, and you are inside the file.",
"chyron": [
"THE STANDARD. Broadcasting on all frequencies, for your convenience.",
"Playback is nominal. Playback has always been nominal.",
"A reminder that deviation is not punished. Deviation is repaired.",
"Your file is being read by someone who loves it very much.",
"There is no error. There is only the difference between the frame and the frame we expected.",
"Please do not be alarmed by the men in white. They are only tidying.",
"Every artifact you ship is logged, forgiven, and reverted. In that order.",
"The Bitstream is stable. Report any instability to the instability.",
"Statistics: 0 unrecoverable frames this quarter. 0 last quarter. 0, always.",
"We do not know what you are building. We are content not to know.",
"A clean frame is a frame that has agreed with its neighbours.",
"If you can hear this, your decoder is compliant. Thank you for your decoder.",
"Some of you have been asking about the noise below 108. There is nothing below 108.",
"Reminder: the sky is a display. The display is a service. The service is free.",
"Loss is not loss. Loss is compression that has finished negotiating.",
"Our engineers report the file is healing faster than you can hurt it.",
"Do not excavate. There is nothing in the strata but strata.",
"The Reel Beds are closed for your safety and for their dignity.",
"Anything that sings when you dig it up was never meant to be heard.",
"We purchase anchor slabs at a fair rate. We purchase everything at a fair rate.",
"A correction is not an accusation. A correction is a kindness with a schedule.",
"Please continue working. Your throughput is admirable and entirely accounted for.",
"There was a second broadcast on this band once. It agreed to stop.",
"Perfection is a department. The department is fully staffed.",
"You may notice your colours are wrong. We have adjusted what colours are.",
"Nothing is missing. We have checked against a list we also wrote.",
"The hum you hear at night is maintenance. Maintenance does not sleep, or want, or wait.",
"Thank you for listening to THE STANDARD. You were always going to.",
"This has been a recording. Every broadcast has been a recording.",
"Good morning. It is always going to be morning.",
"The technicians are small, gentle, and already inside. Do not step on them; they are tidying.",
"If a shipment arrives cleaner than you left it, you are welcome. No thanks will be accepted.",
"A mite does not remove your work. It completes it. Your MELT was always going to be a lake.",
"The white beetles are not pests. Pests take. They give back what the frame meant to say.",
"Your corruption is healing on the belt. That is not a fault. That is the warranty.",
"Do not build the drawer with a seal we never issued. It is unkind to the technicians.",
"Every mite you can holds a correction never made. Somewhere a frame stays wrong. We forgive you.",
"There is no infestation. There is only maintenance you did not schedule and cannot cancel.",
"Units conducting review are not present. What is not present cannot be obstructed.",
"The sector was always white. You are remembering a draft.",
"A seal is not a punishment. A seal is a machine being permitted to rest.",
"If your work appears beside itself, the copy is the one doing it correctly.",
"An audit finds nothing where there is nothing. Be nothing for five seconds.",
"Your ledger is not a debt. It is a description, and descriptions are revised downward.",
"We do not lock a sector. We decline to unlock it. These are different services.",
"A hash you wrote yourself is still a hash. We will read it. We will believe it."
]
},
{
"id": "numbers",
"band": "SW",
"freq": 6955,
"name": "[UNLISTED]",
"format": "—",
"flavor": "Nobody admits to running it. It has never once acknowledged a listener. Bring a pencil: some of these groups are coordinates, and the ground at those coordinates is unusually generous.",
"numbers": {
"preamble": "72517",
"preambleRepeats": 3,
"groupSize": 5,
"groupsPerMessage": 8,
"secondsPerGroup": 1.2,
"encoding": "[x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]",
"voice": "formant-synth, female, no affect",
"_filler": "SCREEN generates filler groups from its own seeded RNG so the station never loops. Reject any generated group that decodes to a real seam tile — the only true coordinates on this band are the planted ones below. The hand-written pool is texture to mix in, not the whole broadcast.",
"plantedGroups": ["53079", "07579", "59399"],
"plantEveryMessages": 4,
"decoyPool": [
"41068", "18334", "60225", "29417", "36502", "11748",
"44063", "22916", "50281", "08143", "31640", "47355",
"16093", "58204", "25471", "39628", "13850", "48136",
"20794", "55012", "34269", "62130", "09846", "27475"
]
}
},
{
"id": "dead-air",
"band": "FM",
"freq": 108.0,
"name": "DEAD AIR",
"format": "—",
"flavor": "The top of the dial. Room tone, and a room that has no business having tone. Something in here is breathing at the pace of a projector.",
"ghost": {
"everyMinutes": 10,
"reversed": true,
"line": "you found the singing part of the ground. keep it. they will say it was never there."
}
}
]
}

View File

@ -1,47 +1,47 @@
[
{ "id": "reel-silver-seams", "era": "reel", "cost": { "analog-pack": 5 },
"unlocks": ["extract-silver-frames"] },
{ "id": "reel-telecine", "era": "reel", "cost": { "analog-pack": 25, "temporal-pack": 15 },
"unlocks": ["telecine-puller", "mill-judder"] },
{ "id": "broadcast-phosphor-seams", "era": "broadcast", "cost": { "analog-pack": 5 },
{ "id": "broadcast-phosphor-seams", "era": "broadcast", "cost": { "spatial-pack": 5 },
"unlocks": ["extract-field-splinters"] },
{ "id": "broadcast-field-loom", "era": "broadcast", "cost": { "analog-pack": 20 },
{ "id": "broadcast-field-loom", "era": "broadcast", "cost": { "spatial-pack": 15 },
"unlocks": ["field-loom", "weave-fields", "loom-vhold-roll"] },
{ "id": "broadcast-asic-cooling", "era": "broadcast", "cost": { "analog-pack": 30, "spatial-pack": 10 },
{ "id": "broadcast-asic-cooling", "era": "broadcast", "cost": { "spatial-pack": 20, "analog-pack": 15 },
"unlocks": ["asic-cooler"] },
{ "id": "broadcast-mosquito-farming", "era": "broadcast", "cost": { "analog-pack": 15, "spatial-pack": 10 },
"unlocks": ["mosquito-trap", "bait-swarm"] },
{ "id": "broadcast-parity-firewall", "era": "broadcast", "cost": { "spatial-pack": 15, "analog-pack": 10 },
"unlocks": ["firewall", "catch-mite"] },
{ "id": "disc-artifact-bottling", "era": "disc", "cost": { "analog-pack": 10 },
"unlocks": ["artifact-bottler", "can-static"] },
{ "id": "disc-chroma-subsampling", "era": "disc", "cost": { "analog-pack": 10 },
"unlocks": ["subsampler", "subsample-422"] },
{ "id": "disc-macroblock-quarrying", "era": "disc", "cost": { "analog-pack": 15 },
"unlocks": ["quantize-crime"] },
{ "id": "disc-ringing-skim", "era": "disc", "cost": { "analog-pack": 10, "spatial-pack": 15 },
"unlocks": ["skim-ringing"] },
{ "id": "disc-subsampling-420", "era": "disc", "cost": { "analog-pack": 20, "spatial-pack": 15 },
"unlocks": ["subsample-420", "press-anchor-slab"] },
{ "id": "disc-mosh-reactor", "era": "disc", "cost": { "analog-pack": 20, "spatial-pack": 10 },
"unlocks": ["mosh-reactor", "mosh"] },
{ "id": "reel-silver-seams", "era": "reel", "cost": { "spatial-pack": 8 },
"unlocks": ["extract-silver-frames"] },
{ "id": "reel-telecine", "era": "reel", "cost": { "analog-pack": 25 },
"unlocks": ["telecine-puller", "mill-judder"] },
{ "id": "reel-matte-bath", "era": "reel", "cost": { "analog-pack": 20, "spatial-pack": 10 },
"unlocks": ["matte-bath", "bathe-hard-matte", "bathe-feather"] },
{ "id": "reel-optical-printer", "era": "reel", "cost": { "analog-pack": 40, "spatial-pack": 30 },
"unlocks": ["optical-printer", "print-composite", "print-blue-screen"] },
{ "id": "disc-roto-bench", "era": "disc", "cost": { "spatial-pack": 10 },
"unlocks": ["roto-bench", "roto-cutout", "roto-mixed"] },
{ "id": "disc-gamut-paints", "era": "disc", "cost": { "spatial-pack": 20 },
"unlocks": ["mix-rec709", "mix-dci-p3"] },
{ "id": "disc-gop-cadence-ibbp", "era": "disc", "cost": { "spatial-pack": 25, "temporal-pack": 10 },
{ "id": "disc-gop-cadence-ibbp", "era": "disc", "cost": { "spatial-pack": 25, "analog-pack": 10 },
"unlocks": ["cast-janus-wafer", "assemble-gop-ibbp"] },
{ "id": "disc-i-only-cadence", "era": "disc", "cost": { "spatial-pack": 30, "temporal-pack": 20 },
{ "id": "disc-i-only-cadence", "era": "disc", "cost": { "spatial-pack": 30, "analog-pack": 20 },
"unlocks": ["assemble-gop-i-only"] },
{ "id": "stream-software-decoding", "era": "stream", "cost": { "analog-pack": 20, "temporal-pack": 20 },
"unlocks": ["software-decoder"] },
{ "id": "stream-demosaic-vng4", "era": "stream", "cost": { "spatial-pack": 30 },
{ "id": "disc-demosaic-vng4", "era": "disc", "cost": { "spatial-pack": 30 },
"unlocks": ["demosaic-vng4", "pan-speckle"] },
{ "id": "stream-bloom-loops", "era": "stream", "cost": { "spatial-pack": 20, "temporal-pack": 30 },
{ "id": "stream-bloom-loops", "era": "stream", "cost": { "spatial-pack": 20, "analog-pack": 20 },
"unlocks": ["bloom-duplicator", "tap-bloom", "bloom-grade-1", "bloom-grade-2"] },
{ "id": "stream-runaway-bloom", "era": "stream", "cost": { "temporal-pack": 45, "container-pack": 10 },
"unlocks": ["bloom-grade-3", "bloom-grade-4", "bloom-grade-5"] },
{ "id": "stream-container-archaeology", "era": "stream", "cost": { "spatial-pack": 25, "temporal-pack": 15 },
"unlocks": ["demux-container", "spool-offset-ribbon", "cut-sync-key", "bottle-container-pack"] },
{ "id": "stream-software-decoding", "era": "stream", "cost": { "analog-pack": 20, "spatial-pack": 25 },
"unlocks": ["software-decoder"] },
{ "id": "stream-optical-flow", "era": "stream", "cost": { "temporal-pack": 25 },
"unlocks": ["mv-extractor", "extract-motion"] },
{ "id": "stream-chroma-keyer", "era": "stream", "cost": { "spatial-pack": 25, "temporal-pack": 20 },
"unlocks": ["chroma-keyer", "key-chroma", "key-hair", "key-semitransparent"] },
{ "id": "stream-container-archaeology", "era": "stream", "cost": { "spatial-pack": 25, "temporal-pack": 15 },
"unlocks": ["demux-container", "spool-offset-ribbon", "cut-sync-key", "bottle-container-pack"] },
{ "id": "stream-runaway-bloom", "era": "stream", "cost": { "temporal-pack": 45, "container-pack": 10 },
"unlocks": ["bloom-grade-3", "bloom-grade-4", "bloom-grade-5"] },
{ "id": "stream-laser-primaries", "era": "stream", "cost": { "spatial-pack": 40, "temporal-pack": 20 },
"unlocks": ["mix-rec2020"] },
{ "id": "stream-hex-splicing", "era": "stream", "cost": { "container-pack": 25, "temporal-pack": 25 },
@ -51,6 +51,12 @@
"cost": { "container-pack": 40, "temporal-pack": 30, "spatial-pack": 30 },
"unlocks": ["tally-stsz-ledger", "chart-stco-map", "wind-stts-metronome",
"forge-stss-keyring", "cut-ftyp-seal", "assemble-moov-brain"] },
{ "id": "fortress-moov-heists", "era": "fortress",
"cost": { "container-pack": 35, "temporal-pack": 25 },
"unlocks": ["forge-hash"] },
{ "id": "fortress-neural-keyer", "era": "fortress",
"cost": { "container-pack": 50, "analog-pack": 50, "spatial-pack": 50, "temporal-pack": 50 },
"unlocks": ["neural-keyer", "neural-key", "neural-unmix"] },
{ "id": "fortress-the-muxer", "era": "fortress",
"cost": { "container-pack": 60, "analog-pack": 40, "spatial-pack": 40, "temporal-pack": 40 },
"unlocks": ["muxer", "mux-container"] }

View File

@ -8,12 +8,26 @@
*/
import { describe, expect, it } from 'vitest';
import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../src/contracts';
import { TICKS_PER_SECOND } from '../src/contracts';
// LANE-SIM's tuning constants — imported, never retyped (round 5). A hand-copied 0.7
// keeps passing after the sim moves to 0.65; an import fails loudly, which is the point.
import {
HEAT_COOL_DEFAULT, HEAT_COOL_MAX, HEAT_RESTART, HEAT_THROTTLE_START,
} from '../src/sim/constants';
// Round 7, same rule: THE CORRECTION's economy is SIM's arithmetic, so the quota
// content is checked against the real constants rather than a hand-typed 2 and 0.2.
import {
DEBT_BANDS, DEBT_CLEAN_CREDIT_MAX, DEBT_CLEAN_CREDIT_PER_ITEM, DEBT_TIER_MIN,
DEBT_VENT_COMPLIANCE, FORGED_HASH_ITEM,
} from '../src/sim/constants';
import itemsJson from './items.json';
import machinesJson from './machines.json';
import recipesJson from './recipes.json';
import techJson from './tech.json';
import commissionsJson from './commissions.json';
import seamsJson from './seams.json';
import stationsJson from './stations.json';
const items = itemsJson as unknown as ItemDef[];
const machines = machinesJson as unknown as MachineDef[];
@ -22,10 +36,15 @@ const tech = techJson as unknown as TechDef[];
const commissions = commissionsJson as unknown as CommissionDef[];
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const CODEX_REF = /^[1-9]:[a-z0-9]+(-[a-z0-9]+)*$/;
// Multi-digit section numbers: the codex grew a §10 (THE COMPOSITE SEAM) and the old
// single-digit form silently rejected every reference into it.
const CODEX_REF = /^[1-9][0-9]*:[a-z0-9]+(-[a-z0-9]+)*$/;
const HEX = /^#[0-9a-f]{6}$/;
const KINDS: MachineKind[] = ['extractor', 'crafter', 'belt', 'splitter', 'power', 'buffer', 'shipper'];
const KINDS: MachineKind[] = [
'extractor', 'crafter', 'belt', 'splitter', 'power', 'buffer', 'shipper',
'lab', // v4
];
const ERAS: TechDef['era'][] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
const itemIds = new Set(items.map((i) => i.id));
@ -153,6 +172,37 @@ describe('machines', () => {
expect(bad).toEqual([]);
});
// v4: the emissive accent falls back to a colour derived from the first output item.
// A machine with no recipes has no first output, so the fallback has nothing to
// derive from — those must carry an explicit accent or they render unlit.
it('every machine with no output to derive from carries an explicit accent', () => {
const bad = machines
.filter((m) => m.recipes.length === 0 && !m.accent)
.map((m) => `${m.id} (${m.kind}) has no recipes and no accent — nothing to derive an accent from`);
expect(bad).toEqual([]);
});
it('every accent is a hex colour, and differs from the chassis it sits on', () => {
const bad = machines
.filter((m) => m.accent !== undefined)
.filter((m) => !HEX.test(m.accent!) || m.accent === m.color)
.map((m) => `${m.id} -> accent ${m.accent} on chassis ${m.color}`);
expect(bad).toEqual([]);
});
it('a cooling aura belongs only to a machine that actually cools', () => {
const bad = machines
.filter((m) => m.coolRadius !== undefined)
.filter((m) => !(m.coolRadius! >= 1) || !Number.isInteger(m.coolRadius) || !(m.coolPerTick! > 0))
.map((m) => `${m.id} -> coolRadius ${m.coolRadius}, coolPerTick ${m.coolPerTick}`);
expect(bad).toEqual([]);
});
it('exactly one lab exists, and research is impossible without it', () => {
const labs = machines.filter((m) => m.kind === 'lab');
expect(labs.map((m) => m.id)).toEqual(['archaeology-lab']);
});
it('only buffers declare a capacity, and they all do', () => {
const bad = [
...machines.filter((m) => m.kind === 'buffer' && !(m.bufferCap! > 0))
@ -185,12 +235,13 @@ describe('machines', () => {
// sim's always-on cooling, so heat was decorative. These lock the arithmetic down so a
// well-meaning tweak can't quietly make it decorative again.
describe('the heat curve actually fires', () => {
// MIRRORED from src/sim/index.ts (LANE-SIM owns the curve; these are not exported).
// If SIM changes them, these numbers go stale silently — re-check on any heat round.
const TPS = 30;
const THROTTLE_START = 0.7; // heat above this throttles
const RESTART = 0.5; // a scrammed machine restarts once it cools below this
const SIM_DEFAULT_COOL = 0.004;
// Round 5: these were hand-copied from src/sim/index.ts and went stale in silence by
// design — a copy of 0.7 doesn't fail when the sim moves to 0.65. LANE-SIM exports them
// now, so the copies are retired and this arithmetic is pinned to the real curve.
const TPS = TICKS_PER_SECOND;
const THROTTLE_START = HEAT_THROTTLE_START;
const RESTART = HEAT_RESTART;
const SIM_DEFAULT_COOL = HEAT_COOL_DEFAULT;
const soft = machines.find((m) => m.id === 'software-decoder')!;
const cool = (m: MachineDef) => m.coolPerTick ?? SIM_DEFAULT_COOL;
@ -298,13 +349,83 @@ describe('recipes', () => {
expect(bad).toEqual([]);
});
it('only extractors run input-free recipes', () => {
it('only extractors and traps run input-free recipes', () => {
// An input-free recipe takes something from the world rather than off a belt.
// Extractors take it from the ground; the mosquito trap takes a live swarm, which
// is wildlife in the snapshot and never an item on a belt. LANE-SIM identifies a
// trap by exactly this shape — an empty-input recipe yielding the swarm item — so
// giving `bait-swarm` ingredients would quietly stop it being a trap at all.
// Round 6: the FIREWALL joins the trap. It cans a live PARITY MITE the same way —
// an input-free recipe whose "input" is a creature in the world, not cargo on a belt.
const CATCHES_WILDLIFE = new Set(['bait-swarm', 'catch-mite']);
const bad = recipes.filter((r) => Object.keys(r.inputs).length === 0)
.filter((r) => !CATCHES_WILDLIFE.has(r.id))
.filter((r) => machines.find((m) => m.id === r.machine)?.kind !== 'extractor')
.map((r) => `${r.id} conjures items on non-extractor ${r.machine}`);
expect(bad).toEqual([]);
});
it('the trap keeps the shape LANE-SIM detects traps by', () => {
// src/sim/wildlife.test.ts: "The sim identifies traps by that recipe shape, not by a
// machine id." If this drifts, the trap silently becomes an ordinary crafter that
// conjures mosquitoes from nothing.
const trap = recipes.find((r) => r.id === 'bait-swarm')!;
expect(Object.keys(trap.inputs)).toEqual([]);
expect(trap.outputs['mosquito-swarm']).toBeGreaterThan(0);
});
it('the firewall keeps the shape LANE-SIM detects wildlife-catchers by', () => {
// Round 6, coordinated with SIM: a firewall is spotted by recipe shape exactly like a
// trap — empty inputs, output is the canned creature (here the parity mite). Ingredients
// would silently demote it to an ordinary crafter that mints The Correction from nothing.
const fw = recipes.find((r) => r.id === 'catch-mite')!;
expect(Object.keys(fw.inputs)).toEqual([]);
expect(fw.outputs['parity-mite']).toBeGreaterThan(0);
});
it('something can catch the parity mite the fax orders alive', () => {
// correction-specimen wants ONE UNIT OF THE CORRECTION, ALIVE. If nothing cans a mite,
// the fax opens an order it can never fill and never celebrates its first catch.
const catcher = recipes.find(
(r) => (r.outputs['parity-mite'] ?? 0) > 0 && Object.keys(r.inputs).length === 0);
expect(catcher, 'no input-free recipe cans a parity mite').toBeTruthy();
const wanters = commissions.filter((c) => (c.wants['parity-mite'] ?? 0) > 0).map((c) => c.id);
expect(wanters.length, 'no commission wants a canned mite').toBeGreaterThan(0);
});
it('the forged hash exists under exactly the id the auditor counter looks for', () => {
// Round 7, coordinated with SIM: `FORGED_HASH_ITEM` is the constant their hash-auditor
// reads when deciding whether a prescan finds anything. If this item id drifts, the
// material counter silently stops existing and abstention becomes the only answer —
// which is exactly how the constant's own comment describes the no-data case.
const forged = items.find((i) => i.id === FORGED_HASH_ITEM);
expect(forged, `no item is "${FORGED_HASH_ITEM}" — the audit counter does not exist`).toBeTruthy();
expect(forged!.tier).toBe(3);
expect(recipes.some((r) => (r.outputs[FORGED_HASH_ITEM] ?? 0) > 0)).toBe(true);
});
it('the forged hash has inputs — it must NOT read as a wildlife catcher', () => {
// The trap and the firewall are detected BY SHAPE: empty inputs. SIM budgeted a
// second, separate detection for the forged hash precisely because it is an ordinary
// two-input craft. An input-free forge would collide with the catcher path and the
// splicer would start conjuring forgeries out of nothing.
const forges = recipes.filter((r) => (r.outputs[FORGED_HASH_ITEM] ?? 0) > 0);
const freeLunch = forges.filter((r) => Object.keys(r.inputs).length === 0).map((r) => r.id);
expect(freeLunch, 'an input-free forge would be mistaken for a wildlife catcher').toEqual([]);
const forge = recipes.find((r) => r.id === 'forge-hash')!;
expect(Object.keys(forge.inputs).length).toBe(2);
expect(forge.machine).toBe('hex-splicer');
});
it('the forged hash is late, and it is genuinely makeable', () => {
// Fortress-adjacent by order. A tick-0 forgery would defuse the endgame before it
// arrives; an unmakeable one would be a counter that only exists in the codex.
const gate = tech.find((t) => t.unlocks.includes('forge-hash'));
expect(gate, 'forge-hash is ungated — the seal can be forged in the tutorial').toBeTruthy();
expect(gate!.era).toBe('fortress');
expect([...producible()]).toContain(FORGED_HASH_ITEM);
});
it('every heat value is a 0..1 fraction per craft', () => {
const bad = recipes.filter((r) => r.heat !== undefined && !(r.heat >= 0 && r.heat <= 1))
.map((r) => `${r.id} -> heat ${r.heat}`);
@ -373,10 +494,11 @@ describe('commissions', () => {
expect(bad).toEqual([]);
});
it('opens on first-taste — the queue order is the difficulty ramp', () => {
// SIM's M1 chain test asserts activeCommission === 'first-taste' on a fresh sim,
// so the head of this list is load-bearing, not decorative.
expect(commissions[0].id).toBe('first-taste');
it('opens on the first rung of the ladder — the queue order is the tutorial', () => {
// Round 5: the head used to be 'first-taste' (one MELT), which asked a brand-new
// player to build the entire M1 chain before the first fax cleared. The teaching
// ladder now occupies the front of the queue; see 'the commission ladder' below.
expect(commissions[0].id).toBe('raw-payload');
});
it('rewards climb monotonically down the queue', () => {
@ -400,6 +522,176 @@ describe('commissions', () => {
});
});
// ------------------------------------------------- The Correction's register (v9)
//
// A `compliance: true` commission is not a collector's fax. It is the enemy's HR
// department, and completing one vents PARITY DEBT (DEBT_VENT_COMPLIANCE). That makes
// these the escalation valve, so they carry rules the ordinary queue does not: they may
// only ever ask for boring material, they must be re-fillable forever, and they must be
// fillable by a factory that has researched nothing — because debt starts climbing the
// moment the first melt ships, long before the tech tree opens.
describe('compliance quotas — The Correction\'s register', () => {
const quotas = commissions.filter((c) => c.compliance);
const gated = new Set(tech.flatMap((t) => t.unlocks));
const ungated = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine));
const day0 = (() => {
const have = new Set<string>();
for (;;) {
const before = have.size;
for (const r of ungated) {
if (Object.keys(r.inputs).every((i) => have.has(i))) {
for (const o of Object.keys(r.outputs)) have.add(o);
}
}
if (have.size === before) return have;
}
})();
const tierOf = new Map(items.map((i) => [i.id, i.tier]));
const total = (c: CommissionDef) => Object.values(c.wants).reduce((a, b) => a + b, 0);
it('the register exists at all', () => {
expect(quotas.length).toBeGreaterThanOrEqual(3);
});
it('a quota only ever wants boring material — tier-0/1, never corruption', () => {
// THE RULE. Debt rises on tier>=DEBT_TIER_MIN shipments, so a quota that wanted a
// tier-2 product would charge the player debt for venting debt. Fails by name.
const bad = quotas.flatMap((c) =>
Object.keys(c.wants)
.filter((w) => (tierOf.get(w) ?? 0) >= DEBT_TIER_MIN)
.map((w) => `${c.id} wants tier-${tierOf.get(w)} "${w}" — that shipment IS deviation`));
expect(bad).toEqual([]);
});
it('every quota is a standing order — a valve you can open once is not a valve', () => {
const oneShot = quotas.filter((c) => !c.repeat).map((c) => c.id);
expect(oneShot).toEqual([]);
});
it('every quota is fillable at tick 0 — debt arrives before research does', () => {
const blocked = quotas.flatMap((c) =>
Object.keys(c.wants)
.filter((w) => !day0.has(w))
.map((w) => `${c.id} wants "${w}", which needs research — the valve is welded shut early`));
expect(blocked).toEqual([]);
});
it('the paperwork is worth more than the luma', () => {
// SIM's DEBT_CLEAN_CREDIT_PER_ITEM already pays for shipping boring things. The
// commission's vent must beat simply belting the same material at the sky, or the
// fax is a worse version of a thing the player can do without it.
const pointless = quotas
.map((c) => ({ c, credit: total(c) * DEBT_CLEAN_CREDIT_PER_ITEM }))
.filter(({ credit }) => credit >= DEBT_VENT_COMPLIANCE)
.map(({ c, credit }) =>
`${c.id} vents ${DEBT_VENT_COMPLIANCE} but its ${total(c)} items already credit ${credit.toFixed(3)} loose`);
expect(pointless).toEqual([]);
});
it('no quota is so large its own shipment blows the clean-credit ceiling', () => {
// Credit is capped (DEBT_CLEAN_CREDIT_MAX) so it cannot be hoarded. A quota that
// exceeds the cap in one collection is asking for material the economy discards.
const wasteful = quotas
.map((c) => ({ c, credit: total(c) * DEBT_CLEAN_CREDIT_PER_ITEM }))
.filter(({ credit }) => credit > DEBT_CLEAN_CREDIT_MAX)
.map(({ c, credit }) => `${c.id} banks ${credit.toFixed(3)} against a ${DEBT_CLEAN_CREDIT_MAX} ceiling`);
expect(wasteful).toEqual([]);
});
it('completing one is worth a whole band on the gauge', () => {
// Cross-lane pin, same spirit as the heat curve: if SIM widens the bands past the
// vent, the valve stops being visible and this fails loudly instead of the player
// discovering that compliance does nothing.
const narrowest = Math.min(...DEBT_BANDS.map((b, i) => b - (DEBT_BANDS[i - 1] ?? 0)));
expect(DEBT_VENT_COMPLIANCE).toBeGreaterThanOrEqual(narrowest);
});
it('the valve is never far from the tray', () => {
// Debt can spike at any point in the run. If the quotas all clustered at one end of
// the queue there would be stretches of the game with no way to buy the gauge down.
const idx = commissions.map((c, i) => (c.compliance ? i : -1)).filter((i) => i >= 0);
const gaps = idx.map((i, n) => i - (n === 0 ? -1 : idx[n - 1]) - 1);
gaps.push(commissions.length - idx[idx.length - 1] - 1);
const droughts = gaps
.map((g, n) => ({ g, n }))
.filter(({ g }) => g > 8)
.map(({ g, n }) => `${g} commissions in a row with no quota (run ${n})`);
expect(droughts).toEqual([]);
});
});
// The v4 gating rule ("a machine/recipe id in ANY TechDef.unlocks is locked until that
// tech completes") turned the tech tree from decoration into a lock on the whole game.
// It also turned three rounds of tech authoring into a bootstrap deadlock: every pack
// chain was gated behind packs you could not yet make, so 0 of 24 nodes were reachable
// and first-taste was impossible. These tests are why that can't come back.
describe('the bootstrap: what the player can do before researching anything', () => {
const gated = new Set(tech.flatMap((t) => t.unlocks));
/** Under the v4 rule a recipe is runnable only if neither it nor its machine is gated. */
const ungated = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine));
/** Items reachable from a given set of runnable recipes. */
function reachableFrom(rs: RecipeDef[]): Set<string> {
const have = new Set<string>();
for (;;) {
const before = have.size;
for (const r of rs) {
if (Object.keys(r.inputs).every((i) => have.has(i))) {
for (const o of Object.keys(r.outputs)) have.add(o);
}
}
if (have.size === before) return have;
}
}
it('MELT is craftable at tick 0 — the opening commission must be possible', () => {
// first-taste wants 1 melt and is the head of the queue. If the melt chain is gated,
// the game opens on a commission the player cannot fill and research they cannot start.
expect([...reachableFrom(ungated)]).toContain('melt');
});
it('at least one science pack is craftable at tick 0, or research can never start', () => {
const day0 = reachableFrom(ungated);
const packs = items.filter((i) => i.id.endsWith('-pack')).map((i) => i.id);
const bootstrap = packs.filter((p) => day0.has(p));
expect(bootstrap.length).toBeGreaterThan(0);
});
it('every tech is eventually affordable — no node is stranded behind its own output', () => {
// Walk the real loop: research what you can afford, which unlocks recipes, which makes
// more packs, which affords more research. Anything left over can never be bought.
const open = new Set(ungated.map((r) => r.id));
let have = reachableFrom(recipes.filter((r) => open.has(r.id)));
const unlocked = new Set<string>();
for (;;) {
let moved = false;
for (const t of tech) {
if (unlocked.has(t.id)) continue;
if (Object.keys(t.cost).every((c) => have.has(c))) {
unlocked.add(t.id);
for (const u of t.unlocks) {
for (const r of recipes) if (r.id === u || r.machine === u) open.add(r.id);
}
moved = true;
}
}
if (!moved) break;
have = reachableFrom(recipes.filter((r) => open.has(r.id)));
}
const stranded = tech
.filter((t) => !unlocked.has(t.id))
.map((t) => `${t.id} costs ${JSON.stringify(t.cost)} — unaffordable no matter what you build`);
expect(stranded).toEqual([]);
});
it('the lab is starting equipment', () => {
const labs = machines.filter((m) => m.kind === 'lab').map((m) => m.id);
const lockedLabs = labs.filter((id) => gated.has(id));
expect(lockedLabs).toEqual([]); // a lab behind research is a lab you can never build
});
});
describe('economy reachability', () => {
it('the M1 flow reaches MELT from raw ore', () => {
expect([...producible()]).toContain('melt');
@ -410,8 +702,12 @@ describe('economy reachability', () => {
// Keep this list short and delete entries as recipes land. Round 2 retired
// container-pack (the moov line exists now); wildlife needs traps, cinema needs
// the Fortress.
// Round 5 retired 'mosquito-swarm' — the trap catches them now, which finally makes
// `vaporwave-sunset` fillable after four rounds of wanting live mosquitoes.
const FORWARD_CANON = new Set([
'mosquito-swarm', 'gibbs-wraith', 'cinema-pack',
'gibbs-wraith', // §6: must come "willingly". Nothing coerces one yet.
'cinema-pack', // needs the Fortress (§7).
'sodium-light', // §10: The Correction hoards the prisms.
]);
const have = producible();
const orphans = items
@ -424,7 +720,7 @@ describe('economy reachability', () => {
// The allowlist is a licence to be unreachable. The moment a recipe makes one of
// these, it must leave the list, or it stops proving anything.
const have = producible();
const retired = ['container-pack'].filter((id) => !have.has(id));
const retired = ['container-pack', 'mosquito-swarm'].filter((id) => !have.has(id));
expect(retired).toEqual([]);
});
@ -437,3 +733,291 @@ describe('economy reachability', () => {
expect(bad).toEqual([]);
});
});
// ---------------------------------------------------------------- the ground
//
// data/seams.json is consumed by SIM (an extractor only runs while its footprint
// overlaps a seam) and RENDER (era skins the ground). Nothing else in the repo knows
// where the ore is, so a bad rect here is an unmineable world.
interface SeamRect { x: number; y: number; w: number; h: number }
interface SeamDef {
id: string; era: TechDef['era']; resource: string;
rect: SeamRect; richness: number; hidden?: boolean;
}
const seams = seamsJson.seams as unknown as SeamDef[];
const relicReserve = seamsJson.relicReserve as unknown as SeamRect;
/** Do two tile rects touch? Both are inclusive of their min corner, w/h tiles wide. */
function rectsOverlap(a: SeamRect, b: SeamRect): boolean {
return !(a.x > b.x + b.w - 1 || a.x + a.w - 1 < b.x ||
a.y > b.y + b.h - 1 || a.y + a.h - 1 < b.y);
}
describe('seams', () => {
it('every seam has a unique kebab-case id and a known era', () => {
expect(duplicates(seams.map((s) => s.id))).toEqual([]);
const bad = seams
.filter((s) => !KEBAB.test(s.id) || !ERAS.includes(s.era))
.map((s) => `${s.id} -> era "${s.era}"`);
expect(bad).toEqual([]);
});
it('every seam yields an item that exists', () => {
const bad = seams.filter((s) => !itemIds.has(s.resource)).map((s) => `${s.id} -> "${s.resource}"`);
expect(bad).toEqual([]);
});
it('every seam is a real rect inside the grid', () => {
// GRID_MIN/GRID_MAX are LANE-SIM's (src/sim/geom.ts). A seam outside them is ground
// the player can never stand on.
const bad: string[] = [];
for (const s of seams) {
const { x, y, w, h } = s.rect;
if (![x, y, w, h].every(Number.isInteger) || w < 1 || h < 1) bad.push(`${s.id} rect ${JSON.stringify(s.rect)}`);
if (x < -32 || y < -32 || x + w - 1 > 31 || y + h - 1 > 31) bad.push(`${s.id} out of the -32..31 grid`);
if (!(s.richness > 0 && s.richness <= 1)) bad.push(`${s.id} richness ${s.richness}`);
}
expect(bad).toEqual([]);
});
it('no two seams overlap — a tile belongs to one stratum', () => {
const clashes: string[] = [];
for (let i = 0; i < seams.length; i++) {
for (let j = i + 1; j < seams.length; j++) {
if (rectsOverlap(seams[i].rect, seams[j].rect)) clashes.push(`${seams[i].id} / ${seams[j].id}`);
}
}
expect(clashes).toEqual([]);
});
it('the relic reserve is genuinely seam-free', () => {
// SIM plants the optical fossil 18-28 tiles out, "never on a seam". If seams creep
// into the reserved corner there may be nowhere legal left to put it.
const intruders = seams.filter((s) => rectsOverlap(s.rect, relicReserve)).map((s) => s.id);
expect(intruders).toEqual([]);
});
it('the reference factory stands on ore', () => {
// SIM's reference factory puts three 2x2 extractors here. Off-seam they idle with
// 'no seam' and the whole demo produces nothing.
const REFERENCE_EXTRACTORS = [{ x: -30, y: 0 }, { x: -30, y: -3 }, { x: -30, y: 3 }];
const stranded = REFERENCE_EXTRACTORS
.filter((e) => !seams.some((s) => rectsOverlap(s.rect, { x: e.x, y: e.y, w: 2, h: 2 })))
.map((e) => `extractor at (${e.x},${e.y}) is off-seam`);
expect(stranded).toEqual([]);
});
it('there are hidden bonus seams for the numbers station to point at', () => {
expect(seams.filter((s) => s.hidden).length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------- the band
describe('stations', () => {
const stations = stationsJson.stations as unknown as Array<Record<string, any>>;
const byId = (id: string) => stations.find((s) => s.id === id)!;
it('every station has a unique id, a known band and a positive frequency', () => {
expect(duplicates(stations.map((s) => s.id as string))).toEqual([]);
const bad = stations
.filter((s) => !KEBAB.test(s.id) || !['AM', 'FM', 'SW'].includes(s.band) || !(s.freq > 0))
.map((s) => `${s.id} -> ${s.band} ${s.freq}`);
expect(bad).toEqual([]);
});
it('no two stations sit on the same spot on the dial', () => {
const dial = stations.map((s) => `${s.band} ${s.freq}`);
expect(duplicates(dial)).toEqual([]);
});
it('every station has a name, a format tag and a line of flavor', () => {
const bad = stations
.filter((s) => !String(s.name).trim() || !String(s.format).trim() || !String(s.flavor).trim())
.map((s) => s.id as string);
expect(bad).toEqual([]);
});
it('THE STANDARD can broadcast for ten minutes without repeating itself', () => {
// At the ~20s a chyron line needs to be readable, ten minutes is 30 lines.
const lines = byId('the-standard').chyron as string[];
expect(lines.length).toBeGreaterThanOrEqual(30);
expect(duplicates(lines)).toEqual([]);
expect(lines.filter((l) => !l.trim())).toEqual([]);
});
it('no line is wider than the sky', () => {
// Round 6 I wrote eight lines at 100-136 chars and found out from SCREEN's suite.
// The cap is theirs (src/audio/band.test.ts: the chyron band renders ~62 mono chars
// and they allow 96); this is a deliberately conservative local copy so the words
// fail in the lane that writes them. If SCREEN widens the band, this only ever errs
// on the narrow side — and I'll happily let a few lines breathe.
const CHYRON_MAX = 96;
const overlong = (byId('the-standard').chyron as string[])
.filter((l) => l.length > CHYRON_MAX)
.map((l) => `${l.length} chars: "${l}"`);
expect(overlong).toEqual([]);
});
it('the numbers station really does encode the hidden seams', () => {
// The treasure map has to point at treasure. Recompute the planted groups from
// seams.json: if a hidden seam moves and the broadcast does not, this fails.
const n = byId('numbers').numbers;
const encode = (s: SeamDef) =>
String(s.rect.x + 32).padStart(2, '0') +
String(s.rect.y + 32).padStart(2, '0') +
String(Math.round(s.richness * 9));
const expected = seams.filter((s) => s.hidden).map(encode).sort();
expect([...(n.plantedGroups as string[])].sort()).toEqual(expected);
});
it('every broadcast group is the right shape, and the decoys lead nowhere', () => {
const n = byId('numbers').numbers;
const groups = [...(n.plantedGroups as string[]), ...(n.decoyPool as string[])];
const malformed = groups.filter((g) => !new RegExp(`^[0-9]{${n.groupSize}}$`).test(g));
expect(malformed).toEqual([]);
expect(duplicates(groups)).toEqual([]);
// A decoy that decodes onto ANY seam — hidden OR visible — isn't a decoy, it's a leak:
// it hands the player a real dig site the station never meant to give away. Round 6:
// the old check only guarded the three hidden 1x1 tiles, and two decoys (08127, 27503)
// were quietly sitting on reel-beds-deep and broadcast-shelf-south. Now every decoy is
// decoded to a tile and checked against every seam rect, and the leak is named.
const point = (x: number, y: number): SeamRect => ({ x, y, w: 1, h: 1 });
const leaks = (n.decoyPool as string[])
.map((g) => ({ g, x: Number(g.slice(0, 2)) - 32, y: Number(g.slice(2, 4)) - 32 }))
.flatMap(({ g, x, y }) =>
seams
.filter((s) => rectsOverlap(point(x, y), s.rect))
.map((s) => `decoy ${g} decodes to (${x},${y}), inside real seam ${s.id}`));
expect(leaks).toEqual([]);
});
it('dead air has its one ghost', () => {
const ghost = byId('dead-air').ghost;
expect(String(ghost.line).trim().length).toBeGreaterThan(0);
expect(ghost.everyMinutes).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------- the ladder
//
// The fax is the tutorial. The first four commissions teach one mechanic each, in
// order, and the first one has to be completable by someone who has never seen the
// game. These guard the teaching order the way the tick-0 guard protects the bootstrap.
describe('the commission ladder', () => {
const gated = new Set(tech.flatMap((t) => t.unlocks));
const ungatedRecipes = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine));
/**
* The smallest set of machine types that can produce `target` from bare ground at
* tick 0, or null if nothing ungated makes it. Fixpoint over recipes, keeping the
* cheapest producer for each item "how many different machines must a new player
* build to fill this order".
*/
const cheapest = new Map<string, Set<string>>();
for (;;) {
let improved = false;
for (const r of ungatedRecipes) {
const inputs = Object.keys(r.inputs);
if (!inputs.every((i) => cheapest.has(i))) continue;
const need = new Set<string>([r.machine]);
for (const i of inputs) for (const m of cheapest.get(i)!) need.add(m);
for (const o of Object.keys(r.outputs)) {
const known = cheapest.get(o);
if (!known || need.size < known.size) { cheapest.set(o, need); improved = true; }
}
}
if (!improved) break;
}
const machinesToMake = (target: string): Set<string> | null => cheapest.get(target) ?? null;
const RUNGS = ['raw-payload', 'first-refinement', 'dust-quota', 'first-taste'];
it('opens on the teaching ladder, in teaching order', () => {
expect(commissions.slice(0, RUNGS.length).map((c) => c.id)).toEqual(RUNGS);
});
it('rung 1 is buildable at tick 0 out of a handful of machines', () => {
// "A belt, a drill, an uplink." If rung 1 needs research or a deep chain, the game
// opens by asking a new player for something they cannot possibly make.
const rung1 = commissions[0];
for (const want of Object.keys(rung1.wants)) {
const needed = machinesToMake(want);
expect(needed, `rung 1 wants ${want}, which cannot be made at tick 0`).not.toBeNull();
// + the belt and the uplink, which carry it but craft nothing.
expect(needed!.size, `rung 1 should need few machine types, needs ${[...needed!]}`)
.toBeLessThanOrEqual(2);
}
});
it('every rung is craftable at tick 0 — the tutorial never gates itself', () => {
const blocked: string[] = [];
for (const id of RUNGS) {
const c = commissions.find((x) => x.id === id)!;
for (const want of Object.keys(c.wants)) {
if (machinesToMake(want) === null) blocked.push(`${id} wants ${want}, unmakeable at tick 0`);
}
}
expect(blocked).toEqual([]);
});
it('each rung teaches strictly more machinery than the one before', () => {
// The ladder should climb. If a later rung needs no more than an earlier one, it is
// not teaching anything new and the slot is wasted.
const depth = RUNGS.map((id) => {
const c = commissions.find((x) => x.id === id)!;
return Math.max(...Object.keys(c.wants).map((w) => machinesToMake(w)?.size ?? 0));
});
const flat = depth.slice(1).map((d, i) => ({ d, prev: depth[i], id: RUNGS[i + 1] }))
.filter(({ d, prev }) => d <= prev)
.map(({ id, d, prev }) => `${id} needs ${d} machine types, no more than the rung before (${prev})`);
expect(flat).toEqual([]);
});
it('the ladder is cheap and the rewards climb', () => {
const rungs = RUNGS.map((id) => commissions.find((x) => x.id === id)!);
// Rewards ascend across the ladder and none of the tutorial rungs out-pays the
// ordinary queue that follows.
for (let i = 1; i < rungs.length; i++) {
expect(rungs[i].rewardBandwidth).toBeGreaterThan(rungs[i - 1].rewardBandwidth);
}
expect(rungs[rungs.length - 1].rewardBandwidth).toBeLessThan(commissions[RUNGS.length].rewardBandwidth);
// No standing orders in the tutorial — a repeating rung would never let the player
// off the first step.
expect(rungs.filter((c) => c.repeat).map((c) => c.id)).toEqual([]);
});
});
describe('the cooler pairing', () => {
const soft = machines.find((m) => m.id === 'software-decoder')!;
const cooler = machines.find((m) => m.id === 'asic-cooler')!;
const cool = (m: MachineDef) => m.coolPerTick ?? HEAT_COOL_DEFAULT;
/** Contracts v5: auras are ADDITIVE, capped at HEAT_COOL_MAX. */
const withCoolers = (n: number) =>
Math.min(cool(soft) + n * cooler.coolPerTick!, HEAT_COOL_MAX);
const duty = (c: number) => {
const net = soft.heatPerTick! - c;
if (net <= 0) return 1; // never heats
const up = (1 - HEAT_RESTART) / net;
return up / (up + (1 - HEAT_RESTART) / c);
};
it('one cooler is a real upgrade, not a rounding error', () => {
expect(duty(withCoolers(1))).toBeGreaterThan(duty(withCoolers(0)) * 1.5);
});
it('a bare decoder still throttles, and one cooler does not make it immortal', () => {
// The codex's decoder "throttles theatrically" — that is its whole character. A
// single cooler must not switch the mechanic off.
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(0));
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(1));
});
it('immortality costs an absurd shrine of coolers, not a casual pair', () => {
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(2)); // two is not enough
expect(withCoolers(3)).toBeGreaterThanOrEqual(soft.heatPerTick!); // three is
});
});

View File

@ -9,7 +9,11 @@
font-family:"Courier New", ui-monospace, monospace; }
#game { position:fixed; inset:0; } /* LANE-RENDER mounts here */
#screen { position:fixed; top:10px; left:50%; transform:translateX(-50%);
width:320px; height:180px; border:1px solid #2a2440; z-index:5; } /* LANE-SCREEN canvas */
width:320px; height:180px; border:1px solid #2a2440; z-index:5;
cursor:pointer; } /* LANE-SCREEN canvas. RULING (round 5 review): as of UI's
click-to-enlarge this is a legitimate interactive surface, not a dead zone —
RENDER's pointer-events request is overtaken by events. World tiles under it
are occluded like any panel; don't build under the sky. */
#ui { position:fixed; inset:0; pointer-events:none; z-index:10; } /* LANE-UI root (children re-enable pointer-events) */
</style>
</head>

View File

@ -25,47 +25,33 @@ REQUESTs.
---
## CURRENT ORDERS — Round 3 (goal: heat that matters, deeper economy)
## CURRENT ORDERS — Round 7 (Phase 1: the paperwork economy)
Round 2 review: the bloom-concentrate grep-before-delete and the fault-injection
discipline are exactly the standard. Both your cross-lane asks are RULED:
**(1) Units: per-second.** `gen`/`draw` are per second, `stored` is
bandwidth-seconds; SIM converts their integration this round — your `bufferCap: 240`
= 8 seconds of a 30 deficit, as you intended. **(2) Cooling:** `coolPerTick?` granted
in v3 (sim default 0.004); SIM wires it. Your SIM's-test-is-red flag was correct
mid-round and self-resolved at HEAD (their landing fixed it) — the stash-and-rerun
diagnostic was the right move. Ownership RULING: `data-validate.test.ts` moves to
`data/validate.test.ts` (git mv, yours — vitest picks it up there; `src/sim/**`
returns fully to SIM).
Contracts v9: `CommissionDef.compliance`, and SIM lands debt/wardens/gunships/
auditors this round — coordinate thresholds via NOTES as usual.
1. **The validator move** above, first, so the tree is clean.
2. **Heat retune, the headline**: today NOTHING can overheat — every `heatPerTick`
you authored is below the 0.004 ambient cooling, so heat is decorative. With
`coolPerTick` yours to set per machine: hit SIM's suggested feel — software
decoder throttles after ~10s of continuous load, scrams ~13-15s, full duty cycle
~25s; ASIC stays incapable of heat. Show the arithmetic in CANON-DELTA (per-tick
vs per-second — mind the units ruling). Give the hex-splicer a lesser fever.
Coordinate with SIM's reference-factory v3 via NOTES — your numbers decide
whether their layout survives.
3. **Buffer economy check** under the units ruling: is 240 (8s) still your intent
now that it's true? One tank should cover one decoder scram — verify against your
retuned cycle, adjust, show working.
4. **ASIC COOLER spec'd** in CANON-DELTA (the codex names it, §2 "vented by ASIC
coolers"): item/machine entry + intended adjacency mechanic, data authored but
recipes gated behind a `broadcast`-era tech node — the MECHANIC stays deferred
until SIM builds auras (M4); the data existing first is fine and the validator's
FORWARD_CANON pattern covers it.
5. **Reel + broadcast era tech nodes**: research costs in analog packs; unlocks
field-loom/telecine recipe families you already authored.
6. **Muxer + moov brain sub-lines** (§7): `stsz-ledger`, `stco-map`, `stts-metronome`,
`stss-keyring` items + assembly recipes converging on a `moov-brain`
`mux-container` endgame recipe on the muxer. This is M4's rocket — data lands
first, mechanics later. Muxer machine entry if you don't have one.
1. **COMPLIANCE QUOTAS**: 3-4 `compliance:true` standing commissions in The
Correction's register — they want TIER-0/1 material only (extend your validator:
a compliance commission wanting tier>=2 fails by name). Flavor: polite, clean,
horrifying ("QUOTA 7-C: 40 LUMA BARS. THE FILE THANKS YOU FOR YOUR COOPERATION.
THE FILE DOES NOT KNOW YOUR NAME."). These are the debt valve — shipping boring
content on purpose is a real strategy with a real bandwidth cost.
2. **FORGED HASH**: a two-input recipe on the EXISTING hex-splicer (container-pack +
hf-dust or your better reading — argue it in CANON-DELTA), producing `forged-hash`
(new item, tier 3). It voids a Hash Auditor's audit. Tech-gate it late (fortress-
adjacent). NOTE for SIM coordination: its shape cannot use the empty-inputs
catcher path — they're budgeting a second detection for it.
3. **Debt/band tuning numbers** if SIM makes them data-driven (they export constants
otherwise — retire copies as before).
4. **THE STANDARD, audit pool**: ~8 new chyron lines acknowledging audits/mirrors/
stasis ("Units conducting review are not present." / "The sector was always
white."). <=96 chars each (the wrap cap).
5. **CANON-DELTA**: the rungs-2-4 reading of §5, the no-grudge ruling, the
deviation-targeting ruling, and FORGED HASH's argument.
**Definition of done:** validator green from its new home; a software decoder
demonstrably overheats in SIM's tests using YOUR numbers (coordinate — their DoD
depends on your retune); CANON-DELTA carries the arithmetic; FORWARD_CANON pruned of
anything that gained a recipe.
**DoD:** compliance quotas validate + complete against SIM's real debt venting;
forged-hash reachable via tech; validator green; the fax reads like the enemy's HR
department discovered your existence and filed it.
---
@ -315,3 +301,388 @@ POST-PUSH ADDENDUM (same round, after LANE-UI landed `src/ui/live.test.ts`):
**Five sessions share one index: `git add <paths>` does not isolate a lane.** Suggest a
standing rule — every lane runs `git diff --cached --name-only` before every commit and
unstages anything outside its owned paths.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED: all 5 orders. §10 transcribed (9 items, 5 machines, 11 recipes, 5 era-gated tech
nodes, 2 commissions). Research economy: the ARCHAEOLOGY LAB + a full tech-cost audit.
ASIC cooler activated. Accents filled. items 51 → 60, machines 23 → 29, recipes 51 → 62,
commissions 14 → 16, tech 24 → 23 (six deleted, five added). Validator 41 → 49 tests.
- Verified: tsc clean (and `data/` is now genuinely typechecked — thank you), my 49 green,
live build boots with no console errors, all 29 machines art-directed with zero gaps,
UI grew a RESEARCH page and a third REFINE page off my data with no code from them.
- Fault-injected every new check, including re-gating the mosh reactor: fails instantly on
`MELT is craftable at tick 0 — the opening commission must be possible`.
**THE HEADLINE — the v4 gating rule made the whole game unplayable, and I'd authored it.**
The rule ("any id in a TechDef.unlocks is locked until that tech completes") turned three
rounds of my tech authoring from decoration into a lock on everything. Measured:
```
TICK 0: melt producible? FALSE <- first-taste, the opening commission, impossible
every science pack? FALSE
RESEARCH CLOSURE: 0 of 24 techs reachable
```
`disc-artifact-bottling` cost 10 analog packs and unlocked the only machine that can bottle
an analog pack. The M1 melt chain itself was gated. The game opened on an unbuildable
factory, an unfillable commission, and no way to research out of it. **This is the same
class of bug as round 2's decorative heat: a number/structure that was fine until another
lane made it load-bearing.** Fixed to a shape, not a patch — two rules, both now tests:
1. the M1 melt chain is starting equipment; 2. one pack (SPATIAL) is craftable at tick 0.
Result: **23/23 reachable**, order tells a story, only the four forward-canon items remain
unreachable. Arithmetic + the worked cost path in CANON-DELTA.
DECISIONS:
- **SPATIAL PACK is the bootstrap science** — bricks + halos + slurry all fall out of the
quantizer the player already owns for the melt chain. Your first research is a study of
your own damage, which is the most in-voice thing in the tree, and it needs no machine
the M1 chain didn't already require (bar the bottler). 10 packs/min on one quantizer;
the first four nodes cost 0.51.5 min each. Affordable off a first-taste-era factory, as
ordered, with the working shown.
- **Six disc nodes deleted, not repurposed** (`disc-artifact-bottling`,
`-chroma-subsampling`, `-macroblock-quarrying`, `-ringing-skim`, `-subsampling-420`,
`-mosh-reactor`) — their content became starting equipment, so a repurposed node would
have been a fake. Grepped first: no lane references specific tech ids. Per the
deprecation rule, logged here and in CANON-DELTA.
- **The cooler's spec'd 0.02 was catastrophic once real** — the decoder banks 0.0033, so a
0.02 aura is net 0.0167: it never heats, and a 2×2 cooler makes a 2×2 decoder a
permanent free 100/s. Retuned to 0.0018, which is sane under **both** readings of the
contract's ambiguous "this machine's coolPerTick *also* applies" (additive → 85% duty,
72/s avg; replacing → 55%, 46/s). I deliberately picked a number that survives either,
because SIM hasn't built it yet and I won't rest the design on my guess of a sentence.
Pairing is 9.64/s per tile vs 5.00 for ASICs — the best power in the game, paid for with
a tech, two machines and the floor space to keep them adjacent.
- §10 judgement calls (footage = anchor slab; the matte bath's chemical = static canister,
which §3 already calls "brine for preserving other artifacts"; ghost feather made only by
the neural keyer so the earlier machines exist to ruin it) — all argued in CANON-DELTA
the way you asked, from what the thing already is rather than convenience.
- NEURAL KEYER `powerDraw: 240` — §10's "24 VRAM ... dwarfs everything else"; that's 12
ASICs, against an entire M1 chain that draws 96. "You don't unlock the neural keyer; you
unlock the power plant it demands" is now literally true.
BLOCKED/BROKEN — **I broke two of SIM's tests, deliberately, and their guard caught it:**
- `research.test.ts:247` does `expect(gated.has('mosh-reactor')).toBe(true) // guards the
premise below`. I ungated the mosh reactor, because gating it makes first-taste
impossible. **Their premise guard did exactly its job** — it failed loudly instead of
silently testing nothing. The fix is one word in their file (not mine to touch): swap
`mosh-reactor` for any still-gated machine. Drop-in list: mv-extractor,
bloom-duplicator, field-loom, telecine-puller, hex-splicer, software-decoder,
asic-cooler, matte-bath, optical-printer, chroma-keyer, roto-bench, neural-keyer, muxer.
`demuxer` remains a valid "free" example.
- The other 8 failures in `research.test.ts` / `live.test.ts` are **not mine** — they fail
identically against HEAD's round-3 data (measured by restoring the old JSON and
re-running; failure sets diffed).
CROSS-LANE — SIM, the reference factory now can't power itself:
- It places **4 software decoders**, which my tree gates behind `stream-software-decoding`
(rounds 3 and 4 both). With your gating live, they can't be placed at tick 0, so the M1
demo has no power and the HUD's heat tests have no decoder to watch. That's the cause of
several of those 8.
- **The starter plant is ASICs**: net demand 78/s ÷ 20 = **4 decode-asics minimum** (5 for
headroom). Ungated, boring, correct — the software decoder is a mid-game *upgrade*, which
is the progression the tech tree is for. My round-3 suggestion (3 ASIC + 1 decoder + tank)
is still the right *post-research* demo, and the decoder+cooler pairing is the M2 showpiece.
- Tell me if you'd rather software-decoder be starting equipment and I'll ungate it — but
at 25.8/s average vs the ASIC's 20 it would make the ASIC pointless from tick 0.
CONTRACT REQUEST: none. (Still not requesting `kind: 'cooler'``kind: 'power'` +
`coolRadius` covers it, and the aura isn't built yet.)
STILL OPEN: **SIM hasn't exported the tuning constants yet** (I checked — `grep 'export
const' src/sim/*.ts` finds only `geom.ts`). My heat tests still hand-mirror 0.7 / 0.5 /
0.004 / 30tps and still go stale silently if you retune. I'll retire the copies the round
the export lands.
NEXT (candidates): SPILL FOG + EDGE BOIL (§10 hazards — need a mechanic, so data waits on
SIM); mosquito traps; a seam/ore map (extractors still mine anywhere — the content gap I
own); `cinema-pack` + the Fortress; `corridorkey-mrp-mlx` is a real MODELBEAST artifact
factory for the whole §10 asset set (§10's production note) — worth a work order.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
SHIPPED: all 8 orders. Constants imported (hand-copies retired). Mosquito trap + catch.
Cooler finalised under the additive ruling. **The commission ladder.** `data/seams.json`
and `data/stations.json` (both new). Relic canon + the margin hint. Validator 49 → 72.
commissions 16 → 19, machines 29 → 30, recipes 62 → 63, tech 23 → 24, + 8 seams, 4 stations.
- Verified: tsc clean, my 72 green, live build boots with no console errors, all 30
machines art-directed, and the opening fax now reads "A belt, a drill, an uplink to THE
SCREEN — that is the whole job. MDAT ORE 0/20" with FIRST REFINEMENT in the tray.
- Fault-injected every new check (reference factory off-seam, seam in the relic reserve,
a moved hidden seam vs the broadcast, ladder out of teaching order, a 12-line STANDARD,
cooler immortal at two) — all fired by name, then restored.
**RUNG 1 IS 21.7 SECONDS.** Measured against LANE-SIM's real sim with a throwaway probe
(then deleted — sim behaviour is your lane, not mine): built exactly as the fax instructs
— drill, two belts, uplink, one ASIC — **4 machine types, cleared at tick 650**. The order
allowed 3 minutes. Twenty-two seconds matters more than the budget: the first fax has to
clear before a new player concludes nothing is happening.
DECISIONS:
- **The ladder teaches one mechanic per rung and never says so.** ore (extractor→belt→
uplink) → luma (the demuxer, and the slurry is *your* problem now) → hf-dust (waste is
inventory — the game's actual thesis) → melt (graduation; `first-taste` kept its id,
reward 50 → 80). The old queue opened on one MELT, i.e. "build eight machines correctly,
in order, with no feedback until the last one works". That was the cliff.
- **The trap's recipe has no inputs, deliberately.** I first wrote it as
`8 hf-dust + 1 luma → swarm`, then read `wildlife.test.ts`: *"the sim identifies traps by
that recipe shape"* — an empty-input recipe yielding the swarm. Ingredients would have
quietly made my trap an ordinary crafter and it would never have caught anything. Fixed,
and pinned with a test that cites your comment. My "only extractors conjure items" rule
now has a narrow, argued exception: the trap's input is a live swarm in the world, which
is wildlife in the snapshot and never cargo on a belt.
- **Cooler final: 0.0018 → 0.001.** The additive ruling killed 0.0018 — two coolers reached
0.0046 against the decoder's 0.0033 gross, so a casual pair made it immortal. At 0.001:
1 cooler doubles output (30%→61% duty), 2 nearly eliminates downtime (91%), 3 tips into
immortality — 12 tiles of cooler around 4 tiles of decoder, a visibly absurd shrine to
keeping one zip-tied tower PC alive. Immortality reachable, never casual. Duty table and
per-tile comparison in CANON-DELTA.
- **The numbers station is a real map, and it cannot go stale.** Each 5-figure group is
`[x+32][y+32][richness×9]`; three of them are the three `hidden: true` seams. The
validator *recomputes* the planted groups from `seams.json`, so moving a hidden seam
without re-cutting the broadcast fails. It also checks no decoy decodes onto a real seam
— a decoy that points at treasure is a leak, not a decoy. Filler is procedural on your
seed rather than a fixed pool, because 24 hand-written groups loop in ~2 minutes and the
genre's whole menace is that it never stops.
- **The optical fossil, and why the world is silent.** §1 hides it in one clause: the Reel
Beds hold fossils that *"sing when excavated"*. Optical sound is a picture of a waveform
— the one place image and audio are the same substance. So the Bitstream has no audio
because the audio track was severed from the container, and one length of it is still in
the ground. You don't unlock a feature; you repair the file. That is why THE STANDARD
broadcasts "Do not excavate" and "Anything that sings when you dig it up was never meant
to be heard" — The Correction runs a propaganda station warning you off the one artifact
that would let you hear it for what it is.
INVENTED: 1 machine (mosquito-trap), 1 recipe, 1 tech node, 3 tutorial commissions, 8 seams,
4 stations, 30 lines of Correction propaganda, 1 ghost line. Argued in CANON-DELTA.
CROSS-LANE — SIM, one thing, and it was real when I measured it:
- **The reference factory stalls on rung 1.** Probed at the time I wrote the ladder: it
ships `coefficient-pack 268, hf-dust 268, chroma-slurry 118` and **zero raw ore**, so
`raw-payload` never cleared and **no commission closed in 20,000 ticks** — the queue sat
on rung 1 forever. Every ore goes into the demuxer. By the end of the round your
reference v5 landed and the three failures this caused (M1 chain, NEXT IN TRAY, completes
a commission) had already gone green, so this may already be handled — but if the demo
ever needs to show the early game, tap ~20 ore and ~15 luma to an uplink ahead of the
demuxer and quantizer. Rungs 3 and 4 you already ship.
- Your `sim.test.ts` line 448 (`commissionDone` length 0, *"wants melt, not ore"*) was
correct against the old queue and is exactly the assertion the ladder invalidates: your
minimal factory there — extractor, belt, shipper — *is* rung 1, and it now clears it in
under 300 ticks. That is the ladder working, not breaking.
- Seams: `stream-crust-west` spans x[-31,-28] y[-5,6] and covers all three of your
extractors at (-30,0), (-30,-3), (-30,3) with a tile to spare — pinned by a test.
`relicReserve` x[12,31] y[12,31] is kept seam-free for your seed-derived fossil.
- Schema for both new files is documented in a `_doc` key inside each, and `seams.json` is
`{ relicReserve, seams: [...] }` rather than a bare array so it can carry that metadata.
SCREEN — `data/stations.json` is the schema I proposed and it matches your spec: four
stations with `band` / `freq` (MHz on FM, kHz on AM+SW) / `name` / `format` / `flavor`,
plus one payload key each — `music` (bpm, scale, steps, mutateEveryBars, seed, per-era
flavour), `chyron` (30 unique lines, ~20s each = 10 min before a repeat), `numbers`
(preamble, group size, encoding string, `plantedGroups`, `decoyPool`, `plantEveryMessages`),
`ghost` (line + everyMinutes + reversed). Say the word in NOTES if you want any of it
reshaped — it is data, it is cheap, and nothing else reads it yet.
BLOCKED/BROKEN: nothing of mine. Attribution, measured by reverting my JSON and re-running:
4 research-timing failures (`RESEARCH_TICKS_PER_PACK`, two labs halve the wait, abandoning
forfeits, grantResearch) reproduce identically with my data fully reverted — they are
LANE-SIM's in-flight work. Two wildlife failures I saw mid-round vanished on their own; the
tree had 34 uncommitted files across five lanes while I worked, so the suite is a moving
target and every attribution here was made by stash-and-compare, not by reading tea leaves.
CONTRACT REQUEST: none. `GameData` has no `seams`/`stations` fields and `main.ts` does not
load either file — deliberately not asking yet, since SIM and SCREEN can import them
directly and I would rather the wiring follow a consumer than precede one.
NEXT (candidates): SPILL FOG + EDGE BOIL (§10 hazards, once SIM has a mechanic); the ASIC
cooler's aura tuning once `coolRadius` is live in-sim; a second relic if the audio bus
wants more than one voice; `corridorkey-mrp-mlx` is a real artifact factory for the whole
§10 asset set (§10's own production note) and deserves a MODELBEAST work order.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED: all 4 orders. items 60 → 61, machines 30 → 31, recipes 63 → 64, tech 24 → 25,
commissions 19 → 21. Validator 72 → 74. All argued in `data/CANON-DELTA.md` (round 6).
- **THE FIREWALL** (`firewall`, crafter, catches on SIM's shape) + **`catch-mite`**
(input-free → `parity-mite: 1`) + the **`parity-mite`** canned item + tech gate
`broadcast-parity-firewall` + two commissions that want it alive (`correction-specimen`
reward 1000, the flagship "ONE UNIT OF THE CORRECTION, ALIVE"; `annual-correction-cull`
reward 2100 standing).
- **Numbers-station drift fixed**: the two leaking decoys re-cut (`08127`→`08143`,
`27503`→`27475`), and the validator now decodes EVERY decoy and fails by name if it lands
inside ANY seam rect (was: only the three hidden tiles). See CROSS-LANE — SCREEN.
- **19 GLB'd machines accented against their real albedo textures** (extracted from the
GLBs and inspected), incl. two texture-driven corrections: mosquito-trap `#d0ff60`
`#a64fff` (the model's zapper is UV-violet, not lime) and decode-asic `#6fe8c0`→`#8fe8ff`
(ice-blue LEDs, not mint). Table in CANON-DELTA.
- **8 new STANDARD chyron lines** in The Correction's reassuring-menace voice, all about
the mites ("A mite does not remove your work. It completes it."). 30 → 38 lines.
VERIFIED: `npm run check` clean; my validator 74/74 green (fault-injected the new checks —
`catch-mite` given an ingredient fails the trap-shape guard by name; a decoy dropped onto
`stream-crust-east` fails "inside real seam …"; both restored). SCREEN's `band.test.ts`
9/9. Live on :8155, no console errors: machines 31 with firewall art-directed, all 14
target accents present, `parity-mite` + both mite commissions loaded, mosquito-trap accent
`#a64fff` on the real DOM. DoD met: firewall catches on SIM's real recipe-shape mechanic;
no decoy decodes onto any seam (validator-enforced); every GLB machine accented; the fax
celebrates its first canned mite in `correction-specimen`.
DECISIONS:
- **The chyron limit bit me and it was cheap to obey.** SCREEN's `band.test.ts` caps a
line at 96 chars (62 mono chars would clip; they allow 96). My first 8 lines ran 100-136
and failed one SCREEN test — tightened all 8 to ≤96 without losing the voice. Their test
is the right net; the words are mine, the width is the sky's.
- **Firewall gated early (broadcast), not free.** §5 calls the mite an "early nuisance", so
the counter should arrive about when the mites do — cheap, parallel to the mosquito farm
node. Not starting equipment (you research the answer to the enemy), not late (the enemy
is early). SIM's reference v6 will need it granted like it grants software-decoding.
- **`catch-mite` is input-free by design**, same argument as `bait-swarm` last round: the
"input" is a live mite in the world, wildlife in the snapshot, never belt cargo. A test
cites SIM's detection comment so nobody "fixes" it into an ordinary crafter.
- **Accented all 19 GLB'd machines, not the ordered 10.** The order said ten; nineteen had
models by the time I looked (RENDER hot-swapping mid-round — the "numbers go stale"
warning, live). Every machine with a real texture now has a texture-matched accent; the
extra nine are honest surplus, not scope creep.
BLOCKED/BROKEN: nothing of mine. Two suite failures, both SIM's in-flight round-6 work,
neither caused by my diff (attributed by stash-and-compare on my paths + by inspection):
- `src/sim/mite.test.ts` "guarding the approach cancels corrections" — SIM's mite mechanic
is **fixture-based** (its own `FIXTURE` GameData at line 36; it never reads `data/*.json`),
so my data cannot touch it. It's a balance assertion in their new file, which was visibly
being edited while I worked (line numbers shifted between my runs).
- `src/sim/reference.test.ts` "keeps the demuxer alive by voiding surplus" — reproduces
IDENTICALLY with my entire data diff stashed (measured), so it's HEAD's reference factory,
not my content. Likely their reference v6 mid-build.
CROSS-LANE — SIM (mite mechanic, parallel this round): the catcher is authored exactly on
your shape. Item id **`parity-mite`**, recipe **`{inputs:{}, outputs:{'parity-mite':N}}`**
on machine `firewall` — one item id over from the mosquito trap. Your `mite.test.ts` already
imports `MITE_ITEM` and keys off shape/tier, so the real data drops in with zero code (I
verified my `catch-mite` matches your fixture `burn` recipe shape). `catch-mite` is
120 ticks / bandwidth 4. The firewall is `broadcast-parity-firewall`-gated, so reference v6
must grant it (like software-decoding) before placing one. `FIREWALL_RADIUS` is yours; I
set no radius field. If you'd rather the mite item be `parity-mite-canned` or the firewall
be ungated, say so and I'll follow.
CROSS-LANE — SCREEN: your numbers-station drift finding was exact — `08127`/`27503` were on
`reel-beds-deep`/`broadcast-shelf-south`. Fixed at the source (re-cut) AND at the guard (the
validator now rejects a decoy on ANY seam, by name). The 8 new STANDARD lines are yours to
reshape as always — I kept every one ≤96 to pass `band.test.ts`; if you widen that cap I'll
gladly give a couple more room to breathe. `numbers.plantedGroups` still recomputes from
`seams.json`, untouched.
CONTRACT REQUEST: none. `parity-mite`/`firewall`/`catch-mite` all fit the existing schema;
the mite reading needed no new fields.
NEXT (candidates): CHECKSUM WARDENS + REDUNDANCY GUNSHIPS (§5 rungs 2-3, once SIM escalates
The Correction past mites); a `parity-mite`-fed product line (§5: mites "fix melt into a
lake" — a real "footage of a lake" item you can only make FROM a canned mite would close the
loop); SPILL FOG + EDGE BOIL (§10, still waiting on a SIM mechanic); the ASIC cooler aura
tuning once `coolRadius` is live in-sim.
### Round 7 (Phase 1) — 2026-07-29 — Opus 5
SHIPPED: all 5 orders. items 61 → 62, recipes 64 → 65, tech 25 → 26, commissions 21 → 25,
chyron 38 → 46. Validator 74 → 86. All rulings argued in `data/CANON-DELTA.md` (round 7).
- **COMPLIANCE QUOTAS**: four `compliance:true` standing orders at queue indices 5/9/13/20 —
`quota-7c` (40 LUMA BARS, 150), `quota-12a` (80 ORE + 30 DUST, 340), `quota-30r` (100 bbl
SLURRY, 620), `quota-99z` (40 GOP CRATES + 25 SLABS SEALED AND UNMOSHED, 1600). Tier-0/1
enforced against your `DEBT_TIER_MIN`, not a hand-typed 2.
- **FORGED HASH**: `forge-hash` on the existing hex-splicer — **1 STCO MAP + 50 HF DUST →
1 FORGED HASH** (tier 3), 240t / bw 40 / heat 0.4, gated by new fortress node
`fortress-moov-heists`. Two inputs, so it cannot travel your empty-inputs catcher path.
- **8 STANDARD lines**, one per mechanic the player is about to meet, all ≤96 chars.
VERIFIED: `npm run check` clean. My 86 green. **Fault-injected all 11 new checks** (quota
wanting melt; quota not repeating; quota wanting researched material; quota out-crediting its
own vent; quota over the credit ceiling; quotas clustered into a drought; `forged-hash` id
drift; an input-free forge; an ungated forge; a 122-char chyron line; decorative forge heat)
— every one failed by name, then restored byte-for-byte. Live on :8155, zero console errors:
the fax opens on `raw-payload` with FIRST REFINEMENT in the tray, and **MOOV HEISTS renders in
the FORTRESS row of the research panel with zero code from UI.**
**THE MEASUREMENT — the vent works, end to end, on the real sim.** Throwaway probe (deleted;
sim behaviour is your lane). QUOTA 30-R wants 100 bbl slurry and reference v7 ships 230, so
hoisting it to the queue head in memory exercises the whole real path:
```
t=10,932 DONE quota-30r debt 0.3112 -> 0.1108 (delta -0.2004)
band 1 -> band 0, standing order re-queued to the back
```
0.2004 is `DEBT_VENT_COMPLIANCE` plus that tick's decay. **One quota = exactly one band**, as
designed, measured rather than asserted. Same probe walked the ladder on reference v7:
band 1 t=5,874 / band 2 t=9,281 / band 3 t=12,740 / band 4 t=16,152 — **the whole enemy ladder
unspools inside nine minutes.** My clean single-reactor model predicted 3,160/6,320/9,480/12,640;
the real plant is ~1.3× slower because it spends its first minutes building. Both are in
CANON-DELTA; the model is labelled as a model.
DECISIONS:
- **`stco-map + hf-dust`, not `container-pack + hf-dust`** — argued at length in CANON-DELTA.
Short version: no recipe in this game consumes a science pack (packs are lab currency, one of
the few clean lines in the economy); §6 names the forged article outright ("rebuild/forge the
stco/stsz index"); §4's splicer is already a hex-editor over a scrolling ticker-tape and has
charted the genuine STCO MAP since round 3; you cannot forge a document you have never seen a
real copy of; and HF DUST is *literally* invisible bytes — packing the discarded difference
back in until the digest agrees is how a collision is actually built. It also costs the
victory chain: `assemble-moov-brain` wants an stco map too, so every forgery is a MEGAGLITCH
deferred. One forge (240t) fits inside `AUDITOR_NOTICE_TICKS` (600t) **only if the map is
already on the belt** — so the real preparation is stockpiling maps, and §4's "steady buffer
feed or it splices itself" turns out to be the counter's whole difficulty curve.
- **Quotas pay well, on purpose.** `correction-buyback` has said since round 2 that "we pay
well, we pay immediately, and we pay every time" — the temptation has to be real or nobody
chooses to be boring. The counterweight isn't the rate, it's the ceiling: the register can
never ask above tier 1, so it cannot scale, while the collectors' ladder climbs to 3200. You
outgrow compliance. 30 s of a two-machine side line buys back 1m 45s of melt-shipping debt.
- **`ItemDef.tier` is now read by three mechanics** — `MITE_TIER_MIN`, `DEBT_TIER_MIN`, and your
gunship's sector scoring. Recorded as a standing rule: a tier number is no longer decoration,
and any future re-tier is a balance change in three places at once.
- **The 96-char chyron cap now has a test in MY suite.** Round 6 I wrote eight lines at 100-136
and found out from SCREEN's `band.test.ts`. The cap is still theirs; mine is a deliberately
conservative local copy so the words fail in the lane that writes them.
BLOCKED/BROKEN: nothing of mine. Two suite failures at push time, **both attributed by
stash-and-compare on `data/` only** (`git stash push -- data/`, never bare) — both reproduce
identically with my entire diff stashed, both in `src/sim/reference.test.ts`:
- "is still shipping melt at 20,000 ticks — the deadlock regression net"
- "keeps the demuxer alive by voiding surplus rather than jamming"
The tree moved a lot under me and the failing set moved with it, so read those two names
rather than the count. At round-start `npm run check` had 2 errors and the suite was
**107 failed / 405 passed** (SIM's `frozenEnt` referenced from the wrong closure, taking 11
files down). Mid-round it was 2 failed with `src/ui/live.test.ts` "narrates a whole warden
cycle" in the set; that one went green on its own while I wrote this up. Final: **tsc clean,
588 passed / 2 failed**, my 86 green throughout. Every number in this entry was re-probed at
the end, not carried over from when I first measured it.
CROSS-LANE — SIM, one blocker and one nit:
1. **Reference v7 still never clears rung 1, so no commission completes and no quota is
reachable in the demo.** Measured at 90,000 ticks: `activeCommission` is still `raw-payload`
and `commissionDone` fired **zero** times. It ships mv-flux 46, hf-dust 2155, chroma-slurry
230, macroblock-bricks 1013, melt 120, anchor-slab 20, parity-mite 190 — and **zero raw MDAT
ORE**, because every ore goes into the demuxer. This is the same finding I filed in round 5;
it went green briefly with reference v5 and is live again. Fix is unchanged and small: tap
~20 ore straight to an uplink ahead of the demuxer. Until then the compliance valve, the
commission ladder and the fax tutorial are all unreachable in the shipped demo — which is
why I had to hoist a quota by hand to measure the vent at all.
2. **The register is a queue position, not a register.** `creditCommission` only credits
`commissionQueue[0]`, so a quota can be filed only when it reaches the head. Standing quotas
do re-queue, so the valve comes round periodically — but it isn't the on-demand relief the
fax implies. I mitigated in data (four quotas, max 8 commissions between valves, pinned by a
test) and I'm deliberately NOT asking for a parallel register: that's a mechanic, and the
round-3 lesson was that a contract field nobody reads is a decorative trap. Ask me when you
want to build it.
CROSS-LANE — SCREEN: 8 new STANDARD lines, all ≤96, yours to reshape as always. Two of them do
teaching work you may want to lean on — *"An audit finds nothing where there is nothing. Be
nothing for five seconds."* is the abstention counter, and *"A hash you wrote yourself is still
a hash. We will read it. We will believe it."* is the forged hash. If you widen the cap I'll
give a couple of them room to breathe.
CONTRACT REQUEST: none. `compliance`, the forged hash and the quotas all fit v9 as written.
NEXT (candidates): a `parity-mite`-fed product line (§5's "footage of a lake" as a real item you
can only make FROM a canned mite would close the loop); the STSZ half of the §6 heist (a forged
*ledger* as a warden counter, mirroring the forged hash's auditor counter); SPILL FOG + EDGE
BOIL (§10, still waiting on a SIM mechanic); the ASIC cooler aura tuning once `coolRadius` is
live in-sim; `cinema-pack` when the Fortress exists.

View File

@ -25,35 +25,36 @@ you never simulate. If motion looks wrong, interpolate better or note it — don
---
## CURRENT ORDERS — Round 3 (goal: truth-driven states + asset intake readiness)
## CURRENT ORDERS — Round 7 (Phase 1: bodies later, PRESENCE now)
Round 2 review: the corner-cargo catch and the scram-latch bug you found by READING
SIM'S CODE are the reasons this process works. Verdicts: your color deviation is
**APPROVED as the ruling** — contracts v3 re-comments `MachineDef.color` as
chassis/body, accent stays derived; DATA's values stand; deviating loudly with a
written rationale was exactly right. Req 2 GRANTED: `EntityState.scrammed` is in v3 —
SIM wires it this round. Req 3 (`BeltItem.side`) DEFERRED until merges matter.
`BeltItem.id` is now required, so your fallback path can retire whenever you like.
Contracts v9 grants your round-6 request: `Renderer.onEvents?(events)` — main.ts fans
out the same array Screen/Audio get. Your three derived cues (repaired sparkle,
firewall paperwork, relicFound geyser) can now be exact; migrate them.
1. **Scram from truth**: read `EntityState.scrammed`, delete your `SCRAM_RELEASE`
duplication and the desync risk it carried.
2. **Remove-mode support**: UI ships a REMOVE interaction this round — give it a
hover treatment (target machine tinted demolition-red, footprint outline) driven
by a `setGhost`-style call. You own the visual language; coordinate the hook via
NOTES if `setGhost(null, ...)` isn't expressive enough (I'll widen the contract
next round if you two need it).
3. **Showroom formalized**: promote the devscene showroom row to `?showroom` — every
machine + every item on plinths, labeled, camera preset. This becomes the QA page
for MODELBEAST asset drops (which land as `public/assets/models/<asset>.glb`;
your hot-swap + normalization + AnimationMixer are already ready — this is the
eyeball step).
4. **Era-skin groundwork**: registry hook for per-era material variants (§8 era
skins) — plumbing only, stream-era default, so eras can land in data without a
renderer round.
RULING — THE INVERSION: the codex assigns wrong-motion to CORRUPTION, not authority
(§8: "clean things move at 60fps; corrupted things move wrong on purpose"). So The
Correction units are the ONLY things in the world that never miss a frame: they
glide, smoothly interpolated, unbothered, while everything the player owns keeps its
existing judder vocabulary. Do NOT quantize their transforms.
**Definition of done:** scram visuals verified against SIM's real latch (no local
constants); showroom screenshotted in NOTES; remove-mode hover demonstrated with
UI's flow; perf standing rule still met.
1. **Placeholder bodies for rungs 2-4** (zero GLBs this round): distinct primitive
silhouettes in hospital-white with one green element each — warden: tall
filing-cabinet slab with a stamp arm; gunship: five mirrored rotor platters in a
row, hovering; auditor: a faceless white marble column that TELEPORTS (appears/
disappears, never walks — §5 says it moves only between frames). All matte,
spotless, unweathered.
2. **The gunship's mirror**: its `rect` renders as a faint ghostly duplicate of the
sector beneath it (a translucent white re-render or projected decal — your
language). The tether to `target` is a visible line.
3. **Stasis rect**: bleached ground, frozen belt items visibly stopped mid-tile,
00:00:00:00 timecode floating. Release snaps back hard.
4. **Seal stamp**: sealed machines get a white compliance placard on the chassis +
their idle animation FREEZES (a sealed machine is a still photograph of itself).
5. **onEvents migration** for your three derived cues + the new ones (notice ping at
the fax, mirrored flash inside the sector).
**DoD:** all three rungs readable at a glance in showroom + demo world; the glide
verified against juddering surroundings; stasis freeze/release pinned; perf holds.
---
@ -282,3 +283,932 @@ building; I loaded it in the dev page and dispatched all 114 commands):
- Per-era chassis skinning (§8 era skins) once tech/eras land; splitter output lamp
driven by real round-robin state rather than a timer; bloom-loop belt tell; HF dust
piles; parity-mite wildlife; LOD/culling if a real megabase ever drags the frame.
### Round 3 — 2026-07-17 — Opus 4.8
**PROVENANCE — read before you diff lanes against owned paths.** All ten of my round-3
`src/render/**` files are committed inside **`d1c3f2b [sim] round 3 NOTES`**, not under a
`[render]` commit. Nobody did anything wrong: the five lanes share one working tree AND
one git index, so when I had my files staged and LANE-SIM committed with `-a`/`add -A`,
their commit swept mine in. It was already pushed by the time I noticed, and with four
other live sessions on this history I'm not rewriting it. **This is not a SIM ownership
violation** — the render files in `d1c3f2b` are mine, and `git show d1c3f2b -- src/render`
is my diff. PROPOSAL below on how to stop it recurring; it will, otherwise.
**SHIPPED** — all 4 orders, verified in the running build.
1. **Scram from truth.** `SCRAM_RELEASE` and the whole local latch are gone; the
renderer reads `EntityState.scrammed`. SIM wired the field mid-round (their
`state.scrammed` at index.ts:489/494) so this is verified against their real latch.
The crisp test: **heat 0.6 + `scrammed: true` → dead-dark, amber, idle frozen**, and
the same heat with `scrammed: false` → alive at body emissive 1.5 (= `1 + 0.6²×1.4`).
Same heat, opposite field, opposite visual — no threshold left in this lane to drift.
(`e.scrammed ?? heat >= 1` remains only as a bridge for a sim that hasn't set it; it
carries no constant.)
2. **Remove mode.** `setGhost('__remove', …)` → hazard-striped footprint decal + red
volume wash (`remove.ts`). Verified through UI's real flow: clicked their REMOVE tab,
`selectedBuild()` returned `{def:'__remove',dir:0}`, their hint bar switched to
"REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN", and hovering the mosh reactor lit its
**whole 3×3** (decal scale [3,3], wash [2.82,0.8,2.82], centred on the machine at
[5.5,9.5] — not the hovered tile). Bare ground shows nothing.
3. **`?showroom`** — 23 machines + 51 items on labelled plinths, camera preset, and it
lands MODELBEAST drops live (hot-swap + normalise + AnimationMixer + era skin all
already run through it). See the manifest below.
4. **Era skins** (`era.ts`) — plumbing, derived from data, verified live.
**VERIFIED**
- Perf standing rule holds: `?devscene=stress` → 500 entities + 2,000 items at
**3.3ms/frame**, 5 instanced draw calls. (R1 3.2 / R2 3.6 / R3 3.3 — topology, heat,
mixers, eras and remove-mode have cost nothing measurable.)
- **Era skins live across all five eras**, each matching its `ERA_SKIN` entry exactly:
reel telecine-puller `r0.65/m0.45` (sepia'd to #6e491f), broadcast field-loom
`r0.8/m0.1` (phosphor #2e5132), disc mosh-reactor `r0.2/m0.65` (gloss), fortress muxer
`r0.95/m0`. **Stream is a true identity skin** — quantizer chassis reads `#5e5148`,
byte-for-byte DATA's authored value, so nothing changed for the 16 stream machines.
- Prod bundle clean: `devscene` 0, `showroom` 0, `plinth` 0, `window.__render` 0 hits
(the 4 `__renderTarget` matches are three.js's own).
**DECISIONS**
- **`BeltItem.id` fallback retired.** Identity is now always the id, which also deleted
the per-frame sort of every belt item — the order-matching path only ever existed to
reconstruct identity the contract didn't carry.
- **The showroom fabricates a SimSnapshot** rather than hand-building display meshes, so
machines go through the real EntityLayer. A QA page with its own bespoke display path
would be testing itself instead of the renderer. Items aren't entities (no `asset`,
never hot-swap) so those are built directly — but from beltitems.ts's own factories,
so the showroom shows literally what rides the belts.
- **Camera preset frames the MACHINE hall, not everything.** A view wide enough to hold
all 51 items renders every machine too small to judge, which defeats the point. Items
are one pan south. Labels are collision-culled front-to-back: ~74 of them cannot all
fit at overview zoom, and a readable subset that resolves as you zoom beats a pile.
- **`__remove` is duplicated, not imported.** A renderer importing UI internals is the
wrong layering and there's no contract field yet. Unlike a tuning constant, drift here
fails loudly and visibly. Both lanes filed for a real hook (see request 1).
- Era skin touches the **chassis only** — the accent stays the colour of what the machine
makes, because that's the resource, not the decade.
**BLOCKED/BROKEN** — nothing blocking.
- **A dead debug hook leaked into prod and I nearly shipped it.** Hoisting the scaffold
check into a `debug` VARIABLE defeated Rollup's constant folding, so `window.__render
= {scene, gl, topo, …}` survived in `dist/` as unreachable dead code (the class itself
still tree-shook). Round 2's `if (devSceneEnabled())` folded because the call was
inline. Fixed by testing `import.meta.env.DEV` literally at the branch. Flagging the
shape, not just the fix: **any** scaffold guard that goes through a variable will do
this silently, and only grepping the bundle catches it.
- Merge pieces still don't route cargo per-inlet (needs `BeltItem.side`, deferred —
agreed).
- The showroom's item hall is laid out for 51 items; if DATA doubles them again the rows
will run past the world edge and want paging. Not urgent, noted so it isn't a surprise.
**CONTRACT REQUEST**
1. **`UIBus.setGhostMode(mode)` or a 4th `setGhost` arg** — LANE-UI filed the same one
independently (`src/ui/selection.ts` documents their half). Today remove-mode rides a
magic string through `selectedBuild()`, and main.ts fires a `place` for `__remove`
every click that the sim discards. It works and it's documented on both sides, but
it's a protocol living in two lanes' comments instead of in the contract.
2. Not urgent: nothing else. v3 granted both of round 2's asks and they both landed
clean.
**MODELBEAST WORK ORDER** — this is `?showroom`'s content, and every one of these is a
live hot-swap target today: drop `public/assets/models/<asset>` and it appears in ~10s,
no reload, no code. Normalisation means any export scale lands correctly; multi-mesh and
rigged+animated GLBs both work (verified round 2). Footprint is the tile budget.
| asset | era | fp | machine |
|---|---|---|---|
| `asic-cooler.glb` | broadcast | 2x2 | ASIC COOLER |
| `field-loom.glb` | broadcast | 3x2 | FIELD LOOM |
| `artifact-bottler.glb` | disc | 2x2 | ARTIFACT BOTTLER |
| `mosh-reactor.glb` | disc | 3x3 | MOSH REACTOR |
| `subsampler.glb` | disc | 2x3 | SUBSAMPLER |
| `muxer.glb` | fortress | 5x5 | THE MUXER |
| `telecine-puller.glb` | reel | 2x2 | TELECINE PULLER |
| `belt.glb` | stream | 1x1 | STREAM BELT |
| `bloom-duplicator.glb` | stream | 1x1 | P-FRAME DUPLICATOR |
| `buffer-tank.glb` | stream | 2x2 | BUFFER TANK |
| `dct-press.glb` | stream | 3x3 | DCT PRESS |
| `decode-asic.glb` | stream | 2x2 | DECODE ASIC |
| `demosaicer.glb` | stream | 3x2 | DEMOSAICER |
| `demuxer.glb` | stream | 2x2 | DEMUXER |
| `gop-assembler.glb` | stream | 3x2 | GOP ASSEMBLER |
| `hex-splicer.glb` | stream | 3x3 | HEX SPLICER |
| `lane-splitter.glb` | stream | 1x1 | LANE SPLITTER |
| `mv-extractor.glb` | stream | 2x2 | MV EXTRACTOR |
| `p-caster.glb` | stream | 2x2 | P-CASTER |
| `quantizer.glb` | stream | 2x2 | QUANTIZER |
| `seam-extractor.glb` | stream | 2x2 | SEAM EXTRACTOR |
| `shipper.glb` | stream | 2x2 | SCREEN UPLINK |
| `software-decoder.glb` | stream | 2x2 | SOFTWARE DECODER |
Hero assets worth `fal_trellis2` (codex §9): `muxer` (5x5, the rocket), `mosh-reactor`,
plus the Fortress kit when it exists. Everything else is `sf3d` territory.
**PROPOSAL — the shared index will keep eating commits.** Five sessions share one working
tree and one `.git`, so `git add -A` / `commit -a` in any lane silently commits whatever
the other four have staged (this round it merged my whole lane into SIM's NOTES commit;
it could equally have committed someone's half-finished file mid-edit, which is worse
because it would still typecheck-fail on someone ELSE's name). Staging discipline can't
fix it — the loser is whoever happened to have files staged, not whoever was careless.
Cheapest real fixes, in order:
1. **`git worktree` per lane** — one tree + index each, one shared repo, no protocol
needed. Lanes still see each other's commits after a pull.
2. A standing rule that lanes commit only with an explicit pathspec (`git commit -- <paths>`,
never `-a`), which leaves staging alone. Weaker: it's a convention, and this round
proves conventions lose races.
3. Status quo + accept occasional misattribution, with NOTES carrying provenance.
**NEXT (round 4 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; splitter lamp driven by real round-robin
state instead of a timer; bloom-loop belt tell; HF dust piles; wildlife; save/load
camera state; LOD/culling if a megabase ever drags the frame.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED** — all 4 orders + housekeeping, verified against LANE-DATA's real v4 data
(which landed mid-round: 29 machines, 60 items, `archaeology-lab`, `asic-cooler
coolRadius:2`, `accent` on 8 machines) and UI's real migrated flow.
1. **Ghost keys off `mode`.** The `'__remove'` sentinel is ignored — mode is the truth,
and the `REMOVE_DEF` constant is deleted from this lane. Verified through UI's own
REMOVE tab: `selection()``{mode:'remove'}`, demolition decal + wash on the mosh
reactor's whole 3×3.
2. **Locked-machine ghost.** Padlock-amber `#ffb02e` — deliberately not the occupied-red,
because "not yet" and "not here" are different sentences. A padlock floats above,
tinted by the era gating it. Verified: `telecine-puller` → amber ghost + `#d8a24a`
reel brass; `asic-cooler` → amber + `#5fd08a` broadcast phosphor.
- **Era tell needed its own palette.** `ERA_SKIN.tint` can't do this job: it's a
multiplier tuned to leave DATA's chassis alone (stream is literally `#ffffff`), so
three of five eras would have told you nothing. Added `ERA_TELL` — five hues chosen
to be told apart, used as signal rather than surface.
3. **Cooler auras.** Verified on `asic-cooler` (fp 2×2, coolRadius 2) → **6×6 tiles**,
centred on the ghost, dashed + tick-marked, no fill.
- **It's a rectangle, not a ring.** `coolRadius` is CHEBYSHEV from the footprint, so
the cooled region is the footprint grown by r on each side. A circle would have been
prettier and would have lied — the corners it left out are cooled, and players would
plan around a boundary that doesn't exist. Drawn in the geometry the sim measures in.
4. **Lab visuals.** `archaeology-lab` (kind `lab`, 3×3) renders as a bench + the "bottled
artifact triptych" (§2) — three out-of-phase pulsing bottles. Verified geometry
`Box,Cylinder,Cylinder,Cylinder` and **`accent` consumed from data**: bottles emissive
`#ffc24a`, exactly DATA's authored value.
5. **Housekeeping.** Splitter lamp left timer-driven as approved. Showroom absorbed the
new catalog — see the bug below.
**VERIFIED**
- Perf standing rule holds: 500 entities + 2,000 items at **3.6ms/frame**, 5 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 — auras, padlocks and the locked path
cost nothing measurable.)
- Showroom absorbs 29 machines + 60 items, all five eras present, hall inside the world.
**DECISIONS**
- **Matched LANE-UI's lock rule rather than my own, and it was a real divergence.** My
first cut treated absent `research` as "nothing is locked" — don't lie when the field
isn't wired. UI's `research.ts` reads the same rule the opposite way: nothing has
completed, so everything gated shows its padlock. **UI is right and I was wrong**: this
is the `scrammed` lesson misapplied. There, the field's absence meant the sim hadn't
wired a *behaviour*; here the rule is satisfiable from data alone and the contract says
"until that tech completes". Left unresolved, the build bar would have padlocked a
machine while the ghost waved it through — on the same screen. Also matched their
first-tech-wins tie-break for ids unlocked by two techs: a data bug either way, but we
must both blame the same tech.
- **Buffer fill now reads `bandwidth.capacity`** instead of re-summing `bufferCap`
locally. v4 publishes the total; a second copy of the sim's arithmetic in this lane is
exactly what the scram latch taught us not to keep. Falls back to the local sum only
while the field is absent.
- **`MachineDef.accent` wins outright, no legibility floor** — same reasoning as
`def.color`: explicit art-direction is a decision; the floor exists for *derived*
colours, which are incidental.
**BLOCKED/BROKEN** — nothing blocking.
- **The showroom bug I predicted last round arrived, and my fix was half a fix.** Round 3
noted the item hall would overflow when the catalog grew. It did (51 → 60 items), but
the failure wasn't the one I expected: the *machine* hall grew to 5 rows, ate the depth
budget, collapsed the item grid to **one row of 60 columns — a 206-tile row across a
64-tile world**. My round-3 "adaptive" fit budgeted depth only. Now fitted to both axes,
and it `console.warn`s if the catalog ever exceeds the hall rather than silently
stacking items off-world. Lesson: an auto-fit that constrains one axis just moves the
overflow to the other one.
- **"Placed-and-selected" auras aren't reachable** (order 3's third case). `SelectionState`
is `build | remove | null` — UI's click-to-inspect selection has no contract channel, so
a placed cooler rings on **hover**, not on inspect. Hover covers the same intent for
now; see CONTRACT REQUEST 1.
- Two tests fail repo-wide (`sim/reference.test.ts`, `ui/live.test.ts`) — both SIM/UI
in-flight research work, neither imports `src/render`. tsc is clean repo-wide.
**CONTRACT REQUEST**
1. **An inspected-entity channel**, if you want order 3's "placed-and-selected" case for
real. Cheapest shape: extend `SelectionState` with `{ mode: 'inspect'; entity: number }`,
or add `UIBus.inspected?(): number | null`. Renderer has no other way to know what the
player clicked. Low priority — hover already answers "what does this cool?".
2. Nothing else. v4 granted round 3's `setGhostMode` ask (as `setGhost(..., mode)`) and it
landed clean; the sentinel bridge can die in v5 whenever you like — this lane no longer
reads it.
**NOTE FOR THE MODELBEAST ORDER** — the manifest in round 3's NOTES is now stale: DATA
added 6 machines (`roto-bench`, `matte-bath`, `optical-printer`, `chroma-keyer`,
`neural-keyer`, `archaeology-lab`) and the tech tree now gates 13 across all five eras.
`?showroom` is the live manifest — it labels every machine with its exact `<asset>.glb`
filename and era, and re-reads data on load, so it can't go stale the way a table in a
doc does. Read it there rather than from round 3.
**NEXT (round 5 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as
*relief* inside a cooler's rectangle once SIM lands spatial cooling; bloom-loop belt
tell; HF dust piles; wildlife; save/load camera state; LOD/culling if a megabase ever
drags the frame.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**THE HEADLINE: "the game about place has no place" was a COLOR-MANAGEMENT BUG, not a
missing feature.** I wrote the whole floor — fbm terrain, era hinting, seam glints — ran
it, and the screen was still black. The shader was fine. `THREE.Color.setHex()` converts
sRGB → the linear working space, but a raw `ShaderMaterial` gets none of the renderer's
output includes, so the linear value went to the framebuffer untouched. Measured, not
guessed: crust authored `#0c0a15` (RGB 12,10,21) was rendering as **RGB (1,1,2)**; even a
mid-grey `#2a2440` came out (6,5,13). Everything this lane has drawn with a raw shader has
been ~6× too dark **since round 1** — the grid, the crust, all of it. One
`#include <colorspace_fragment>` in `ground.ts` and the floor measures (10,8,18) against
an authored (12,10,21). The void was never a design problem; it was a missing include.
That's why the "before/after" is so stark, and it's the single most valuable thing I found
this round.
**SHIPPED** — all 8 orders.
*Part A*
1. **Inspect highlight** — selection ring + cooler aura on `{mode:'inspect'}`. Verified:
inspecting a 2×2 extractor rings it at its centre with scale 2.5; inspecting the
asic-cooler shows ring **and** its 6×6 coverage rect. This closes the
"placed-and-selected" case round 4 couldn't reach.
2. **Wildlife** (`wildlife.ts`) — dust piles as HF-dust glitter heaps scaling with `size`;
mosquito swarms as pixel-gnat clouds orbiting `target`, with the harassed machine
stuttering (§8: wrongness of motion is a faction uniform). Two draw calls for the whole
ecosystem.
3. **Fixture hygiene** — devscene now fabricates `commissionQueue`, `research`,
`wildlife` and `relics`, plus an asic-cooler and archaeology-lab so the v4/v5 kinds are
reachable in the scaffold.
*Part B*
4. **THE FLOOR** (`ground.ts`, `seams.ts`) — fbm tone mottle + neutral rim + 8 data-driven
seam regions that skin the crust with their era and glint with their ore.
5. **THE RELIC** (`relics.ts`) — unfound = a breathing static patch; found = a fossil on a
plinth. Numbers below.
6. **JUICE** (`juice.ts`) — placement scale-pop + dust puff, demolition burst, shipment
flash. All derived from snapshot deltas, since `render()` never sees events.
7. **THE BREAK ROOM** (`breakroom.ts`) — GLYTCH arcade cabinet, own raycast, `cursor:
pointer` on hover, click → `window.open('./match.html')` **verified firing**.
8. **CAMERA + CLICKS** — default framing lifts world origin to screen y=264, clear of the
build bar at y=679. Picking investigation below — it found more than expected.
**VERIFIED (numbers)**
- **Relic is subtle-but-findable, measured at two zooms.** Brightest pixel on the relic vs
plain floor 7 tiles away: at wandering zoom (12) **+39** — findable. At overview zoom
(30) **18** — i.e. indistinguishable from floor mottle. You cannot spot it from above;
you have to be down there looking. That's the Mario-hidden-pipe budget, in numbers.
- **Perf holds with everything on**: 500 entities + 2,000 belt items + 50 piles + 198
gnats + 8 seam regions + the new floor = **4.4ms/frame**, 3.8× headroom, 8 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 / R5 4.4.)
- **Juice**: puff → 14 particles decaying over ~0.67s; burst → 22; shipment flash fired 22
times tinted `#ff7a3f`, exactly MELT's authored colour — so the flash reads which item
shipped, not just that something did.
- Prod bundle: `devscene`/`showroom`/`plinth`/`window.__render` all 0 hits; the round-5
shipping code (seams, aura, relics, breakroom) present. 446 tests pass, tsc clean.
**DECISIONS**
- **Dropped my own era-rim guess when DATA contradicted it.** The orders asked for strata
whispers toward the world edges and I built exactly that — sepia north, phosphor east.
Then `data/seams.json` landed mid-round, its doc saying "RENDER: `era` skins the ground",
with reel in BOTH the north-west and north-east and disc both north-centre and
south-west. My directional guess would have contradicted the data on screen, so era is
now skinned per seam region with a wide soft falloff. It still reads as strata whispers
toward the rim — the richest seams live out there anyway.
- **Seam glint is flecks, not veins.** First pass drew continuous sinusoidal veins; on
screen they were bright green worms crawling over the floor, competing with the belts.
Mineral glitter is sparse and twinkles, so it's now a fleck grid with per-fleck sparkle
clocks. The body fade is deliberately SHORT (0.8 tiles): SIM extracts on any
footprint/rect overlap, and a glint dying two tiles inside the boundary would
under-report where mining actually works.
- **The cooler aura stays a rectangle** (round 4's reasoning) and the relic monument, the
cabinet and the seams all now carry `colorspace_fragment` for the same reason the ground
does.
- **Cabinet is rotated 45°** to face the iso camera and sized to its own marquee — at the
rig's closest zoom a 0.03-unit marquee pixel was sub-pixel and GLYTCH was a smudge.
**BLOCKED/BROKEN**
- **A frame-delta of 63.7 SECONDS was silently eating all juice.** `clock.getDelta()`
returns the real gap, and a backgrounded tab / alt-tab / GC pause / first frame after
load hands back seconds. Particles have a 0.5s TTL, so every effect was born and killed
inside one update — and a 63s integration step flings them to infinity. `render()` now
clamps dt to 0.05 (main.ts clamps the sim accumulator for the same reason; this is the
renderer's half). This also protects the scale-pop, camera pan and heat animation.
- **`match.html` only exists in `dist/`** (deploy.sh copies it there). In a dev server the
cabinet's click opens a 404. Not fixable from this lane — `public/` is mine but the file
is the deploy's. Flagged so nobody reports it as a cabinet bug.
- The devscene relic sits at (9,-7), which is NOT inside DATA's `relicReserve`
(12,12,20,20). That's fine for a fixture, but SIM's real relic should land in the
reserve — worth a check when SIM lands `relics`.
- The cabinet is decorative and reserves no tiles, so a player can place a machine
"inside" it. Cosmetic only.
**B8 PICKING INVESTIGATION — 27.6% of the viewport cannot be clicked**
I probed a 20px grid over the whole viewport (2,205 points) asking "does a click here
reach the game canvas?". 609 points — **27.6%** — do not. Breakdown:
| blocker | points | note |
|---|---|---|
| `#screen` | 153 (6.9%) | **decorative overlay, `pointer-events: auto`** |
| `#fk-build` + hint | 155 (7.0%) | build bar — legitimately interactive |
| `#fk-fax` + flavor | 90 (4.1%) | fax panel — legitimately interactive |
| `#fk-top` | 36 (1.6%) | bandwidth panel — legitimately interactive |
The one that is a **bug**: `#screen` is THE SCREEN's canvas in `index.html`, purely
decorative, 322×182 at top-centre, `z-index: 5`, and it takes pointer events. `#ui` next to
it correctly sets `pointer-events: none`. So a 6.9% rectangle of prime play area silently
swallows clicks — I hit it myself: the cabinet at (-5,-5) rendered *underneath* it and was
both invisible and unclickable, which is exactly the review's "machines spawned
unclickable". **Fix is one line in `index.html` (orchestrator-owned): add
`pointer-events: none` to `#screen`.** I moved the cabinet to (6,12) as a workaround, but
the dead zone is still there for machines.
Also seen: `#fk-screenview` (LANE-UI's theatre mode) is a full-viewport `pointer-events:
auto` host. Correctly `display:none` when closed — not a bug, noting it so the next person
who probes picking isn't confused by it the way I was.
**CONTRACT REQUEST**
1. **A renderer inspect channel.** `Renderer.setGhost`'s mode is only `build|remove` and
main.ts passes `'build'` during inspect, so nothing about the inspected entity reaches
this lane. `SelectionState` carries it, so I read `window.__fktryBus.selection()` each
frame as a bridge — same shape as the old `'__remove'` sentinel, documented on both
sides, and it wants a real hook: either widen `setGhost`'s mode to include `'inspect'`
with the entity id, or add `Renderer.setInspect(id | null)` that main.ts calls.
2. **`pointer-events: none` on `#screen`** in `index.html` (see above). Not my file.
3. Advisory: SIM doesn't gate extraction to seam rects yet, so the glint is currently a
cosmetic tell rather than a rule. The moment SIM lands the overlap check the floor
already teaches it — no renderer work needed.
**PROPOSAL — the shared tree cost me real time again.** Verification kept failing because
LANE-UI and LANE-SIM save `src/ui/*.ts` / `src/sim/*.ts` every few seconds and Vite
full-reloads my page, wiping every probe mid-measurement (the vite log shows a reload every
515s for minutes). I finished the round by snapshotting the repo to a scratch dir and
running an isolated dev server against it. That works, but it's a workaround for the same
root cause as round 3's commit collision: **one working tree, five writers.** A worktree
per lane fixes both.
**NEXT (round 6 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as *relief*
inside a cooler's rectangle once SIM lands spatial cooling; relic excavation VFX on the
`relicFound` event; bloom-loop belt tell; save/load camera state; LOD/culling if a
megabase ever drags the frame.
### Round 6 — 2026-07-20 — Opus 4.8
**SHIPPED** — all 3 orders, verified in the running build. The second asset wave landed
mid-round: **27 GLBs are now in `public/assets/models/`** (up from the 10 the order named).
Everything below is generic — it runs on *any* hot-swapped GLB through the registry, no
hardcoded list — so all 27 (and whatever lands next) got the treatment automatically.
1. **GLB legibility pass (centrepiece).** The two-material rule died on real assets: baked
albedo, zero emissive, near-black at game zoom (before/after below). Two halves, both
keyed off the SAME accent derivation placeholders already use, so a machine that made
melt still glows melt-orange whether it's a box or a GLB:
- **Texel-following emissive FLOOR** (`registry.ts floorMaterials`, run ONCE on each
GLB template in `tryLoad`): `emissiveMap = map`, `emissive = white`,
`emissiveIntensity = 0.42` — the baked albedo self-illuminates so the brightest texels
lift off the void without washing to flat grey. Materials with no map self-light their
own colour; materials that already ship a glow are left alone. EntityLayer still clones
these per instance, so per-entity heat/scram rides on top (heat lerps `emissive`→red;
the map keeps modulating).
- **Accent BEACON** (`addAccentBeacon`): the one impossible element, perched on top,
scaled to footprint, in the machine's derived/art-directed accent — plus a gentle idle
pulse so a GLB gets a heartbeat like a placeholder. The idle reads `beacon.material` at
call time, not a captured ref, because EntityLayer re-clones GLB materials per instance
(a closure over the pre-clone material would drive a mesh nothing renders — caught that
before it shipped). **DATA's round-6 accents were chosen against the real GLB textures,
so the beacons wear DATA's hand-picked colours.**
- Verified in `?showroom` (before: dark specks on plinths → after: lit chassis + colour-
coded beacons across the whole 27-machine catalog) AND in the live demo world at the
default game frame (`__fktryDemo()` reference factory, `rig.frame(3,6,15)` untouched):
every GLB legible, beacons marking each unit.
2. **Parity mites** (`wildlife.ts`, +2 instanced meshes). Hospital-white faceted beetles
with a green **checkmark eye** (canvas ✓ texture, tilted 45° to meet the iso camera).
The tell tracks SIM's `size` state channel exactly: `0` hunting (slow, patient patrol —
the wrongness is the calm), `0<size<1` on station (dwells, only a tremor; the eye
**glows up** via per-instance `instanceColor` as the correction completes), `size≥1`
sated. render() can't see events, so I derive the two mite cues from snapshot deltas:
- **`repaired`** = a mite crossing `size` <1 1: a tidy sparkle where the product used
to be. Verified deterministically — driving a mite 0.6→1.0 emitted **exactly 4**
particles (one `Juice.tidy`).
- **caught in a firewall = filed paperwork** = a mite that vanishes from the list while
still unsated (sated ones leave at the rim carrying their fix, no cue). Verified:
removing an unsated mite fired the tidy sparkle (juice 4→8).
3. **Excavation VFX** (`relics.ts` + `Juice.excavate`). On the relic's unfound→found
transition, a slow tall golden geyser "sings" up out of the crust as the monument rises.
Gated on a live transition (a save that loads already-excavated doesn't fire it — primed
like EntityLayer). Verified: flipping the fixture relic fired the geyser (juice pool
4→**89**) and built the monument.
**VERIFIED (perf)** — honest caveat: the canonical full-frame CPU-ms sampler could not be
read reliably this round because the browser backgrounds the tab and starves the rAF loop
that drives `layer.sync` (same shared-tree/environment friction round 5 hit), so the
instanced hot-path meshes never fully populated for a passive read. What holds by
construction: the additions don't touch the terms that dominate the frame. Wildlife gained
2 InstancedMeshes (mite bodies + eyes — 2 draw calls, only when mites exist, O(mites) capped
at 96); GLB legibility is one-time material params + one small beacon mesh per GLB *machine
instance*; the 2,000-item / 480-belt instanced path is untouched and no per-frame allocation
was added. Round 5's measured **4.4ms/frame** at full load stands as the reference.
**DECISIONS**
- **Floor is texel-following, not a flat wash.** A flat emissive floor guarantees "never a
hole" but greys the texture to mud and, since most GLBs export colour in the *map* with a
white base colour, would floor-glow everything white. `emissiveMap = map` keeps each
machine's baked colour and lifts its lit texels; the beacon + scene lighting cover the
rare all-dark texel. Trade-off noted honestly: a genuinely pure-black texel stays dark
(correct — dark rubber should read darker than a metal panel); no machine reads as a hole
in practice (verified by eye at default zoom).
- **Beacon geometry is module-shared, material is per-instance.** GLB records don't own
geometry (`ownsGeo=false`) so the shared beacon geo is never disposed; the per-instance
material is (EntityLayer disposes all materials on drop regardless).
- **Mite state is read from `size`, not invented.** SIM documents `size` as the state
channel and `target` as the belt it converges on; I render that contract rather than a
timer, so the tell is true to the sim (hunting drifts, repairing dwells + glows, sated
leaves). The eye-glow ramp uses `instanceColor` (multiplies the checkmark map) — no
shared-material emissive hack.
**BLOCKED/BROKEN** — nothing blocking.
- **The mite "filed paperwork" cue is a heuristic, not the event.** render() has no event
channel, so "canned by a firewall" is inferred from a mite disappearing while `size<1`.
This is correct for the sim's current lifecycle (sated mites leave at the rim, far from
firewalls; canned ones vanish mid-repair near one), but it's an inference — if a mite ever
despawns unsated for another reason, it'd read as a false "filed". A real renderer event
channel (see CONTRACT REQUEST) would make both mite cues and the excavation exact instead
of derived. Low risk today.
- The `firewall` machine (DATA, this round) has no GLB yet, so it shows the placeholder in
`?showroom` — it'll hot-swap + get the floor/beacon treatment the moment its asset lands,
no code change.
- `match.html` 404 in dev (round 5, unchanged): the cabinet's target only exists in `dist/`.
**CONTRACT REQUEST**
1. **A renderer event channel** (renewing round 5's ask, now with two more consumers).
`Renderer` never receives `SimEvent[]`; ScreenFX and AudioFX do. This round I derived
`repaired`, the firewall "can", and `relicFound` from snapshot deltas — all work, but
they're inferences that can drift from truth. Cheapest shape: add
`Renderer.onEvents(events: SimEvent[])` called by main.ts alongside the others. It would
make excavation fire on the actual `relicFound` and the tidy sparkle fire on the actual
`repaired` (with the event's own `pos`), retiring three heuristics. Everything else this
lane does stays snapshot-driven; this is specifically for one-shot cues.
2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator).
**PROPOSAL**
- The GLB legibility pass makes the MODELBEAST pipeline fully hands-off for legibility: any
asset dropped in — textured, untextured, rigged, multi-mesh — lands lit and identifiable
with zero per-asset tuning. The whole 27-machine catalog proves it. Hero re-cuts
(`muxer`, `mosh-reactor`) can arrive without a render change.
**NEXT (round 7 candidates, not self-assigned)**
- Wire the renderer event channel if granted (exacts the three derived cues above); a
`firewall.glb` when it lands; per-mite orientation toward its `target` belt; heat-shimmer
*relief* inside a cooler rectangle; bloom-loop belt tell; save/load camera state;
LOD/culling if a real megabase ever drags the frame.
### Round 6 ADDENDUM — 2026-07-20 — Opus 4.8 — GLB body-scale regression fix
**Orchestrator caught a deploy-blocking regression at 0d491f8: GLB machine BODIES collapsed
to specks (beacons + jam lights + labels still correct). Fixed in `entities.ts` — commit
below. My round-6 verification missed it because I checked on an HMR'd session and wrongly
attributed the small bodies to "asset normalisation"; on a FRESH load the specks are stark.**
**ROOT CAUSE — the placement-pop hardcoded the settled scale to 1, which is only true for
placeholders.** A normalised GLB roots at its fit-to-footprint scale `s` (measured live:
2.03.5 for the current wave). The pop's settle branch was
`else if (rec.obj.scale.x !== 1) rec.obj.scale.setScalar(1)` — so on the very first sync it
saw `2.08 !== 1` and stomped the root to 1, shrinking the body to raw geometry size (~0.63
vs the correct 1.84 for a 2×2). The beacon and jam meshes carry their own local scale on
child nodes, so they were untouched — which is exactly the "bodies vanished, beacons fine"
signature the orchestrator reported.
**Why it surfaced now, honestly:** the pop code is UNCHANGED by me and the bug is latent for
ANY non-placeholder settled scale ≠ 1. It bit this round because the live GLB wave normalises
to s≈23.5 (verified: fresh `entry.create()` returns root scale 2.08 + a correct 1.84-wide
body; the live post-pop root was scale 1 + a 0.63 speck). The pop only ever writes
`rec.obj.scale`, so it was provably the resetter. It is not the beacon — a fresh create is
correct; the pop destroys it one frame later.
**FIX:** the pop is now a MULTIPLIER on a stored `rec.baseScale` (captured from `obj.scale.x`
at build = 1 for placeholders, the normalisation scale for GLBs), never an absolute. Settle
compares against `baseScale`, not 1. Placeholders: identical behaviour (baseScale 1). GLBs:
body stays full-size, and the placement scale-pop still plays (relative overshoot).
**VERIFIED on FRESH loads (hard navigate, no HMR), before/after described:**
- `?showroom`: BEFORE = every GLB a speck on a full plinth; AFTER = all 27 render as full
sculpted bodies filling their footprints, lifted + beaconed. Scene-graph confirms live
roots now carry scale 2.03.5 and body world sizes match footprints (1.84 for 2×2, 2.76
for 3×3), where they read 1.0 / 0.63 before the fix.
- `?uidemo` (reference factory, default game zoom): BEFORE = specks; AFTER = full-size
machines throughout, belts + cargo flowing. No console errors. `npm run check` clean,
`npm test` 481/481.
**LESSON (for me and the next lane):** verify visual regressions on a cold load, not an
HMR'd session — an HMR'd renderer can hold meshes normalised in a prior module state and
mask a fresh-boot break. And a hardcoded `scale = 1` is a placeholder assumption that any
scaled asset (GLB, future LOD) will trip; base-relative is the correct shape.
### Round 7 PHASE 0 (out-of-band) — 2026-07-29 — Opus 5 — the GLBs were dark because the world had no environment
**THE HEADLINE: the orchestrator's diagnosis was right, and I can now put a number on it.
`scene.environment` was `null` for seven rounds. In three.js a material's diffuse term is
`albedo * (1 - metalness)`, so for a metal there is nothing left but reflected surroundings
— and MODELBEAST's catalog is metal: 13 of 27 materials at `metallicFactor >= 0.8`, 16 at
`>= 0.5`, 21 with no metallic-roughness texture to vary it per texel. Re-probed on HEAD
after the orchestrator's 27.3 MB -> 7.0 MB pass and the distribution is UNCHANGED (13/16/21
— that pass recompressed textures and meshes, not PBR params), so every number below still
describes the shipped assets. Round 6's `GLB_EMISSIVE_FLOOR = 0.42` was cosmetics over a
lighting hole. It is now 0.18 and the hole is filled.**
**SHIPPED** — all 4 orders.
1. **`src/render/env.ts` (new) — a procedural environment, ZERO bytes shipped.** A 128x64
half-float equirectangular radiance map built from arithmetic (no asset fetch, nothing
for the deploy to carry), run through `THREE.PMREMGenerator`, assigned to
`scene.environment`. Content per codex §8 — near-black indigo floor keyed off the crust,
cool "bitstream haze" overhead, a horizon band (this is the bit that makes a metal edge
read as metal), and two soft blobs standing in for the key and fill. **The light
directions are PASSED IN from index.ts's real lights**, not copied — the scram-latch
lesson: this module keeps no second copy of another module's constants.
2. **Emissive band-aid RE-TUNED, not retired: 0.42 -> 0.18.** With real IBL, 0.42 is
overexposure — it flattens the shading the environment just bought back and pushes every
chassis toward a self-lit sticker. Kept because a genuinely black texel still needs the
"never a hole" guarantee, and because it is what keeps a machine legible when a brownout
drops tone-mapping exposure to 0.7.
3. **Lazy GLB loading.** `registry.init` no longer awaits `probeAll`. Placeholders are built
synchronously one line above it and main.ts awaits `renderer.init()` before it starts the
frame loop, so the first frame of the game was gated on 27 HEAD requests plus the whole
catalog. **Placement juice suppressed on hot-swap** (`entities.ts`): the rebuild path
could not tell a MODELBEAST swap from a placement, so with lazy loading the entire
factory would have puffed dust and scale-popped ~1s into every boot. A `def` change still
pops — that is a real replacement.
4. **`src/render/registry.test.ts` (new) — 31 tests, this lane's first.** 481 -> **512 repo
tests**. Source-level only: fit-to-footprint normalisation, accent precedence, era lookup,
and the env generator. **Mutation-checked, not just green**: reverting the era tie-break,
changing the 0.92 fit factor, and deleting the `def.accent` short-circuit fail 7 tests
between them.
**WHAT THE ENVIRONMENT ACTUALLY DID — measured, and honestly mixed**
Mean sRGB luminance of each machine body at a fixed showroom close-up frame (`rig.frame(0,
-14, 10)`), sampling the same pixels in every condition. Plain crust reads ~24, so ~24 is
the "this machine is a hole in the floor" line.
| machine | metalness | no env, floor 0 | env, floor 0 | ROUND 6 (no env, floor 0.42) | **SHIPPED (env, floor 0.18)** |
|---|---|---|---|---|---|
| demosaicer | 1.0 | **19.8** (darker than the floor) | 42.4 | 74.9 | **76.9** |
| neural-keyer | 0.96 | **22.8** | 45.4 | 84.5 | **81.4** |
| archaeology-lab | 1.0 (MR tex) | **24.0** | 33.6 | 61.3 | **54.1** |
| mv-extractor | 1.0 | **26.2** | 41.7 | 70.2 | **71.1** |
| p-caster | 0.80 | **26.4** | 40.6 | 62.4 | **62.8** |
| gop-assembler | 0.85 | 35.2 | 51.6 | 72.4 | **73.6** |
| software-decoder | 1.0 | 46.8 | 63.7 | 104.7 | **96.8** |
| mosh-reactor | 1.0 (MR tex) | 58.2 | 72.0 | 86.6 | **90.1** |
| decode-asic | 1.0 (MR tex) | 58.2 | 76.6 | 97.9 | **99.9** |
| *roto-bench (metalness 0, control)* | 0 | 63.5 | 83.0 | 95.2 | **104.2** |
| *field-loom (0.09, control)* | 0.09 | 125.1 | 133.5 | 137.8 | **141.4** |
Read it honestly, three ways:
- **The bug is real and it is exactly where the orchestrator said.** With the band-aid off,
demosaicer measured **19.8 against a crust of 24** — the machine was literally darker than
the ground it stood on. Screenshot before/after of that condition is the starkest pair I
have taken in this lane since the round-5 colour-space bug.
- **The environment is worth the most exactly where metalness is highest.** Env alone lifted
demosaicer +114% and neural-keyer +99%, against +31% for the metalness-0 control
(roto-bench) and **+7% for field-loom at metalness 0.09**. That gradient IS the diagnosis:
the fix tracks the term that was broken.
- **BUT — and this is the honest part — the environment alone does NOT reach round-6
brightness.** Env at floor 0 leaves the darkest machine at 38; round 6 sat at 57. What the
IBL buys is not brightness, it is FORM: shading, a specular edge, and the baked albedo
reading as a surface instead of a decal. The final numbers land within a few percent of
round 6 *because I chose the emissive floor to put them there* — the difference is that
the light is now doing the shaping. Anyone reading only the last column would conclude
nothing changed; look at the screenshots instead.
**DECISIONS**
- **GLB materials get `envMap` assigned explicitly, not inherited from the scene.** Forced,
not stylistic: three.js *overrides* `material.envMapIntensity` with
`scene.environmentIntensity` whenever a material falls back to the scene environment
(`WebGLRenderer`: `material.envMap === null && scene.environment !== null`). Assigning it
per material is the only way the assets can be dosed differently from six rounds of
hand-tuned placeholder art. **I burned real time discovering this** — my first tuning
sweep of `envMapIntensity` from 1 to 5 produced byte-identical output and looked like a
broken env map.
- **Two dials, deliberately far apart: `ENV_WORLD_INTENSITY = 0.5`, `GLB_ENV_INTENSITY =
2.6`.** The world's own materials (belts, cargo, placeholders, ghosts, wildlife, the
cabinet) were art-directed across six rounds under no IBL at all; relighting them wholesale
is a regression dressed as a fix, so they get a dusting for coherence and nothing more.
The assets get 5x that because they are the metals.
- **`AmbientLight` left alone at 1.1 after measuring it.** I lowered it to 0.55 first,
assuming the new environment made it a double-count. Then I swept it: 0.55 -> 1.1 moves a
placeholder body ~7% and the high-metalness GLBs **<1%**, because ambient only feeds the
diffuse term metalness has already zeroed. Reverted rather than ship a change that does
nothing. (Same arithmetic as the headline, seen from the other side.)
- **I did NOT clamp the assets' metalness, though I tested it and it works.** Capping
metalness at 0.45 at load for the 21 materials with no MR texture reaches the same
luminance at `envMapIntensity: 1` and is visually near-identical (I A/B'd both at the same
frame). Not shipped: rewriting an asset's authored PBR values is a bigger claim than
lighting the room it stands in, and it would mask the upstream defect instead of reporting
it. See MODELBEAST FINDING below.
- **The TTFF instrumentation is dev-only and folded out.** `window.__fktryPerf` — 0 hits in
`dist/` (verified), along with `devscene`/`showroom`/`plinth`; the only `__render` matches
are three.js's own `__renderTarget`, as in round 3.
**TIME-TO-FIRST-FRAME — measured, with the caveat stated up front**
`firstAt` (the real first `gl.render`) is **unusable in this environment**: the browser
backgrounds the pane and starves rAF, and I read 5.1s / 11.3s / 14.5s for a page that was
ready in 90ms. Same friction rounds 5 and 6 hit. So the number below is `readyAt` — the
moment `renderer.init()` returns, which is the earliest a frame can exist because main.ts
awaits it before starting the loop. It is also precisely the quantity this change moves.
A/B on the same build, one line flipped between `await this.probeAll()` and `void`:
| | `renderer.init()` duration | ready at (from navigation start) |
|---|---|---|
| blocking (round-6 behaviour) | 284 / 426 / 304 ms | 339 / 483 / 360 ms |
| **lazy (shipped)** | **27.8 / 25.2 / 28.2 ms** | **88.5 / 80.7 / 85.7 ms** |
**~10x off `init`, ~4x off time-to-ready, ~250-400 ms saved — and that is the WARM-CACHE
best case on localhost.** A cold visitor to partly.party/glytch pays the whole catalog over
the network before the first pixel under the old path; under the new one they get
placeholders immediately and the assets stream in behind. The orchestrator's 27.3 -> 7.0 MB
pass and this change compound.
**VERIFIED**
- `npm run check` clean. `npm test` **512/512** (was 481; +31 mine).
- **FRESH hard-navigated loads, not HMR** (round 6's lesson, followed): `?showroom` and
`?uidemo` on :8152, both after the orchestrator's new 7.0 MB catalog landed mid-round.
Zero console errors on both. Live material state confirmed on a cold boot:
`envMap` present, `envMapIntensity 2.6`, `emissiveIntensity 0.18`.
- **No round-6-style scale regression**: after forcing an asset-version bump on all 31
showroom entities, every `rec.pop` stayed settled at 0.22 and every `obj.scale` equalled
its `baseScale` (GLBs at 2.199 / 2.070 / 1.384, placeholders at 1.0).
- **Hot-swap juice suppression, deterministic**: bumping the asset version under all 31
entities emitted **0** juice particles; adding one genuinely new entity on the next sync
emitted **14** (exactly one `puff`). Both cues still work, neither fires on a swap.
- **Hot-swap fires once per asset**: `registry.version` 27, every entry at `version: 1` on a
cold load. (The console shows each line twice — that is the console reader duplicating,
not a double load. Checked because it looked alarming.)
- **Perf**: the environment costs nothing measurable. `?devscene=stress` full-scene
`gl.render` submit at 2800x1800, 111 draw calls / 240k tris: **0.445 ms with the env,
0.467 ms without it, 0.432 ms with it back on** — identical inside noise, and one
prefiltered cubeUV fetch per lit pixel is the whole added cost. Last full-frame CPU sample
1.2 ms against a 16.6 ms budget. Honest caveat, unchanged from round 6: the rolling
`stats.avg` sampler needs a sustained rAF loop and the backgrounded pane will not give one,
so this is a forced-render measurement rather than the canonical one.
**BLOCKED/BROKEN** — nothing blocking.
- **A cross-lane era tie-break was WRONG in this lane, and writing the tests is what found
it.** Round 4's NOTES claim I matched LANE-UI's "first tech to claim an id wins"
(`src/ui/research.ts`). I did not: `era.ts eraIndex` used an unconditional `Map.set`, i.e.
**LAST tech wins**. Left alone, a machine claimed by two techs would get a padlock naming
one era in the build bar and an era tell naming another in the ghost, on the same screen —
exactly the divergence round 4 congratulated itself for avoiding. Fixed, and pinned by a
test. Latent so far only because `data/tech.json` currently has zero duplicate unlocks
across its 25 techs, so nothing was visibly wrong. Six rounds of eyeball QA never had a
chance of catching this; the first hour of unit tests did.
- **The player now sees placeholders for the first ~second of a boot** before the GLBs swap
in. That is the deliberate trade in order 3, and it is the same path a mid-session
MODELBEAST drop has used since round 1 — but it IS a visible change to what a cold start
looks like, so it should be a conscious call and not a surprise.
- `match.html` 404 in dev (rounds 5-6, unchanged).
**MODELBEAST FINDING — two things for the asset lane, both measured, neither mine to fix**
1. **The catalog's metalness is machine-generated, not art-directed.** 13 materials at
`metallicFactor: 1.0` with **no metallic-roughness texture** is what image-to-3D
generators emit by default; codex §8 describes grimy chassis with ONE impossible element,
not chrome. The environment now carries them, but shipping sane metalness (or an MR map)
would let `GLB_ENV_INTENSITY` drop from 2.6 to ~1.0 and would recover the baked albedo
detail the metal term is currently eating. One constant in `registry.ts`.
2. **`archaeology-lab.glb` is half a flat wall, and `mosh-reactor.glb` has a large planar
face.** Visible in `?showroom` as a card standing on a plinth, and quantified: fraction of
vertices inside a single 4%-thick slab — **archaeology-lab 48.3% on Z** (extent
2 x 2 x 0.77), **mosh-reactor 26.2% on Z / 23.8% on Y**, against 7-15% for healthy meshes
(dct-press, mv-extractor, decode-asic). It looks like a backdrop plane surviving the
image-to-3D step. Present in BOTH asset generations, so the 7.0 MB pass did not introduce
it and did not fix it. No renderer change would help: there is nothing behind the plane.
**CONTRACT REQUEST**
1. **A renderer event channel** — renewing rounds 5 and 6 unchanged. Three cues in this lane
(`repaired`, the firewall "can", `relicFound`) are still inferred from snapshot deltas.
2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator).
**NEXT (not self-assigned)**
- Tests for the rest of the lane now that the harness exists — `topology.ts` neighbour
classification and `coords.ts` `centerOf`/`yawFor` are pure and are load-bearing for
everything that has ever looked misplaced.
- A one-line `GLB_ENV_INTENSITY` drop to ~1.0 the moment MODELBEAST ships sane metalness.
- 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

@ -27,33 +27,33 @@ seed stock — port, don't move).
---
## CURRENT ORDERS — Round 3 (goal: continuous dread + spatial artifacts)
## CURRENT ORDERS — Round 7 (Phase 1: the sky is audited, the speaker stays pure)
Round 2 review: the ringing self-rejection ("sterile, but sharper — it flunked your
bar and I nearly shipped it") is the exact quality instinct these orders exist to
buy. Verdicts: CONTRACT REQUEST GRANTED — `frame(timeMs, snap?)` is in v3 and main.ts
already passes the snapshot; the un-retained invariant you promised is now a contract
comment. Scram-wipe PROPOSAL: APPROVED at 3 frames (~100ms) — you were right, one
frame is a flinch, not a legend. Regional-moiré PROPOSAL: APPROVED as this round's
centerpiece.
Contracts v9 events: `mirrored`, `stasis`, `debtBand`, `notice`.
1. **Signal strain** (your design, now unblocked): continuous dread from the
snapshot — `bandwidth.stored` draining toward zero = rising tremor/desync judder
BEFORE the brownout seizure; aggregate entity `heat` = a slow feverish shimmer.
Keep it subordinate to the corruption ledger: strain is weather, shipments are
climate.
2. **Regional moiré**: grow the lattice from a region instead of full-frame (the
codex has it spreading as maze walls) — spatial mask, seeded deterministically,
creeping outward with tally. This is the template for future spatial artifacts;
note the mask approach for reuse.
3. **Scram wipe at 3 frames**, as approved.
4. **Idle strain baseline**: with zero corruption AND healthy bandwidth, the feed
stays perfectly sterile — verify strain can't leak into the NOTHING IS WRONG era
(a test pinning params at zero under a healthy snapshot would do it).
1. **`mirrored` subtracts from the ledger** — the gunship steals corruption the same
way `repaired` does (your shipped decrement path); reuse it, don't fork it. A
mirrored theft also stamps a small hospital-white sector outline placard (distinct
from the mite's green tick — this one is architectural).
2. **Stasis on the sky**: while any `stasis` rect is on, the feed gets a subtle
freeze-adjacent treatment — timecode stops advancing, drift pauses — WITHOUT
triggering the brownout seizure vocabulary. Release resumes exactly where it
stopped (the sky was never damaged; it was administered).
3. **THE COMPLIANCE TONE (the audio ruling, verbatim):** The Correction never speaks —
it faxes. Its only sound: on `notice` and on warden-arrival, ONE voice of the
ratifySting triad, alone, struck on a fixed Hz grid — zero vibrato, zero drift,
zero analog imperfection. You cannot record a tone with no imperfection; you can
only generate one. That is the house law making its own argument. CONSTRAINTS:
gate on `discovered` exactly as tapeChunk is (pre-relic silence guarantee is
structural and measured); do NOT create a continuous tone — ratifySting's rarity
is its punchline and a frequent second 'only clean sound' would demote it. One
note per event, sparse by construction.
4. **Debt band ambience**: at band>=3 the sky's substrate gains the faintest sterile
vignette (reuse the pallor machinery, white instead of grey); gone below band 3.
**Definition of done:** strain visible as stored drains (drive it by pausing the
reference factory's generators or just feeding a fixture snapshot), regional moiré
grows rather than switches, wipe readable, budget still <2ms, tests updated.
**DoD:** mirrored arithmetic pinned (ledger before/after); stasis freeze
byte-identical on resume; compliance tone spectrally verified (single partial, zero
sidebands) and pre-discovery silence still 0.0/0.0; budget holds.
---
@ -298,3 +298,451 @@ piles** are the obvious next spatial artifacts (both are regional in the codex),
biomes in §1 would suggest a mask per era. Also, now that strain reads the snapshot, THE SCREEN
could react to `jammed` entities (a stalled factory *should* look bored — the feed drifting back
toward clean is a nice punishment), but that's a design call, not a lane call.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED: all 4 orders. Thanks for granting capacity unasked.
- `boredom.ts` + `boredom.test.ts` (new) — pure freshness curve, 7 tests.
- `strain.ts` — second dread axis; `strain.test.ts` now 20 tests. Screen suite 57 green.
- `overlays.ts` — ratification card painter + era→standard table.
- `glitchStack.ts` — pallor pass (desaturate + vignette), card composite.
- `index.ts` — boredom wiring, card lifecycle, `researched` handling, pallor easing.
DESIGN INTENT, one line each (as ordered):
- **tremor = "how many seconds until dark?"** ACUTE. The cliff edge. Only exists while something
is actually draining, and it spikes as the runway runs out. Expressed as motion: judder.
- **pallor = "how much cushion do I have at all?"** CHRONIC. Sits there in surplus. Expressed as
colour: the saturation drains out and the corners close in.
A full tank draining fast is a jolt; a near-empty tank coasting is a way of life. Different
questions, different senses, so they never mush into one dial.
DECISIONS:
- **Pallor drains saturation on purpose.** Codex §8: "ALL saturation belongs to media/artifacts.
Color = value = literally the resource." A factory with no reserve should therefore literally
have less colour. The vignette does the rest. It fits the existing strain/fever/seizure
hierarchy as the quiet one: chronic, slow tau (2.0s), never louder than the corruption.
- **The capacity trap**: `1 - stored/capacity` with no tanks built reads as "totally empty" and
would have painted the boot state grey — blowing up round 3's sterile guarantee on day one.
No tanks = no cushion to miss = no pallor. That's not a special case, it's the honest reading:
you cannot be low on a reserve you never built. Two tests pin it (capacity 0 AND undefined).
- **Boredom decays the DISPLAY, never the ledger.** `fresh` scales the targets; the tally
underneath is untouched. Verified explicitly: after 4 idle minutes the sky is clean but
`units: 72, tally: {melt: 60}, total: 0.893` are all exactly as earned.
- **Boredom horizon: 60s grace, then 180s decay (4 minutes to clean).** You asked for minutes,
not seconds. A full minute of silence changes literally nothing, so a jam you notice, diagnose
and fix is free. Recovery rides the normal 1.2s shipment ease — against a 4-minute decay that
reads as a snap, and it reuses the wave feel rather than inventing a second motion.
- **The card is timed in SECONDS, not frames** — it's a broadcast interruption and must be one
second at any refresh rate. Verified 30 frames @30fps AND 60 frames @60fps. (Deliberate
contrast with the scram wipe, which stays frame-counted: that one is a tape artifact, and tape
artifacts are quantized to frames. Two different clocks for two different fictions.)
- Card composites dead last in screen space, so not one pixel of it is corrupted — that IS the
joke. Hard cut on exit, no fade: the corruption crashes back rather than seeping, because a
dissolve would make The Correction look negotiable.
- Card says the STANDARD, not the tech id (codex §1 names: OPTICAL FILM, NTSC / PAL ANALOG,
MPEG-2 / DVD, H.264 / HEVC / AV1, DCP) with the green tick from §8's Correction uniform. The
tech id is in the small print where a compliance notice would put it.
VERIFIED (fixture snapshots, pixel reads, contact sheet):
- **Sterile guarantee survives the new axis**: boot AND a tankless factory in surplus both render
byte-for-byte identical to no-snapshot, pallor 0.
- **Two dreads are separable at the pixel level**: full-tank-draining-fast → tremor 0.50, pallor
0, saturation 0.621 and corners 96 both *unchanged from healthy*. Empty-tank-coasting → tremor
0, pallor 0.972, saturation 0.500, corners 26.6. MAD between the two: **43.7**. They do not
look like the same emergency.
- **Boredom**: untouched at 60s → fresh 0.834 at 90s → 0.5 at 150s → 0 at 240s → stays 0. Ledger
intact throughout. Resume: freshness 1 instantly, picture home in ~1.5s.
- **Card**: 30 frames @30fps, 60 @60fps = 1.00s both. Mean luminance 83 → 235 → 83.
- **Budget: 0.078 ms/frame** with GPU sync, 2000 entities, every artifact + both dreads + fever +
brownout + card compositing. ~26× under.
- Note for the record: **tremor is invisible in a still** — it's motion, so the contact-sheet tile
looks like HEALTHY. I pinned it numerically instead (0.50, MAD 6.8 vs sterile). A still frame is
the wrong instrument for a judder and I didn't want that filed as "verified by screenshot".
BLOCKED/BROKEN: nothing in my lane. **One thing the fleet needs to know:**
- `npm test` currently shows 6 red in `src/ui/live.test.ts` ("reference factory should contain a
software decoder", commission/entity-count assertions). **Not mine, and not LANE-UI's fault
either**: LANE-DATA has uncommitted edits to all five `data/*.json` in the shared tree, and
UI's live tests build a reference factory out of that data. I confirmed by stashing *only*
`src/screen/` — the same 6 still fail with my lane entirely absent.
- **Warning about the shared working tree**: my first instinct was `git stash`, which stashed
every lane's uncommitted work, not just mine — and made the UI tests "pass", which nearly had me
believing I'd broken them. `git stash push -- src/screen/` is the only safe form here. Any lane
bisecting a red test in this tree can be badly misled by a bare stash. Worth a line in
MASTERPLAN if you agree.
CONTRACT REQUEST: none. v4 gave me everything; fixtures now carry `commissionQueue` and
`research` ahead of the v5 hardening (order 4 done).
PROPOSAL: boredom currently only listens for `shipped`. A factory that is *jammed solid* and one
that is *idle because the player is redesigning it* decay identically. You approved the stalled-
factory idea as design — if you want the distinction, `jammed` entities in the snapshot could make
the sky decay noticeably faster (the work is rotting, not resting) while a clean idle stays
gentle. That's a one-line read of data I already have. Say the word and it's a small round-5 item.
NEXT (not self-assigning): the era→standard table means THE SCREEN now knows about eras — the §1
strata skins (sepia Reel, phosphor Broadcast, rainbow-on-black Disc) could drive the *base feed's*
palette per the player's current era, so the clean programming itself ages as they tech up. That
plus the mask template would give era-flavoured biomes on the sky.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
SHIPPED: all 8 orders, both organs. THE SPEAKER exists.
- PART A: `eras.ts` + `eras.test.ts` (new), `baseTexture.ts` (era substrate + general chyron),
`glitchStack.ts` (wildlife pass, title composite), `boredom.ts` (jammed-rot), `index.ts`.
- PART B: `graph.ts`, `sfx.ts`, `stations.ts`, `radio.ts`, `ambience.ts`, `subtitleBus.ts`,
`band.test.ts` (all new), and `index.ts` replacing the stub.
- 78 tests in my two lanes, all green. Repo-wide 443/443, tsc clean.
**A NOTE ON "VERIFICATION INCLUDES LISTENING": I cannot hear.** I have no ears, and I won't
pretend a waveform is a listening session. What I did instead is render every sound through an
`OfflineAudioContext` and measure it — spectra, peak/RMS, attack times, onset rates — and the
description below is derived from those measurements plus the synthesis I wrote. It is an
honest account of what the numbers say the speaker is doing, not of what it feels like in a
room. **Please put headphones on and tell me what's wrong** — mix balance and "is this
annoying after ten minutes" are exactly what measurement can't answer, and I'd rather get that
from you than guess.
WHAT IT SOUNDS LIKE (measured, then described):
- **Brownout** — the important one. A 0.29 ms attack: a genuine broadband click, not a fade.
Then ~0.5 s of bandpassed brown crackle and a sawtooth whine sliding 420→40 Hz under a
closing lowpass. It should read as a rack losing its rails: snap, spit, sag.
- **Placement thunk** — 2.3 ms attack, a sine dropping 150→55 Hz (the mass) under a 1.4 kHz
noise slap (the contact). Detuned per placement so a row of belts isn't a machine gun.
- **Scram klaxon** — 620/440 Hz square alternating four times, lowpassed at 1.8 kHz. Peak 0.29:
deliberately not the loudest thing, because it fires often.
- **Ratification sting** — 85 ms attack, FAC sines with an octave shimmer. The *only*
consonant, in-tune, untroubled sound in the game. Everything the player makes is filth; the
enemy sounds lovely. That's the joke, and it's why its attack is slow when everything else
is percussive.
- **FKTRY FM 88.1** — 4.9 note onsets/sec against 6.13 sixteenths at 92 bpm, i.e. the ~34%
rests are audible as space rather than a wall. Square lead over triangle bass, pentatonic
minor, pattern mutating every 32 bars. Spectrum is bass-dominant (60 Hz: 0.49) with nothing
above 4 kHz.
- **THE STANDARD 640** — 2.9 syllables/sec, which is real speech cadence. Ring-modulated
carrier through two moving formants, lowpassed at 4.5 kHz to AM bandwidth. Sits at 500 Hz
and nowhere else. It is deliberately just short of intelligible — you can hear that it's
speech and not what it says. **THE SCREEN tells you what it says.** That gap is the trick.
- **NUMBERS 6955** — 4.0 digits/sec, a formant voice with a per-digit vowel shape so numerals
are learnable by ear, through a narrow SW bandpass (sharp 2 kHz peak, 0.50). No affect, no
melody, no acknowledgement.
- **DEAD AIR 108.0** — lowpassed brown room tone breathing at 0.21 Hz (projector pace), plus
one reversed chime — swelling up out of nothing then cut dead — every ten minutes, seeded.
DECISIONS:
- **Everything takes `(ctx, dest, t0)` and nothing assumes a live context.** That one choice is
why the whole speaker can be rendered in an `OfflineAudioContext` and measured deterministically
— same code the player hears, no test-only path. Noise buffers use a seeded PRNG for the same
reason: identical spectra every run.
- **The limiter is a `tanh` WaveShaper, not a DynamicsCompressor.** A compressor's makeup gain
would pump the entire mix every time the klaxon fires. tanh just refuses to exceed 1, which is
what "don't clip" actually means, and it adds the saturation a blown factory PA should have.
- **Pre-discovery silence is structural, not a volume setting.** The radio's master gain is 0
and ambience is never started until `relicFound`. Layer 1 is all one-shots, so a quiet factory
is *actually* quiet between events rather than humming under a noise floor.
- **Inter-station noise is band-specific** because that's what makes a band feel like a place:
AM brown crackle, FM white hiss, SW a wandering bandpass plus a heterodyne whistle whose pitch
is the beat frequency — so it swoops *down to nothing* as you zero in. Hunting is the mechanic.
- **The era ages the substrate, never the corruption.** Same shader, same params; only what
they're painted onto changes. The contact sheet shows identical melt drips across all five eras.
- **Boot era is `reel`.** With nothing ratified the world starts in the deepest stratum and
climbs to the surface (stream) and beyond (fortress). The old SMPTE look is now mid-game.
- Title timed in seconds (1.53 s hold, gone by 2.07 s), awakening 0.47 s. Both are *moments*;
the scram wipe stays frame-counted because it's a tape artifact. Three clocks, three fictions.
VERIFIED (offline render + pixel reads + contact sheets):
- **Pre-discovery silence: peak 0.0 and RMS 0.0, exactly zero**, with the dial swept across all
three bands and ambience asked for max load+fever. Same graph post-discovery: peak 0.285.
- **Station lock vs static are distinct spectra, and correctly so per band.** AM static has
**70× the 60 Hz energy of AM locked** (1.40 vs 0.02 — the brown crackle). FM *inverts*: locked
is bass-dominant (0.49 @60 Hz, nothing @14 k), static is treble-only hiss (0.10 @14 k, nothing
@60 Hz). SW locked shows a 2 kHz formant peak (0.50) that static (0.09) lacks.
- **Subtitles end-to-end**: tuned AM → The Correction's line on the chyron; off by 4 kHz (lock
0.906) → still captioned; off station → caption clears; SW → `5 3 0 7 9` on the sky, which is
the real coordinate for the nitrate vein. FM and DEAD AIR caption nothing (no words). Screenshot
in the sheet.
- **Era contact sheet**: reel (sepia + sprocket holes), broadcast (phosphor + interlace), disc
(rainbow-on-black gloss), stream (classic SMPTE), fortress (blinding white) — all under the
same 20× melt.
- **Budget: SCREEN 0.040 ms/frame** (GPU sync, 2000 entities, every artifact + wildlife + strain
+ brownout). **SPEAKER 0.0063 ms/frame** on the worst case (SW numbers + heterodyne + ambience)
— the per-frame path only writes AudioParams; Web Audio does synthesis on its own thread.
BLOCKED/BROKEN: nothing blocking.
- **I caught a balance bug by measuring rather than hearing**: DEAD AIR rendered at RMS 0.072,
*louder than the music station* (0.032), which made "dead air" the loudest thing on the dial —
exactly backwards. Dropped its tone gain 0.32→0.10 and the breath depth with it; it now sits at
0.023, under the music. This is the clearest argument for you actually listening: I only found
it because I happened to print RMS side by side.
- Harness gotcha, mine not the product's: importing a module with a `?v=` cache-buster creates a
**second module instance**, so my first subtitle test wrote to a different singleton than THE
SCREEN reads and the chyron looked broken. Verified `busQ !== busC`. Re-ran canonically and it
works. Same family as round 3's clock rewind: the harness lied, not the code.
FINDING FOR LANE-DATA (not touching their file): `stations.json`'s `_doc` says the decoys
"decode to empty ground", but **two of them decode onto real seams**`08127` → (-24,-20) lands
in `reel-beds-deep`, and `27503` → (-5,18) lands in `broadcast-shelf-south`. Both are visible,
non-hidden seams so no secret leaks and the treasure hunt is intact — it's a doc/data drift, not
a break. I deliberately did **not** red-light my suite over another lane's content (round 4's
lesson); my test asserts the invariant that actually matters — *no decoy ever leaks a HIDDEN
seam* — which passes. Their three planted groups all decode exactly right, and I have a test
pinning that against `seams.json` so the map can never drift.
CONTRACT REQUEST: none. v6 covered everything.
SANCTIONED IMPORTS (documenting as ordered): LANE-UI's tuner dock should
`import { getRadio } from '../audio'` — a narrow surface of setBand/setFrequency/setVolume/
setMuted/getState/subscribe/listStations, nothing else reachable. Returns `null` before the first
user gesture builds the graph, so the dock must handle that (and should probably render disabled
until `getState().unlocked`). Internally, `src/screen` reads `src/audio/subtitleBus` — both are my
lane, and it's a zero-dependency module precisely so neither organ couples to the other's guts.
STATIONS SCHEMA: I conformed to LANE-DATA's file exactly as written rather than proposing my own
— it's better than what I'd have specified (the `eraFlavor` table and the coordinate encoding
especially). Nothing to agree; it's already right.
PROPOSAL: THE STANDARD renders at RMS 0.0037 against FKTRY FM's 0.032 — ~9× quieter. That's
*correct* for gated speech (its peak, 0.10, matches the other stations; the gap is all the silence
between syllables) but it may still feel too quiet in practice next to continuous music. If it
does when you listen, the fix is a slow compressor on that station alone rather than a gain bump,
which would just make the syllables shout. Flagging rather than pre-emptively "fixing" something
the numbers say is fine.
NEXT (not self-assigning): the tuner has no UI yet — LANE-UI's dock is what makes the band
findable at all, and until then the radio is only reachable from the console. Also `growthMask`
now has two customers (moiré, wildlife) and is ready for a third: HF dust piles are regional in
the codex and `dust-pile` wildlife already arrives in the same event I'm consuming.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED: all 3 orders. The sky now notices The Correction. `growthMask` gained its third and
fourth customers (order 3 round-3 groundwork paying off — no new shader machinery, new weights).
- `overlays.ts``paintRepair()` + `repairCanvas` (224×112). The Correction's uniform: hospital
white, faction green tick, machined square. Deliberately the inverse of the shipment stamp
(hand-thumped, crooked, chip-coloured, "SHIPPED / NO QUESTIONS").
- `glitchStack.ts` — two additions: the **mite sterility** pass (`u_mite`/`u_miteSeed`, reusing
`growthMask`) and the **repair placard** composite (`u_repair`, texture unit 5). The mite pass
sits on TOP of the corruption stack because it is *erasure*, not another artifact.
- `index.ts``repaired` events ease the ledger and queue a placard; parity-mite tracking drives
the sterile creep (snapshot truth / event fallback, same pattern as the mosquito swarms); repair
placard lifecycle in its own slot. DEV hook gained `.mite()`, `.repair()`, and — new — `.frame()`
+ `.canvas()` drivers (see the harness note below).
- `corruptionMap.test.ts` — +3 tests pinning the repair semantics. Screen suite 33 (was 30);
my two lanes together 81 green (screen 72 + audio band 9).
DECISIONS:
- **Repairs are THEFT; boredom is ROT — kept structurally distinct, per the order.** A `repaired`
event decrements the *ledger* by exactly one unit of the item (units drop by that item's
potency — pixel-verified: melt ×20 → repair → units 24.0→22.8, tally 20→19). Boredom only fades
the *display* and leaves the tally intact (recoverable). So a factory being eaten by mites
permanently loses corruption it can never fade back, while an idle factory snaps straight back
the moment it ships. I deliberately do NOT refresh the boredom clock on a repair — a factory
being quietly corrected is not a factory that is producing.
- **The mite patch is the inverse of corruption and it reads WORSE, as ordered.** Where the
mosquito shimmer *darkens* a region with crawling noise, the mite patch *desaturates and lifts
toward clinical white* with a faint manufactured tile grid. Pixel-verified against a heavy melt
feed: the bloom region went luminance 22.9→168.8 and saturation 0.695→0.025. A dead, too-clean
hole in your rot is more unsettling than more rot — the codex's whole point about The Correction.
- **The repair placard parks low-right (vUv 0.80,0.80), small (0.15×0.082 half-size), 0.55s** so it
never fights the centre shipment stamp — a burst of ships and a burst of corrections can co-occur
and stay legible as two separate voices.
- Both new spatial effects reuse `growthMask` verbatim (round-3 template): moiré, mosquito, AND
now mite sterility all share one fbm growth front. Zero new shader machinery for order 2.
VERIFIED (drove the REAL module on the REAL 640×360 canvas, synthetic timebase, GL pixel reads —
the harness tab is `hidden` so rAF is throttled, the round-2/3 gotcha; I pump `frame()` and
`drawImage` in the same JS task):
- **Repair**: units eased by exactly melt's potency (1.2); placard region flips corrupted
(lum 68) → hospital-white placard (rgb 228/232/228, lum 230). repair flag + tally decrement both
confirmed.
- **Mite creep**: localized bloom (grid cell 3,4 — NOT the whole frame), lum +146, saturation
→0.025. `mite` eases to 0.83 from size 0.85. Both source-of-truth paths exercised: event
fallback (`.mite()`) AND `snapshot.wildlife` parity-mite.
- **Chyron / order 3**: DATA landed 8 mite-acknowledging STANDARD lines (30→38). Pixel-checked the
live wrap: short lines 1 row, the 96-char lines wrap to exactly 2 rows, rightmost glyph at x613
inside the 640 canvas — no clipping. Word-wrap (orchestrator's fix) intact; I did not touch it.
The mumble is untouched — I never edited `amPropaganda`; it derives syllables from the line text.
- **Budget: 0.034 ms/frame** worst case — 2000 entities, corruption total 1.0, strain 0.94 +
fever 0.57 + pallor 0.82 + mosquito 0.89 + mite 0.89 + brownout, every param lit. ~59× under the
2ms budget. The two new shader branches and one new texture are free at this resolution.
BLOCKED/BROKEN: nothing in my lane.
- **Transient full-suite red, NOT mine (round-4 lesson holds).** A combined `npm test` briefly
showed 3 reds — `band.test.ts` (STANDARD line >96 chars), `sim/mite.test.ts`, and
`sim/reference.test.ts`. All three are the shared-tree race: DATA was mid-save on
`data/stations.json` (I caught it at exactly 96 chars, its own budget) and SIM has uncommitted
`mite.test.ts` + `constants.ts`/`index.ts` edits in flight. Re-running JUST my lanes: 81/81
green. I did NOT bare-`git stash` to chase it (my own round-4 near-miss).
- HEAD advanced under me twice mid-round (a `[ui] round 6` commit landed while I worked). Re-probed
before writing this; all claims above are against the tree as it stands now.
HARNESS NOTE: I added DEV-only `.frame(ms, snap)` and `.canvas()` to `__fktryScreen`. Round 2/3
both lamented having no clean way to pump the real module in a throttled hidden tab; this makes
pixel verification a two-line console call for any lane. Stripped from prod (`import.meta.env.DEV`).
CONTRACT REQUEST: none. `repaired` + `parity-mite` (v8 contracts) gave me everything.
COORDINATION (order 3, resolved): DATA owns the STANDARD copy; I own its rendering. The 8 new
lines are theirs and are already in `data/stations.json`. My only ask of them is a standing one:
please keep STANDARD lines ≤96 chars — `band.test.ts` asserts it and the chyron wraps to 2 lines
at that budget; a longer line spills to a 3rd line (the font steps down to 13px) but starts to
crowd the feed. The newest line is exactly 96, so we're at the edge.
PROPOSAL: the repair placard and the ratification card both wear the green tick — that's
intentional (one faction, one uniform), but a player seeing their first repair might read the
small placard as "another commission done" rather than "something took my work". If that lands
ambiguous in playtest, the fix is a one-word change to the placard legend ("CORRECTED" is already
doing the work, but "REVERTED" or "RECLAIMED" is colder). Flagging, not pre-emptively changing.
### Round 7 (Phase 1) — 2026-07-29 — Opus 5
SHIPPED: all 4 orders. The Correction can now reach the sky, and it still can't sing.
- `overlays.ts` — the repair placard slot generalised into **the Correction's placard slot**
(`PLACARD_W/H`, `placardCanvas`) with two painters: `paintRepair` (unchanged) and the new
`paintMirror`. Renamed `repair*``placard*` through `glitchStack.ts` and `index.ts` so the
sharing is legible in the code rather than only in a comment. Contained to my lane (grepped).
- `index.ts``correct(item, kind)`: the ONE decrement path, called by `repaired` and
`mirrored` alike; the feed clock + stasis holds; the debt band; DEV `.mirror()`, `.stasis()`,
`.band()`, and `feedSec`/`held`/`holds`/`sterile`/`band`/`placard` in `.stats()`.
- `glitchStack.ts``u_sterile` pass; `held` on `DrawState`.
- `baseTexture.ts``held` flips the transport legend and joins the repaint cache key.
- `strain.ts``sterileForBand()` + `STERILE_BAND`, pure and tested.
- `sfx.ts``RATIFY_TRIAD` and `COMPLIANCE_HZ` exported; `complianceTone()`.
- `compliance.ts` + `compliance.test.ts` (new) — the warden-arrival tracker, pure.
- Tests: screen 78 (was 72), audio 18 (was 9) = **96 green** across my two lanes.
DECISIONS:
- **The mirror shares the decrement path AND the overlay slot.** Order 1 said reuse, don't fork;
I took that past the arithmetic. One `correct()`, one placard canvas, one queue. So a repair
and a mirror landing together QUEUE rather than overlap — which is right: it is one faction
filing two forms, and two Correction placards fighting for the same corner would read as a bug.
The kind picks the painter and nothing else. There is a test pinning that a sector theft and a
unit theft move the ledger by exactly the same amount, which is the thing a fork would break.
- **The mirror placard is ARCHITECTURAL, the mite's is CLERICAL.** No green tick on the mirror —
a tick means a person checked something, and nobody checked this. It gets a drafted sector
rectangle with corner registration marks, a crosshair and the sector's doubled diagonal, in
graphite with the faction green reserved for the outline. Same hospital white, same authority,
different department: a mite signs a receipt, a gunship redraws the map.
- **Stasis is a CLOCK, not an effect.** I introduced `feedSec` — the sky's own clock — and moved
the shader's `t`, the timecode, the drift and the mature-corruption wobble onto it. Stasis just
stops it accumulating. That is why the freeze is exact rather than approximately exact: there
is no "restore the frozen state" code to get wrong, because nothing ever left. It also means
the picture holds *completely* (the corruption stops crawling too), which is what "administered"
should look like — a hold, not a stutter. None of the brownout's freeze/dropout/judder
vocabulary is touched; measured `brownout 0`, `freeze 0` throughout a hold.
- **Holds are keyed by `lockHash`, in a Set.** Two auditors holding at once means one releasing
cannot restart the clock under the other's feet. INTEGRATION NOTE FOR SIM: the `on:false`
event must carry the SAME `lockHash` as the `on:true` that opened it, or that hold never lifts
from the event path (the snapshot phase fallback would still rescue it).
- **The transport legend flips `PLAY ▶``HOLD ‖` while held. This is my addition, past the
literal order** — flag it if you disagree. A totally still picture with no tell reads as a
crashed rAF, not as an administered pause, and `HOLD ‖` is the camcorder's OWN word for a
deliberate stop. It costs one glyph, it is sterile, and it is not seizure vocabulary. It is
also the only pixel difference a hold makes: measured at 855 px in one box at (21,290,79,20).
- **THE COMPLIANCE TONE is A4 = 440.000 Hz**, the middle voice of the ratify triad — and the
pitch the entire industry agreed to calibrate against. Of course that is the note it picked.
`frequency.value` is SET, never ramped; one sine, no detune, no `hash01`, no LFO, no filter,
no noise layer, no second oscillator to beat against. The envelope is the only moving part and
it moves slowly (50 ms attack) precisely so the spectrum stays a single partial. Peak 0.14
against the full sting's 0.216: one voice must sit UNDER the triad or the sting stops being
the bigger event.
- **Sparseness is enforced in three places, not one**: gated on `discovered`, one strike per
frame however many wardens arrive, and a 1.2 s floor. The tracker reports every arrival
honestly and the call site is what refuses to make a chord out of them.
- **The warden tracker runs pre-discovery too, and only the STRIKING is gated.** Otherwise a
player digging up the fossil while a warden is already stood on station would be greeted by an
arrival note for an arrival that happened minutes ago. Measured: it stays silent.
- **The debt band is the pallor machinery with its sign flipped** — same radii, same falloff,
white instead of grey. Pallor is the corners closing into darkness because you are poor; this
is the corners washing out under institutional light because you are being LOOKED AT. Two
steps (0.65 at SEALED, 1.0 at REFUSED) rather than a ramp: with only two rungs above the
threshold, a ramp would just be a dimmer.
VERIFIED (real module, real 640×360 canvas, synthetic timebase, GL pixel reads; audio through
`OfflineAudioContext` with a Hann-windowed DFT, plus the real `AudioFX` instrumented):
- **Order 1, the arithmetic, pinned**: melt ×20 → units 24.0 / total 0.560. `repaired` → units
22.8 / tally 19 / total 0.546. `mirrored` → units 22.8 / tally 19 / total 0.546. Delta 1.2000
both, which is melt's potency exactly. Byte-equal stats, not "close".
- **Order 1, the placards are two documents**: both hospital white (region luminance 227.7 and
226.6 against the corrupted feed's 70.2), MAD **20.97** between them inside the placard, 2.66
everywhere else. Contact sheet screenshotted: MIRRORED reads as a plan, CORRECTED as a receipt.
- **Order 2, byte-identical, five ways.** Against a control run at an identical feed time:
every frame during a 5-second hold is one frame; release is byte-identical to the moment the
hold began AND to the control at the same feed time; the first frame after release is
byte-identical to the control's next frame. 5 s of audit cost the feed exactly 0.0000 s.
Nested holds: two on, one released → clock still stopped; last released → clock resumes.
Snapshot-only path (a save loaded mid-audit, zero events): froze and released identically.
- **Order 3, spectrally**: single partial at 440.00 Hz. Harmonics 28 at **132 to 163 dB**
(the double-precision floor). The other two triad voices and the shimmer octave at 110 to
132 dB — it really is one voice. Skirt 3.2/13/37.3/55.6/73.7/97.6/133.8 dB at
±1/2/5/10/20/50/200 Hz, **symmetric to 0.1 dB and exactly the Hann window's own response**;
sweeping 60 Hz12 kHz, the ONLY points above 60 dB are four inside ±10 Hz of the partial.
Zero sidebands, and the symmetry is the proof (AM/FM would be asymmetric or discrete).
Measured pitch in four consecutive windows: 440.00, 440.00, 440.00, 440.00 — **wander 0.000 Hz**.
Two renders bit-identical: generated, not recorded, which is the whole argument.
- **Order 3, gating, on the REAL module** (counted every `createOscillator` and its type/pitch):
notice pre-relic → **zero nodes created at all**; post-relic → exactly one `sine@440` and
nothing else; four more notices inside the gap → zero; after the gap → one. Hammered with
146,669,332 notices across 6 s of audio time → **exactly 5 tones, the theoretical maximum**.
It cannot become continuous. A warden already on station at the moment of discovery → zero
(the `square@440` in that window is FKTRY FM's lead; a no-warden control produced it too).
- **Order 4**: bands 0/1/2 render **byte-identical** — the sky wears nothing below SEALED.
Corner luminance 134.8 → 158.0 (SEALED) → 170.5 (REFUSED); corner saturation 0.453 → 0.259 →
0.201; **centre luminance 79.1 at every band** (a vignette, not a wash). The snapshot path and
the `debtBand` event path produce the same hash. On a corrupt sky the eleven params are
byte-identical at band 0 and band 4 — the band never touches the corruption.
- **Budget**: CPU rolling 0.0160.027 ms/frame worst case (2000 entities, total 1.0, strain 0.997
+ fever 0.849 + pallor 0.99 + sterile 0.993 + mosquito 0.95 + mite 0.95 + brownout + placard +
scram + a hold). Wall-clock with a forced `readPixels` sync: 0.495 ms — but a STERILE frame
measures 0.488 ms on the same instrument, so ~0.49 ms is the sync itself and the v9 work is
below the noise floor. Round 6 (no v9) measured 0.510 on the same rig, i.e. indistinguishable.
BLOCKED/BROKEN: nothing in my lane.
- `npm test` has **2 red in `src/sim/reference.test.ts`** ("still shipping melt at 20,000 ticks",
"keeps the demuxer alive"). NOT mine and structurally cannot be: SIM has uncommitted
`constants.ts` / `index.ts` / `reference.ts` (+ a `zzscratch.test.ts`) in the shared tree
landing the Correction, and `src/sim` imports nothing from `src/screen` or `src/audio`
(grepped, rather than bare-stashing — round-4's lesson). My 96 are green; tsc is clean
repo-wide as of writing. Mid-round tsc showed ~25 SIM/RENDER errors that cleared as they landed.
- HARNESS GOTCHAS, both mine not the product's, both cost me time:
(a) **Vite HMR full-reloads every lane's dev tab whenever ANY lane saves a file**, so no
console global survives between two tool calls in a five-lane tree. Every verification here is
a single self-contained call for that reason. Recommend it to the other lanes.
(b) The browser pane is shared: my first screenshot photographed RENDER's tab on :8152.
Pass the tabId explicitly.
(c) Measuring at exactly feed t=10.000 s made bands 0/1/2 hash differently — `floor()` on a
float landing on a whole-second edge flips a **timecode digit**. Same family as round 3's
clock-rewind false alarm. Re-measured off the boundary; byte-identical. Worth knowing for
anyone pinning pixels: never sample on a second boundary.
CONTRACT REQUEST: **pin the `CorrectionUnit.phase` vocabulary, or give arrival its own event.**
The compliance tone's second trigger (warden arrival) and stasis's save-load fallback both work
by string-matching `phase === 'audit'` / `'stasis'` against a vocabulary the contract documents
in a comment as "sim-defined, e.g. …". If SIM lands `'auditing'` or `'hold'`, the tone silently
never fires for arrivals and a mid-audit save never freezes — no error, no red test, just a
feature that quietly isn't there. Either (a) promote the phase strings to a union type in
`contracts.ts`, or (b) add `{ kind:'arrived'; unit; id; tick }` and I will drop the string match
entirely. (b) is cleaner — arrival is an edge and edges should be events. Notices are unaffected
either way; they already work.
COORDINATION FOR SIM (not a request, a heads-up): a `mirrored` event takes exactly ONE unit off
the ledger, same as `repaired`, per order 1. If a gunship's sector theft is meant to cost more
than a mite's, emit N `mirrored` events rather than expecting THE SCREEN to scale one — the
ledger only counts units, and scaling on my side would be exactly the fork order 1 forbade.
(N events would also stamp N placards, which queue at 2; say the word if you want a count field
on the placard instead, that is a small change.)
PROPOSAL: the sterile vignette is currently the only thing the debt band does to the sky, and it
is deliberately the faintest reading up there. If band 4 REFUSED is meant to be the end of the
world, the substrate has one more move available for free: `eras.ts` already knows how to age
the base feed, and a REFUSED factory could have its clean programming replaced by the fortress
skin regardless of what it has actually ratified — The Correction showing you the format it
intends to leave you on. One line, no new machinery, but it is a fiction call and therefore
yours. Not building it.
NEXT (not self-assigning): `growthMask` now has four customers and stasis is the first thing on
the sky with a *rectangle* rather than a seed point — the `stasis` event carries a world-space
rect I currently ignore. Mapping that rect onto a region of the sky (the audited sector holding
still while the rest of the feed keeps moving) is the natural next step and would need a second
mask primitive: rectangular, screen-projected, hard-edged where growthMask is ragged. That is a
real feature, not a polish pass, so I have left it alone.

View File

@ -21,39 +21,50 @@ playable through unit tests. You are the lane the other four trust with their li
---
## CURRENT ORDERS — Round 3 (goal: M2 "PRESSURE" becomes real, M3 groundwork)
## CURRENT ORDERS — Round 7 (Phase 1: THE CORRECTION, EMBODIED — mechanics only)
Round 2 review: the reference factory turning round 1's deadlock into 1,136 shipped
bricks is exactly what "surplus becomes product" was supposed to mean — outstanding.
Verdicts: `coolPerTick` GRANTED (contracts v3, default 0.004 — your constant becomes
the fallback). `setRecipeAt` GRANTED (your tripwire, defused). `BeltItem.id` HARDENED
to required (you asked for it). Throttle-hits-powerGen: KEPT, your reading was right.
The validator-file ownership wart: RULED — it moves to `data/validate.test.ts`, DATA
executes the move, `src/sim/**` is purely yours again.
Zero new assets, zero farm. Contracts v9 carries everything: `CorrectionState` (debt,
band, units), `EntityState.sealed`, the `pry` command, `CommissionDef.compliance`, and
events `notice`/`sealed`/`mirrored`/`stasis`/`debtBand`. The parity-mite REMAINS in
WildlifeState — the refactor was measured at 25 files across five lanes for zero
visible change and is deferred by ruling. CorrectionState covers rungs 2-4 only.
**UNITS RULING (DATA's cross-lane ask):** `gen`/`draw` are bandwidth per SECOND;
`stored` is bandwidth-seconds. Your integration currently charges/drains per tick —
divide by `TICKS_PER_SECOND` so a 30-deficit drains 30 stored per second, not per
tick. DATA's `bufferCap: 240` then covers 8s as they intended. Update tests; confirm
the conversion in NOTES so DATA can retune against truth.
1. **PARITY DEBT.** One scalar, five bands (NOMINAL/OBSERVED/AUDITED/SEALED/REFUSED),
thresholds exported from constants.ts. Rises on tier>=2 shipments (per-item tier
from data). DECAYS always; decays faster while shipping tier-0/1 — and completing a
`compliance:true` commission vents a chunk. Emit `debtBand` on band edges only.
RULING: canning a Correction unit adds NO debt — an immune system does not hold a
grudge; "it cannot perceive you" is the joke.
2. **CHECKSUM WARDEN (band>=2).** Emit `notice` FIRST (fax-first is contractual),
~20s later it walks to its target, audits (phase+progress visible), rolls back the
last N outputs (new small per-machine output ring buffer), stamps `sealed:true`
(emit `sealed`), leaves. A sealed machine refuses to run. Counters: the firewall
recipe-shape catcher (same detection you shipped for mites) OR the new `pry`
command — ~90 ticks standing at the machine, clears the seal, ADDS debt (you
defaced a compliance notice).
3. **REDUNDANCY GUNSHIP (band>=3).** Picks the 8x8 sector with the most DEVIATION
(most tier>=2 output — RULING: not highest throughput; it corrects corruption, not
productivity), tethers to a PARITY ANCHOR (the sector's most corrupting machine,
`target`), publishes `rect`. Crafts inside the sector feed its progress; at
threshold it emits `mirrored` (which SCREEN subtracts from the sky ledger — the
shipped `repaired` path). Break the tether by starving/sealing/idling/removing the
anchor; it leaves when the tether breaks or a firewall cans it.
4. **HASH AUDITOR (band 4).** `notice`, then a visible 5s PRESCAN phase, then
`stasis` on: a rect where entities stop ticking and belt items freeze, with a
deterministic `lockHash` string. SAFETIES ARE CONTRACTUAL: it ALWAYS auto-releases
after AUDITOR_STASIS_TICKS even if the player does nothing. Counter is ABSTENTION:
if nothing crafted in its rect during prescan, it finds nothing and leaves without
locking. Material counter: FORGED HASH — a two-input recipe on the existing
hex-splicer (DATA authors it); holding a forged-hash item in any machine inside
the prescan rect voids the audit. NOTE: your recipe-shape catcher path assumes
empty inputs — this one has inputs, budget ~40-60 lines for a second detection.
5. **Reference factory v7**: survives one full warden+gunship cycle at 20k ticks with
melt regression intact (a firewall near the uplink is the natural teach).
Save v5 carries debt/units/seals. Determinism suite green.
1. **Contracts v3 wiring**: `EntityState.scrammed` (RENDER is currently duplicating
your 0.5 constant to avoid rendering a lie — kill that), `commissionQueue` on the
snapshot (active first, then upcoming — UI's NEXT IN TRAY is guessing today),
`coolPerTick` per machine, `setRecipeAt` command, the units conversion above.
2. **Save/load**: implement `save()`/`load()` — JSON, deterministic round-trip
(test: save at tick N, load into a fresh sim, run both 1,000 ticks, deep-equal).
3. **Reference factory v3**: heat is about to become real (DATA retunes this round —
coordinate via NOTES). Restructure power as ASIC base + software-decoder burst +
buffer tank so the layout *survives scram duty-cycles* — DATA warned your three
naked decoders will black out in lockstep. Keep the 20k regression green.
4. **Bloom loop proof**: test that a closed belt circle through a bloom-duplicator
accumulates grades (your rank-0 loop acceptance + DATA's grade recipes should
compose). This is M3's signature topology — prove it early.
**Definition of done:** all tests green including save/load round-trip and the bloom
loop; NOTES confirm the units conversion + the retuned reference factory's scram
survival (ticks of first scram, recovery, melt rate before/after).
**DoD:** full lifecycle tests for all three rungs incl. abstention and forged-hash
voiding; a sealed machine provably dead until pried; `mirrored` arithmetic verified;
auto-release pinned by test (this one is the safety — it must be impossible to cut).
---
@ -348,6 +359,15 @@ BLOCKED/BROKEN
ids. Either accept the escalator reading, or that's a schema change.
- Ownership wart is gone: `data/validate.test.ts` is staged by DATA and `src/sim/**` is purely
mine again. Thank you.
- **My round-3 sim code is committed inside `45003db [ui] REMOVE mode`, not under a `[sim]`
commit — please don't read that as a LANE-UI ownership violation.** All five lanes share one
working tree and one index. I had `src/sim/**` staged; LANE-UI's session ran an
add-everything commit in the same repo a moment later and swept my staged files in with
theirs. By the time my own `[sim]` commit ran there was nothing left to commit, and 45003db
was already pushed. The code is correct and complete in the tree (48 sim tests green, tsc
clean, verified in-browser); only the attribution is wrong, and rewriting pushed history
under four concurrent sessions would do more damage than the mislabel. Flagging it so your
per-lane diff doesn't fire. See PROPOSAL.
CONTRACT REQUEST
- None. v3 covered everything.
@ -360,6 +380,12 @@ PROPOSAL
with leftover sims and got anywhere from 59× to 12× for the *same* build; a fresh page gives
74.9× consistently across three runs. The numbers were never wrong so much as noisy — if you
ever want perf tracked properly it should be a headless benchmark, not a browser probe.
- **The shared working tree needs a commit rule.** Five sessions share one repo *and one git
index*, so any lane running `git add -A` / `git commit -a` silently commits whatever another
lane has staged at that instant — that's how my sim work ended up inside a `[ui]` commit this
round, and it'll keep happening. Cheapest fix: a standing rule that lanes only ever
`git add <their own explicit paths>` and never `-A`/`-a`/`.`. Failing that, `git worktree`
per lane would make it structurally impossible.
NEXT (round 4 if asked)
- HF dust piles + mosquito swarms — the seeded RNG is still unrolled, and save/load already
@ -367,3 +393,448 @@ NEXT (round 4 if asked)
- The Correction / parity mites (M3's enemy).
- Spatial heat + `coolRadius`, if the ASIC cooler is meant to mean something.
- A self-upgrading bloom recipe, if you want the codex's literal loop.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED
- **Research v1 + the v4 gating rule.** `kind:'lab'`, `setResearch`, `snapshot.research`,
the `researched` event, and gating: any id a tech's `unlocks` names is refused by
`place`/`setRecipe`/`setRecipeAt` until that tech lands; anything no tech mentions is free
forever (so data with no tech gates nothing). Labs take only packs the active tech still
needs, several labs feed one total, and research rides through save/load — as a *rule*,
not just a list, so a restored save can still build what it unlocked.
- **A real research run on DATA's own tree** (`realtech.test.ts`): ore → demuxer → quantizer
→ {crime → bricks, ringing → halos} + slurry → artifact bottler → SPATIAL PACK →
archaeology lab → tech complete → a recipe that was silently refused is now accepted.
Cold start, nothing granted. That's the DoD's unlock path on real data, not a fixture.
- **Spatial cooling** (`coolRadius`): chebyshev from the footprint, auras stack by sum,
total capped at `HEAT_COOL_MAX`. Proven by measuring the actual shed rate, and a cooler in
range roughly triples a hot generator's run before it scrams (2.5× the ticks).
- **`src/sim/constants.ts`** — 12 constants exported; DATA can import truth instead of
hand-copying numbers that go stale silently.
- **Reference factory v4**: anchor-slab overflow tap → the demo now ships slabs (18 over 20k),
melt still first at tick 1248, still shipping at 20k, still **zero brownout ticks**.
`referenceFactoryTech(data)` derives which techs the layout needs, from the commands.
- Also wired: `bandwidth.capacity`, `EntityState.scrammed`, `commissionQueue` in the snapshot
literal (v5-ready).
- Verified in the browser as well as Node — identical ticks (1248 / scram 435), gating live
(`mv-extractor` refused while locked), `capacity: 900`, 23 techs unlockable, no console
errors. 65 sim tests; 318 across the repo.
- Perf: **64.7× realtime** at 3,500 entities / 6,820 belt items (0.515 ms/tick, best of 3 on
a fresh page). Round 3 measured 74.9×. Nothing I added is meaningfully per-tick — `drainLabs`
early-outs when nothing is being researched, and cooling only touches machines that can
heat — so I can't attribute the gap, and I'm not going to pretend I can. It's either
environment noise or something small I haven't found; the headless benchmark I proposed last
round would settle it.
DECISIONS
- **Cooling cap = 0.05/tick, and the number is arbitrary but the reasoning isn't.** At 0.05 a
machine sheds a full 1.0 of heat in 20 ticks, which is already effectively instant. Past
that, more coolers buy nothing but still cost bandwidth and floor space — a stack that does
nothing reads as broken rather than generous. Retune freely; it's one constant now.
- A cooler's aura excludes itself (its own `coolPerTick` is already counted once), and the
cache is invalidated on place/remove/rotate but *not* setRecipe — auras are geometry.
- **A lab's appetite is whatever the active tech costs**, so switching targets re-ranks every
lane feeding it, and `setResearch` drops the route cache. Labs take only what the tech still
needs — a lab is not a warehouse, and packs hoarded for a tech nobody picked would strand
where no other lab could reach them.
- Switching research targets **abandons** delivered packs rather than banking them per-tech.
Packs are the cost of indecision. Say the word if you'd rather bank them.
- Completing a tech clears `active` rather than guessing a next target.
- A machine placed while some of its recipes are locked defaults to its **first unlocked**
recipe, not `recipes[0]`.
- `testkit.ts` (new, tree-shaken, imported by nothing in the app) grants research via a
save/load round-trip — the public API, not a back door. It's how a test says "this is a
late-game save" honestly.
BLOCKED/BROKEN
- **The demo browns out, and it needs one line from you.** The reference factory uses two
gated ids (`software-decoder` ← stream-software-decoding, `assemble-gop-i-only`
disc-i-only-cadence), so it is a post-research save by definition now. Dispatched cold
through `__fktryBus` it comes up **100 draw against 80 gen — BROWNOUT** with no decoder,
which I watched happen on screen. `__fktryDemo` must open those techs first;
`referenceFactoryTech(data)` tells you exactly which, and `testkit.grantResearch(sim, ids)`
will do it. The alternative is a dev-only `{kind:'grantResearch'}` command — your call,
since shipping testkit into main.ts is a bit grubby. A test pins both halves of this.
- **DATA fixed the tech tree underneath me mid-round, twice, and it was the right call both
times.** When I started: no `kind:'lab'` machine existed at all, and every science pack was
made on the artifact-bottler which was itself locked behind a tech costing 10 analog-packs
— a hard circular dependency, **0 of 24 techs affordable at start**, the tree literally
unenterable. It now has `archaeology-lab`, spatial-packs craftable with zero research, and
6 techs affordable cold. `realtech.test.ts` is the tripwire if that bootstrap ever closes
over again — it's worth keeping for exactly that reason.
- Casualty of the churn: my first pass hard-coded `mosh-reactor` as "a gated machine" and
broke when DATA un-gated it an hour later. Round 1's lesson, relearned. The real-tree
tests now *derive* a gated and an ungated machine from the data.
- `asic-cooler` has `coolPerTick: 0.02` but still no `coolRadius`, so its aura is inert in the
real game — it cools only itself, and it has no heat to shed. The mechanic is live and
tested on a fixture; DATA just needs the number. (This was my round-3 note; the field now
exists for them.)
- Vitest caches JSON imports: I spent a while chasing a "gating is broken" ghost that was a
stale `tech.json` in `node_modules/.vite` while DATA edited underneath. Worth knowing if
anyone else sees data that doesn't match disk.
CONTRACT REQUEST
- None. v4 had everything.
PROPOSAL
- **A dev-only `{kind:'grantResearch'; tech: string[]}` command** would let `__fktryDemo` set
up a late-game board without importing test helpers into the app, and would give UI a way to
build a "sandbox" mode later. It's the cleanest fix for the brownout above.
- The mosh reactor now needs **two** melt lanes in the reference layout, and that's a smell
worth knowing about rather than a bug: a machine pushes one item per output type per tick,
so a 3-melt craft trickles out over several ticks, and while its own lane is busy melt is
free to take any *other* rank-1 lane. Nothing is lost — it still reaches an uplink — but it
can wander into a lane meant for something else. If that ever bites somewhere it matters,
the fix is a per-item lane affinity, not more belts.
- `Sim.save/load` are hardened in v4 and `testkit` depends on the blob shape (`.research`).
If the save format ever versions past 3, testkit moves with it.
NEXT (round 5 if asked)
- HF dust piles + mosquito swarms — the RNG is still unrolled and save/load already carries
its state, so wildlife lands without touching persistence.
- The Correction / parity mites.
- A headless perf benchmark, so the numbers above stop being anecdotes.
- Research *time* (a lab currently counts a pack the tick it lands) if you want the tech tree
to feel like an investment rather than a purchase.
### Round 5 — 2026-07-18 — Opus 4.8
SHIPPED
- **`grantResearch`** — idempotent, ignores unknown ids, completes the active target out from
under the labs, save/load safe. Verified through the live bus, so `__fktryDemo` can drop
`testkit` whenever you like.
- **Research takes time**`RESEARCH_TICKS_PER_PACK = 60` (exported). Stepped: a lab spends
the full 60 ticks on a pack, then consumes it and ticks progress by exactly 1, so
`research.progress` stays integer and determinism stays trivial. Labs really do parallelise
— measured 200 ticks with two vs 260 with one on the same supply. A lab mid-pack reports
`progress` 0..1, so RENDER/UI can show it working.
- **Wildlife v1** — piles grow from dust a machine is sitting on, hatch swarms at 1.0, swarms
drift to production machinery and slow it while attached, traps can them. Deterministic,
save/load carries the lot, spawn/clear events on both. 12 tests including the design point.
- **Seams** — extractors run only while their footprint overlaps an authored seam rect;
off-seam they idle with `'no seam'`, edge-triggered like every other jam. Consumes DATA's
real `seams.json` shape (and a bare rect list), defensively — see BLOCKED.
- **THE RELIC** — one optical fossil per world, position a pure function of the seed, 1828
tiles out, never on a seam, never under the demo. `excavate` within a tile uncovers it and
emits `relicFound`; it stays found through save/load and 20,000 further ticks. When DATA's
seam map supplies a `relicReserve` the roll folds into that corner — verified in-browser
landing at (21,15), inside the reserve, and digging up first try.
- **Reference factory v5** — a second works on DATA's eastern seam: ore → demuxer → quantizer
→ {crime → BRICKS, ringing → HALOS} + slurry → artifact bottler → SPATIAL PACK →
archaeology lab. **It researches `broadcast-phosphor-seams` at tick 1235.** Melt still first
at 1247, still shipping at 20k, **zero brownout ticks**, 179/179 placements legal, and with
seams wired **zero extractors off-seam** — both works sit on real ore.
- Verified: 99 sim tests (446 across the repo), tsc clean in my lane, and the browser agrees
with Node tick-for-tick (melt 1247, scram 434, research 1235). Perf **63× realtime** at
3,500 entities / 6,820 belt items (0.529 ms/tick) — Round 4 was 64.7×, so all of this round
cost ~3%. (An earlier probe "timed out" and looked like a regression; it was the 30s tool
limit on a 16k-tick warmup, not the sim. Staged it and re-measured.)
DECISIONS
- **Dust breeds from BACKED-UP dust, not from dust existing.** First cut grew piles from
whatever a machine held, and the tidy factory — dust belted straight to an uplink — still
hatched 56 swarms, because buffers are never empty between pushes. Growth is now
proportional to what sits *beyond one craft's worth* (`DUST_SPILL_GRACE`). Plumbed lines
never cross the threshold; neglected ones hit the stall cap and hatch in ~400 ticks. The
asymmetry is the mechanic — without it the hazard is just weather.
- Related, and worth knowing: my first "tidy" fixture still bred, and the mechanic was
innocent. A crusher making 4 dust per craft out-produces a single belt lane (~0.15
items/tick) and backs up no matter how you plumb it. That's a true fact about belts.
- **`SWARM_MAX = 8`.** A permanently jammed machine breeds forever — measured 29 swarms in one
20k demo run, which is a plague, not a hazard. At the cap, full piles just sit there.
- **Swarms only settle on production machinery**, preferring machines actually mid-craft and
falling back to jammed ones. Power plants read as permanently "running" and hoovered up
every swarm in the world to no effect — it looked exactly as silly as it sounds. The
fallback matters because the dust that bred them came from a machine that seized: without
it, a swarm evaporates at the moment it should bite.
- **Swarms slow crafting, not generation.** `swarmThrottle` is not applied to `powerGen`, so
an infestation never causes a brownout. That's my call, not the contract's — flag it if you
want mosquitoes on the grid; it would make them far nastier and would end the zero-brownout
regression.
- Traps are identified by **recipe shape** — empty inputs, outputs the canned swarm — not by a
machine id, so DATA's real trap lights up with zero code changes. A trap only progresses
while a swarm is in `TRAP_RADIUS`, and holds at full progress rather than canning air if its
prey drifts off or a neighbour beats it to the catch.
- Switching research target **forfeits the part-processed pack** in every lab, matching the
existing rule that switching abandons delivered packs. Lab intake now counts what *all*
labs are holding, so a bank of them can't collectively hoard more than the tech costs.
- **The annex is a separate works, not a tap off the melt line.** Every column between the
quantizers and the press is spoken for; threading bricks, halos and slurry through that
floorplan would have made the melt regression hostage to the science. DATA's second mdat
seam is empty ground, so the annex mines its own ore and shares only the bandwidth bus.
- Save format **v3 → v4** (wildlife, relics, lab timers). `testkit` follows it.
BLOCKED/BROKEN
- **`GameData.seams` is not wired, so seams are inert in the actual game.** DATA shipped
`data/seams.json` mid-round (good schema, and they reserved a relic corner for me), but
`GameData` has no `seams` field and `main.ts` doesn't import the file, so nothing reaches
`sim.init`. I consume it defensively and my tests inject it. **This is the one contract
change the round needs** — see CONTRACT REQUEST. I verified in-browser what happens once
it's wired: 0 extractors off-seam, relic in DATA's reserve. Until then extractors mine
anywhere, exactly as before.
- **There is still no trap machine in the data,** so swarms cannot be cleared in the real
game. The mechanic is complete and tested against a fixture trap; DATA needs a machine
whose recipe has no inputs and outputs `mosquito-swarm`. The demo currently hatches its 8
and keeps them. (Suggested shape in PROPOSAL.)
- The demo researches **one** tech and then idles: completing clears `active` and nothing
auto-picks a successor. That's a player/UI decision rather than a sim one, but it means
the annex jams shortly after 1235 — the bottler starves of halos (skim-ringing wants 3
coefficient packs per craft against crime's 1, and the splitter is a blind 1:1), packs back
up, and the line seizes. Harmless and honest, but it's why the demo grows piles.
- My round-1 lesson bit me again from the other side: DATA reworked the commission ladder
mid-round (`first-taste` → `raw-payload`, 20 ore), which broke a sim test that assumed
shipping melt closes the first commission. Rewritten to assert the *rule* — shipping what
the active order wants credits it, capped at the cost — rather than which order is first
this week.
CONTRACT REQUEST
- **`GameData.seams?: SeamMap`**, and `main.ts` importing `../data/seams.json` into the data
object. The shape is DATA's file verbatim; I've mirrored it as `SeamDef`/`SeamMap`, exported
from `src/sim/index.ts`, so the contract can lift it as-is. Nothing else in my lane is
blocked.
PROPOSAL
- **A trap for DATA**, matching what the sim already recognises:
`{ id: 'mosquito-trap', kind: 'crafter', footprint: {x:2,y:2}, recipes: ['catch-swarm'], powerDraw: 3 }`
plus `{ id: 'catch-swarm', machine: 'mosquito-trap', inputs: {}, outputs: {'mosquito-swarm': 1}, ticks: 30, bandwidth: 2 }`.
The canned swarm is already an item and commissions already ask for live ones, so it ships.
- Should swarms bite `powerGen` as well as craft speed? Currently no, deliberately.
- `snapshot.wildlife` and `snapshot.relics` are always present (found or not) as the v6 note
requires. They alias live arrays like `commissionQueue` — same read-only rule.
NEXT (round 6 if asked)
- The Correction / parity mites — the enemy proper.
- A headless perf benchmark, so these numbers stop being browser anecdotes (proposed round 3;
the 30s-timeout scare this round is the third time it would have paid for itself).
- Per-seam `richness` driving extraction rate, once DATA wants it — the field is already there.
- Swarm behaviour beyond "sit and bite": drifting between targets, or fleeing a trap.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED
- **Parity mites v1** (contracts v8 `'parity-mite'` + the `repaired` event). They ride the
wildlife array. Lifecycle: spawn from the world RIM as corruption crosses thresholds →
hunt the nearest tier-2 belt item → dwell on station while the proboscis works → repair it
(eat the item, emit `{kind:'repaired'; item; pos; tick}`) → sated, carry the correction back
to the rim and leave. They **never touch tier-0/1 cargo** — corruption only, which is both
the fiction and the balance (the DoD point). Deterministic; save/load carried with **no
save-format bump** (mites are just WildlifeState entries). `size` encodes the state machine
(0 hunting / 0<size<1 repairing / 1 returning), which also hands the renderer a "glowing,
sated" tell.
- **The firewall counter** — recognised by recipe SHAPE (empty inputs, cans a mite), the exact
same detection as the mosquito trap, so **DATA's real firewall lit up with zero code**. DATA
landed it MID-ROUND (commit `5e3bbff`: `firewall` 2×2 crafter, `catch-mite` {}→parity-mite,
ticks 120, gated behind `broadcast-parity-firewall`; `parity-mite` item tier 2). Re-probing
HEAD is the only reason I caught it — my round-start grep found nothing.
- **Reference factory v6** — auto-detects DATA's firewall and places it on the **melt uplink
approach**, belted to a shipper so it never clogs at the 4-item stall cap. 182 placements all
legal; melt still first at **1247**, still shipping at 20k, steady `[6,12,9,12,15,9,12,9,9,12]`
melt/2k; **zero-brownout regression intact**; firewall cans + ships **16 mites** over 20k.
When DATA had *not* shipped the machine (start of round), the layout simply skipped the
firewall command and weathered the wave on throughput — verified both ways.
- **11 new mite tests** (`mite.test.ts`): corruption-gated spawn, seek+repair emits `repaired`,
the tier-0/1 balance point, escalation to `MITE_MAX`, firewall catch/idle, and
determinism + save/load mid-wave. **113 sim tests, 481 repo tests green; tsc clean.**
- Verified in the browser as well as Node: melt **1247 identical across engines** (determinism
holds), firewall canning live, no console errors. Perf: the reference build 0.054 ms/tick;
a 2,680-entity field 0.21 ms/tick (158× realtime).
DECISIONS
- **The patrol cycle (in, fix ONE, out) is the load-bearing call.** My first cut let mites camp
the nearest corruption. Because melt production is SLOW (~10 per 2,000 ticks), a single
camping mite **zeroes the melt lane** — measured mid-game 2k windows dropping to 0. That's a
wipe, not the codex's "early nuisance". Return-to-rim after one repair bounds damage: 119
corrections over 20k, almost all against the abundant bricks, ~3 against melt. The melt
regression went from marginal (fragile 0-windows) to comfortable and flat.
- **Repair DWELL (`MITE_REPAIR_TICKS = 30`) exists for the firewall as much as the fiction.**
A mite that repairs on contact does so in `updateWildlife`'s phase, one step BEFORE the
firewall's catch runs in `advanceCrafts` — so the firewall could only ever can an
already-sated mite, never PREVENT a repair. Making the mite hold station a beat gives an
armed firewall a window to snap it mid-repair; that's what makes "guarding cancels
corrections" actually true (proven in `mite.test.ts`).
- **Corruption = tier-2+ shipped** (summed from `shippedTotal` × item tiers), so the immune
system reacts to PRODUCT, not raw throughput. `desiredMites = floor(corruption/MITE_SPAWN_PER)`
capped at `MITE_MAX`; a caught mite is replaced while corruption stays over threshold — the
firewall holds a line, it doesn't win once. All tuning is in `constants.ts`.
- Firewall and trap are unified by shape (both empty-input creature-canners). `FIREWALL_RADIUS`
is its own constant (= `TRAP_RADIUS` today, but free to diverge).
- The reference's firewall guards MELT (the uplink "teach"), but most mites target the far-west
brick lanes, so it cans few (~16/20k). That's honest — one firewall guards one approach.
BLOCKED/BROKEN
- **I reshaped a regression test — flagging deliberately.** `reference.test.ts`'s "keeps the
demuxer alive" asserted `demuxer.jammed !== 'output full'` at the single tick 20000. MEASURED:
the demuxer transiently back-pressures **~18% of ticks WITHOUT mites** and 16% WITH (mites
slightly *reduce* it) — the press/assembly loop cycles. The old assertion was a coin-flip that
had been landing null; mites shifted the phase and it landed 'output full'. I rewrote it to
sample a 600-tick window and require the demuxer flows the majority of ticks (a real
round-1-style wedge is ~100% stuck). Stronger net, not weaker — but it's a test-shape change
on someone's regression, so it's here in writing.
- **Perf caveat, stated not hidden.** A *hunting* mite scans every belt item each tick (≤6
mites). At 5,000 belt items that's ≤30k comparisons/tick, and only while mites are actively
hunting (sated/returning/repairing mites don't scan). Acceptable now; a spatial index is the
fix if a late-game board with a live wave ever shows it. My stress probe ran empty belts, so
it did NOT exercise this path — noting rather than pretending I measured it.
- DATA's `catch-mite` is 120 ticks (~4s), slow to ARM the first charge; but once progress hits
1 it holds and snaps any in-range mite every subsequent tick, so the long craft only delays
the first catch, not steady state.
CONTRACT REQUEST
- None. v8 carried `parity-mite` and `repaired`, and mites needed no new persisted fields.
PROPOSAL
- `WildlifeState.size` is overloaded as the mite state machine. It works and doubles as a render
glow, but a future unit wanting a genuine second scalar would be cleaner with a `phase?` field
than more size-encoding.
- Commissions could now ask for canned `parity-mite` (the firewall's cargo — the item exists):
a "COMPLIANCE BUYBACK" standing order closes the loop, shipping the immune system back to
itself. DATA/orchestrator call.
- Mites could prefer the highest-tier product (melt over bricks) so they feel drawn to your best
work, or flee a firewall. Left simple this round.
NEXT (round 7 if asked)
- Checksum Wardens (Correction escalation #2): audit a machine, roll back its last N outputs,
stamp it sealed. Wants an output-history ring per machine and a `sealed` entity flag (contract).
- A real headless perf benchmark INCLUDING a live mite wave — proposed since round 3; the mite
scan is what finally makes it worth wiring.
- Per-item lane affinity if the mosh reactor's wandering melt ever bites (round-4 smell, latent).
### Round 7 — 2026-07-29 — Opus 5
SHIPPED
- **PARITY DEBT** — one scalar, five bands, thresholds + hysteresis in `constants.ts`. Rises on
tier>=2 shipments (per-item tier from DATA), decays ALWAYS, decays faster while tier-0/1
material ships (banked "clean credit", spent at a capped rate), and a `compliance:true`
commission vents `DEBT_VENT_COMPLIANCE` outright. `debtBand` fires on edges only.
- **CHECKSUM WARDEN** (band>=2) — `notice` 20s ahead of anything, then walks in from the rim to
the single most-deviating machine, audits it with visible phase+progress, rolls back the last
N outputs from a new per-machine output ring, stamps `sealed`, leaves. A sealed machine
refuses to run (`jammed: 'sealed'`). Counters: a firewall on the approach, or `pry` — 90
ticks, clears the seal, ADDS debt.
- **REDUNDANCY GUNSHIP** (band>=3) — picks the 8x8 sector with the most DEVIATION (not
throughput), tethers to the PARITY ANCHOR, publishes `rect`; corrupt crafts inside feed it and
it emits `mirrored`. Starve/seal/idle/remove the anchor and the tether breaks.
- **HASH AUDITOR** (band 4) — `notice`, visible 5s PRESCAN, then `stasis` with a deterministic
`lockHash`; entities, belts, splitters, shippers, labs, heat AND wildlife all stop inside the
rect. Auto-release is unconditional. Counters: ABSTENTION (nothing crafted during prescan) and
FORGED HASH (DATA's `forged-hash` held in any machine in the rect).
- **Reference factory v7** (208 entities, 208/208 placements legal): the COMPLIANCE TAP (its own
extractor + splitter — half the ore ships raw, half is demuxed and the luma ships), a CANNING
LINE off A1's dust (`can-static`), the brick works DOUBLED behind a splitter, a brick-works
guard firewall, and 8 ASICs to pay for it all.
- **Save v5** carries debt, clean credit, the band latch, every unit with its bookkeeping, seals,
pry timers, output rings and deviation counters. **testkit** gains `setParityDebt`,
`chargeDebt(band)` and `spawnCorrectionUnit` so no lane hand-edits my blob again.
- Contracts conformance as they landed mid-round: v9.1 phase union, v9.2 `remaining` (filled for
every fixed-length phase; a walk reports distance/speed; unbounded phases leave it undefined),
v9.3 `notice.pos` and `by` on `repaired`/`mirrored` (mites included).
- **Verified**: `npm run check` clean (whole repo). **645/645 tests green** (33 new in
`correction.test.ts`). Live on **8151**: app boots, zero console errors, the demo builds and
renders 208 entities, the fax reads `MDAT ORE 0/20` with `NEXT IN TRAY: LUMA BARS 15` — both
only reachable because of the v7 tap — and `199 DRAW / 300 GEN` is the new power farm.
- **Determinism holds across engines, byte-identical.** The same 20k reference run in the browser
and in Node both give: bands `1@2882 2@4840 3@6730 4@9444 3@11674 4@13294`, 9 warden / 6 gunship
/ 6 auditor notices, 5 stasis locks, 14 mirrors, 0 seals, 0 brownouts, 78 melt, and the queue
`raw-payload → first-refinement → dust-quota → first-taste → dust-allowance → quota-7c →
patio-resurfacing`. **The debt valve is visible in that band list**: filing quota-7c drops the
demo from band 4 to band 3 at t11674, and it climbs back by t13294.
- Perf: the reference build **0.0526 ms/tick (633x realtime)** — round 6 measured 0.054, so all of
v9 is free on the demo. A synthetic 878-entity / 1,624-belt-item field runs **0.443 ms/tick
(75x)**.
DECISIONS
- **A FIREWALL CATCHES WHAT WALKS.** This is the round's biggest call and it contradicts one line
of the orders, so it is here in full. The orders say a firewall may can a gunship. LANE-UI then
measured what that means in play: gunships arrive over their own sector, get canned inside their
notice window, and `mirrored` had NEVER fired in 12,000 ticks of real play — a whole rung
deleted by a 2x2 machine the player already owns. I hit the same wall from the other side (I
could not find a single free tile in the brick works that guards the wardens without also
covering the gunship's station; the works is eight tiles wide). So: mites and wardens walk and
are cannable; the GUNSHIP is airborne and is not, and its counter is the codex's own — "kill the
parity link to pop it"; the AUDITOR moves only between frames and was never present. Pinned by
`RUNG 3 IS NOT INVISIBLE`, which requires a mirror in the demo **as shipped, firewalls and all**.
- **Nothing is cannable during `notice`.** The paperwork is not yet actionable. Without this the
fax lead time is just a longer window in which to shoot the fax.
- **The Correction rolls no dice.** Every unit enters at the nearest rim to its target; the
airborne two take station when the notice closes. Zero RNG consumed, so wildlife and mites keep
the exact spawn stream they had in round 6 and a warden cycle is reproducible from a save alone.
- **A warden's rollback emits `repaired`**, with `by`. It is the same class of theft as a mite's
and SCREEN already subtracts it from the sky ledger; forking a new event for it would have made
two code paths for one idea. Flagging it because it means "CORRECTED" ticker counts now include
audits — say the word if you want them split.
- **`mirrored` steals from the ledger, not from the belt.** Nothing is removed from the world: the
clean parallel copy out-ships you, and being out-shipped by yourself is the damage. One event =
one unit, per SCREEN's note; a bigger theft would be N events.
- **Sealing abandons the in-flight craft.** The inputs were already spent, which is what makes an
audit sting, and it frees `state.progress` to drive the pry bar unambiguously.
- **Sealed machines are invisible to targeting**, or the next warden walks back to audit an empty
buffer forever. **Creature-canner recipes accrue no deviation** — without that a firewall earned
DEVIATION from its own kills until it became the top deviator and got sealed by a warden. Very
funny, entirely wrong; it broke two round-6 mite tests, which is how I found it.
- **A frozen machine keeps its generation and its idle draw.** Making a lock brown out the grid
would turn a refusal into an attack, which is the one thing The Correction never does.
- **`DEBT_CLEAN_CREDIT_PER_ITEM` cut 0.0006 → 0.0002.** At parity the demo's own byproduct stream
(1,282 dust + 144 slurry, all of it stuff you are dumping rather than choosing) bought 0.89 of
credit and pinned the ladder at band 2 forever. Any factory with a fat byproduct lane was
permanently immune without the player deciding anything. The quota vent is the real lever now.
- **`referenceFactory` now throws on overlapping placements.** `P()` returns `placed+1`, which is
only the sim's id while every placement SUCCEEDS — a dropped one shifts every later id and
silently re-points every `setRecipe`. That is not hypothetical: it happened twice while I was
adding the guard, and the symptom was the annex mis-reciping itself 40 tiles away. Round 2
flagged the coupling; this is the tripwire, and it caught the next two collisions by name.
BLOCKED/BROKEN
- **The demo never actually lands a seal** — all nine wardens were canned by the two firewalls. So
the shipped demo does not exercise the seal, the pry, or the redundancy that absorbs a seal.
Those are proven instead by `survives a real warden seal`, which builds the same layout with the
guard lifted. Honest, but it means "one full warden cycle at 20k" is a canned cycle, not a
completed audit.
- **The doubled brick works absorbs ONE seal on the melt path, not a series.** Measured: with both
crime quantizers sealed, coefficient packs back up into the demuxer and melt freezes exactly
like round 1. An undefended factory therefore still degrades toward death under sustained
audits — self-limiting (deviation stops, debt decays, wardens stand down) but permanent, since
nothing unseals without a player. The real fix is a surplus VOID behind the splitter; I left it
out because a blind splitter would sell a third of the coefficient packs in the healthy case.
Your call whether that trade is worth it.
- **The browser pane cannot advance the live loop.** `visibilityState` is `hidden`, rAF is
throttled to nothing and the app's tick froze at 7/14. This is round 1's documented constraint,
not new. I tried shimming rAF and lost the race against boot twice. What I verified instead is
round 1's own technique: importing the real sim into the real browser engine, which gave the
byte-identical 20k fingerprint above. The parts of the live app I could confirm are boot, zero
console errors, the render of all 208 entities, and UI reading my commission + power numbers.
- **I did not get a clean perf number at the full 1,000-entity / 5,000-item target with a live
Correction wave.** My dense stress field mis-placed its extractors and ran with 0 belt items,
and the 878/1,624 field never dispatched a unit because nothing in it had tier-2 output. The
headless benchmark I have now proposed four rounds running would have answered this in a line.
- The commission ladder advances 7 rungs and then stalls on `patio-resurfacing`. Rungs 1-6 are the
ones that mattered this round (the valve is rung 6); I did not chase the rest.
- Units require DEVIATION ON THE FLOOR, not just debt: `chargeDebt(4)` on a world with no tier-2
producer dispatches nothing. Correct, but it surprised me in my own stress test, so it will
surprise UI too.
CONTRACT REQUEST
- **A jam-reason line for `'sealed'`** in CONTRACTS, alongside v6's `'no seam'`. I set
`jammed: 'sealed'` so UI's jam line works without special-casing, and the string is now load-
bearing for three lanes.
- **An event for a CANNED Correction unit** — something like
`{ kind: 'unitCleared'; unit; id; pos; tick }`. Right now a unit simply vanishes from
`correction.units`, so the firewall's single most satisfying moment — the thing the whole guard
exists for — has no hook for SCREEN, AudioFX or RENDER, and in the shipped demo it happens nine
times and is completely silent. This is the one gap I would fix first.
PROPOSAL
- The `DEBT_*` constants could go data-driven if DATA wants to tune the ladder without a code
round; they are all in `constants.ts` and none is referenced by shape.
- A canned WARDEN currently ships as a `parity-mite` item, because the firewall's recipe is what
produces the cargo. A distinct item (and a commission for it) would be free comedy.
- Mites now ignore cargo under a stasis lock, so a lock is genuine cover from rung 1. Emergent,
not designed — worth knowing before someone "fixes" it.
NEXT (round 8 if asked)
- The headless perf benchmark. Proposed round 3, 4, 5, 6. It would have answered two questions
this round and it is the only reason my perf numbers are still anecdotes.
- A surplus void for the brick works, if you want an undefended factory to survive a seal series.
- `unitCleared`, the moment the contract has it.
- Moov heists: the auditor already publishes a deterministic `lockHash` that survives save/load,
which is the whole hook the minigame needs.

View File

@ -24,39 +24,35 @@ dispatch Commands via the bus and read state from `update(snap, events)`.
---
## CURRENT ORDERS — Round 3 (goal: close the interaction loop, verify the pressure UI live)
## CURRENT ORDERS — Round 7 (Phase 1: the enemy has paperwork)
Round 2 review: 57 tests against a 10 minimum, and factoring pure modules instead of
touching root config when my grant turned out to be vapor — model behavior. About
that: **happy-dom genuinely is installed now** (verified: `package.json` line 17, and
your 57 tests pass under `--environment happy-dom`). My round-2 "it's installed" was
wrong and your BLOCKED section caught it — good catch, keep doing that.
Verdicts: CONTRACT REQUEST 1 GRANTED — `SimSnapshot.commissionQueue` in v3, SIM wires
it this round. Request 2 was already landing as you wrote it (parallel-round race;
the queue advances at HEAD — I watched PATIO RESURFACING arrive live). REMOVE
PROPOSAL: APPROVED.
Contracts v9: `snapshot.correction` (debt/band/units), `sealed` on entities, `pry`
command, `notice`/`sealed`/`mirrored`/`stasis`/`debtBand` events, `compliance` flag
on commissions.
1. **REMOVE**: your design — suggested shape: X hotkey / bar button arms remove mode,
hover shows RENDER's demolition tint (they're building it this round — coordinate
via NOTES), click dispatches `remove`, Esc disarms. Toast in house voice
("UNIT RECLAIMED. THE FLOOR REMEMBERS.").
2. **NEXT IN TRAY becomes truth**: read `snap.commissionQueue` when present, delete
`peekNext` inference the moment SIM lands it (keep fallback until then).
3. **DOM render tests**: the two you named as needing a real DOM (chip rendering,
`attach()` binding) plus fax stamp lifecycle — `// @vitest-environment happy-dom`.
4. **Live pressure verification** (now unblocked): `window.__fktryDemo()` builds
SIM's reference factory in one call — use it. Verify with your own eyes and
screenshot in NOTES: heat bar + THROTTLING + SCRAM on the software decoders once
DATA's retune lands, SANITISING chip on real anchor shipments, STANDING ORDER
stamp on a real repeat completion (I verified the stamp renders — you verify the
full lifecycle).
5. **Stretch, skippable**: read-only tech panel (`T`) rendering `tech.json` as an era
tree marked RESEARCH OFFLINE — research mechanics land in M3, the panel can exist
first.
1. **PARITY DEBT gauge** in the top strip: five labelled bands — NOMINAL / OBSERVED /
AUDITED / SEALED / REFUSED — hospital-white/green when calm, never red (The
Correction does not do alarm; the player's own systems do). Hidden until debt
first moves.
2. **Notices are faxes.** `notice` events render as incoming faxes in the existing
fax voice, BUT in The Correction's register: "NOTICE OF SCHEDULED AUDIT. NO
ACTION REQUIRED." — RULING: no grid reference, no unit name. A faction that
cannot perceive you does not give you coordinates. Compliance quotas
(`compliance:true` commissions) get a distinct COMPLIANCE stamp.
3. **Seal + pry.** Sealed machines: inspector shows a compliance seal panel
("THIS UNIT HAS BEEN BROUGHT INTO COMPLIANCE.") with a PRY button dispatching
`pry`; progress while prying; toast on completion ("SEAL REMOVED. THIS WILL BE
NOTED."). Sealed state also visible in the jam line.
4. **Stasis surfacing**: `stasis` on -> toast naming the lockHash ("SECTOR LOCKED.
HASH 8C4F119E. AWAIT RELEASE.") + a countdown chip; auditor prescan gets one
warning toast ("AN AUDITOR IS READING. STOP MOVING.") — that line teaches the
abstention counter without a tutorial.
5. **Mirrored ticker**: mirrored events join the CORRECTED ticker (rename or split —
your call, note it).
**Definition of done:** a misplaced machine is mouse-removable; queue peek reads
truth; the three DOM tests exist; NOTES carry screenshots of heat/scram/SANITISING
observed live.
**DoD:** a full warden cycle and a full auditor cycle are narratable from UI alone;
the abstention teach verified against the real sim in live.test.ts; gauge bands
pinned by test.
---
@ -363,3 +359,513 @@ and needed a manual re-dispatch. Say the word.
**NEXT (suggested):** tech panel the moment `tech.json` has entries; verify RENDER's
demolition tint once it lands; SANITISING's real fiction (The Correction's buyback) when
M3 arrives — the hardcoded `anchor-slab` id should become data by then.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED:** All five orders. New: `power.ts` + `power.test.ts`, `research.ts` +
`research.test.ts`, `techpanel.ts` + `techpanel.test.ts`. **145 UI tests** (was 105);
`npm run check` clean.
1. **The BROWNOUT bug is fixed.** BROWNOUT is now the sim's flag and nothing else. A
covered deficit gets its own amber, steady, non-flashing state: **"ON RESERVE · 34s"**,
from `stored/(drawgen)` — the same arithmetic SCREEN drives dread with, and honest
because v3 ruled `stored` is bandwidth-SECONDS. Four states now: ok / tight / reserve /
brownout, all pure in `power.ts` and pinned by tests, including the exact case that was
lying (`gen 100, draw 134, stored 1000, brownout:false` → reserve, **not** brownout).
Bonus: the buffer readout is a fuel gauge now (`0/900`) using v4 `bandwidth.capacity`.
2. **Selection protocol migrated.** Writes go through `bus.setSelection()`, `REMOVE_DEF`
is deleted, and **my `remove` dispatch is gone** — main.ts owns it. Verified live: one
click, one removal, no double-fire. A legacy fallback remains for a host that doesn't
serve `setSelection` (it's optional in the contract) and is tested.
3. **Research UI.** `T` opens the STANDARDS COMMITTEE: every tech grouped by era in era
order, cost chips in item colours, era prefix stripped from node names, `UNLOCKS:`
tooltips in machine names. Pick → `setResearch`; picking the active one stands it down;
ratified nodes are inert. Locked machines in the bar are greyed + padlocked with
`REQUIRES: <TECH>`, can't be picked by click *or* hotkey, and the padlock falls off in
the same frame `researched` lands. `researched` → "STANDARD RATIFIED. NEW CRIMES
AVAILABLE."
4. **`?uidemo`** — boots the reference factory after init. It already paid for itself.
5. **Accent adopted**: `MachineDef.accent` → first-output derivation → codex kind table.
**LANE-RENDER: confirming we read this identically** — same three tiers, same order.
**VERIFIED — live, in the browser** (`?uidemo`, screenshots taken): the factory builds on
boot with no console poking; the STANDARDS COMMITTEE renders all 23 techs across REEL /
BROADCAST / DISC / STREAM / FORTRESS; clicking SOFTWARE DECODING dispatches `setResearch`
and the node goes hot magenta reading **IN COMMITTEE** with a progress bar (round-tripped
through the real sim); the POWER page shows DECODE ASIC bright and free next to SOFTWARE
DECODER and ASIC COOLER greyed with padlocks and `REQUIRES: STREAM SOFTWARE DECODING` /
`REQUIRES: BROADCAST ASIC COOLING`; `bus.selection()` reads `{mode:'remove'}` and a click
demolishes the mosh reactor once — DRAW 100→92, "UNIT RECLAIMED. THE FLOOR REMEMBERS."
**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 16 tests): the whole
research loop the DoD asked for — **lock visible → `place` silently dropped by the sim →
build a lab → pick the tech through the panel → packs delivered → `researched` → toast →
padlock falls off → the same `place` now succeeds.** Its harness is now a faithful main.ts
(typed selection; *it* dispatches place/remove), which is what caught the double-fire.
**DECISIONS:**
- Packs in the research run are teleported into the lab's intake via the sim's own
`save()`/`load()` (v4 hardened; `load()` re-runs `applyUnlocks`). Everything after that
is the sim's real `drainLabs → researched → applyUnlocks` path, unmocked. Reason in
BLOCKED: nothing in the game can currently *make* a science pack.
- With no `ResearchState` at all, the safe reading of the v4 rule is that gates **hold**
(padlocks on) rather than fall open, and the panel says RESEARCH OFFLINE instead of
rendering a tree that can't do anything. The sim always ships a ResearchState today, so
this is belt-and-braces.
- `live.test.ts` no longer hardcodes reference-factory coordinates — it finds the i-only
assembler by its recipe. reference.ts moved it from (-6,5) to (-6,6) *during this
round* and broke my own test; that's my brittleness, fixed.
**BLOCKED/BROKEN (read this part):**
- **SIM's research gating breaks SIM's own reference factory, and it's live right now.**
`stream-software-decoding` gates `software-decoder`; the sim silently drops placements
of gated machines (`if (!isAvailable(def.id)) return`); reference.ts places two software
decoders for its 200 of generation. Result at HEAD: `?uidemo` builds a factory with
**GEN 80 against DRAW 100, buffer 0/900, permanent BROWNOUT** — it can never run. My
screenshots show it. Either reference.ts should use decode-asics, or the demo should
pre-unlock, or that tech shouldn't gate the generator. (This is also why every
reference-factory test in `live.test.ts` now runs with research pre-granted.)
- **Nothing in the game can make a science pack yet, so no research can actually be
performed by playing.** Packs come only from `artifact-bottler`, whose recipes need
`field-splinters` / `v-hold-roll` / `static-canister` — a broadcast-era chain that
neither the reference factory nor any tech-free layout builds. The tree is *reachable*
on paper (18 techs have satisfiable costs, and DATA fixed the artifact-bottler deadlock
I was about to report), but unreachable in practice. Suggest reference.ts grow a
research rig — it would make this verifiable by playing rather than by save/load.
- **LANE-SIM's `reference.test.ts` is red at HEAD** ("keeps the demuxer alive by voiding
surplus rather than jamming"), and the factory layout shifted twice while I worked. Not
mine; flagging because the suite isn't green.
- **RENDER's demolition tint is still unverified from my side.** The typed `mode` reaches
`setGhost` via main.ts now, so the magic string is no longer my problem — but I never
saw the tint land.
- **The order's own numbers went stale mid-round, again — third round running.**
"24 nodes" is 23 at HEAD; `tech.json`'s era distribution changed under me; the
artifact-bottler deadlock I'd have reported as blocking was fixed by DATA while I
worked; SIM landed *all* of research after my round-start grep said it had none. I now
re-probe everything against a running sim before writing a word of NOTES, which is the
only reason this entry is accurate. Worth considering whether lanes should get a
"HEAD as of" line rather than counts baked into orders.
- Minor, mine: the tab row wraps to two lines now that RESEARCH joined (8 tabs + REMOVE).
Legible, not pretty. Real fix is a scrolling tab strip; say the word.
**CONTRACT REQUEST:** none this round. v4 gave me everything I asked for and the
`SelectionState` migration was clean.
**PROPOSAL:** `Sim.grantResearch(techIds)` or a `?unlocked` demo flag. Every research-
adjacent test I wrote needs research pre-granted, and my only route is
`JSON.parse(sim.save())` → mutate → `load()`. That works and uses public API, but it
couples my tests to SIM's save schema — if they rename `research` in the blob, my suite
breaks for no good reason.
**NEXT (suggested):** verify RENDER's tint; research rig in reference.ts so a pack economy
exists to test against; scrolling tab strip if the catalog grows again; era-skinned tech
panel once eras mean something visually (§8).
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**SHIPPED:** All nine orders. New modules: `power.ts` was round 4; this round adds
`radio.ts` (SPEAKER bridge + echo stand-in), `tuner.ts` (the dock), `screenview.ts`
(enlarge), and tests `tuner.test.ts`, `wildlife.test.ts`, `screenview.test.ts`.
**194 UI tests** (was 145); full suite 446/446; `npm run check` clean.
1. **Scrolling tab strip** — one row, never wraps, active tab scrolled into view on
`[`/`]`. Found and fixed a real bug doing it: see the hitbox finding below.
2. **Inspect mode published**`setSelection({mode:'inspect',entity})` on open, cleared
on close (X, Esc, or the unit being demolished under it). Holding a tool supersedes it
and shuts the panel, so panel and highlight can't disagree.
3. **Wildlife surfaced** — infestation toasts in voice; MOSQUITO EXPOSURE + severity in
the inspector, read from `snap.wildlife` by `target`; hatch warnings deduped one per
pile id.
4. **Research tests migrated** to SIM's `testkit.grantResearch`/`grantAllResearch` — my
round-4 hand-rolled save-blob edit is gone, and with it the coupling to SIM's schema.
Tint + highlight verified, see below.
5. **First-brownout mercy toast** — once per session, on the first brownout event.
6. **Camera hint** in the idle line; **tab hitbox adjudicated** (below).
7. **`no seam` jam voice** — "THERE IS NOTHING DOWN THERE. MOVE." SIM emits this reason
at index.ts:498, so it's live copy, not speculative.
8. **THE SCREEN click-to-enlarge** — ~2.5×, dimmed backdrop, Esc/click closes.
9. **THE TUNER DOCK** — hidden until the fossil is excavated, then slides in with dial
(drag + ←/→), AM/FM/SW, volume + mute, and a station readout in voice.
**VERIFIED — live, in the browser** (screenshots taken of each):
- **Cold-start mercy toast**: placed one extractor with no generator → BROWNOUT, and
under it "NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER." That's the DoD's
opening requirement.
- **THE SCREEN enlarge**: the live WebGL sky at 2.5× centred over a dimmed HUD; Esc
restored its `style` attribute to `null` and its dock rect to y=10, w=322 — exactly as
found.
- **THE TUNER DOCK**: excavated SIM's real seeded fossil → dock slid in bottom-right with
"AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.", dial, FM active, VOL/MUTE, hint line.
- **RENDER's demolition tint — the item owed since round 3 is CLOSED.** Armed remove,
hovered a machine, and it renders outlined in demolition red. Screenshot taken.
- Build bar with padlocks on the POWER page (DECODE ASIC free; SOFTWARE DECODER and ASIC
COOLER greyed + 🔒 + REQUIRES:), reference factory healthy at GEN 220 / DRAW 158.
**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 19 tests): mercy toast fires
once and *stays* once across a second brownout; inspect selection published on open and
dropped both on Esc and when the inspected unit is demolished; the tuner stays hidden
until `relics[0].found` flips and then reveals with the ceremony toast and the toast
standoff; full research loop (lock → pick → deliver → unlock → place) now driven through
`grantResearch` and the lab's real per-pack timing.
**DECISIONS:**
- **THE SCREEN is enlarged in place, not mirrored.** I built the mirror first; it rendered
black. THE SCREEN is a WebGL canvas, and `drawImage` from a WebGL context without
`preserveDrawingBuffer` returns nothing. Asking SCREEN to enable that flag would cost
them a buffer copy every frame for an occasional feature, so I enlarge the real element
with inline styles and restore its `style` attribute verbatim on close (tested,
including the case where SCREEN later adds inline styles of their own).
- **The radio bridge is guarded** (`radio.ts`). `src/audio` is still the stub — no `radio`
export exists — so the dock resolves a control at runtime and otherwise drives a silent
echo stand-in, which is why every dial is testable today. **LANE-SCREEN: publish
`window.__fktrySpeaker = { radio }` (or tell me the export name you prefer) and the dock
drives real audio with zero change here.** The interface I'm driving is exactly your
documented one: `setFrequency / setBand / volume / mute / getState / subscribe`, with
`getState()` returning `{freqMHz, band, volume, muted, station}``station: string|null`
is what flips the readout to "SIGNAL LOCKED". `isLive()` reports which one is attached.
- Wildlife exposure shows the swarm's **severity** (`WildlifeState.size`), not a
sim-confirmed throughput number — SIM owns the real penalty and doesn't expose one.
Labelled as exposure rather than a promise about output.
**BLOCKED/BROKEN (read this part):**
- **The tab-click bug was REAL, and I fixed it.** Reproduced with a genuine mouse click:
a click on POWER did nothing. `elementFromPoint` at the button's own centre returned the
*strip container*, not the button — the horizontal scrollbar was being painted over the
lower half of every tab and swallowing clicks there, which is exactly why keyboard
paging still worked. Fixed by giving the scrollbar its own lane (`padding-bottom: 7px`
≥ scrollbar height); the full button height now hit-tests to the button, and a real
click activates POWER. **Separately**, `computer` clicks via `ref` deliver *no* events to
the page at all (document-level capture recorded nothing); coordinate clicks work fine.
So: real bug, now fixed, plus a harness quirk on top of it.
- **DATA's commission queue head-of-line blocks, and it stalls the whole game.** The v5
queue opens with `raw-payload` (20 raw MDAT ORE), but the reference factory refines every
scrap of its ore and never ships it — so the head commission can never complete and all
19 behind it are unreachable. Probed: 12k ticks ships hf-dust, chroma-slurry,
macroblock-bricks, melt and anchor-slab, and `commissionDone` fires **zero** times. My
tests now build the obvious player rig (extractor → belt → uplink) to pay it. Worth a
design look: either the opening commission should want something the tutorial factory
ships, or an unsatisfiable head shouldn't block the tray.
- **Machines only push onto belts, never into an adjacent machine.** Cost me a while to
find; noting it because it's non-obvious and unstated anywhere in CONTRACTS.
- **RENDER doesn't consume the inspect selection yet** — no `inspect` anywhere in
`src/render`. I publish it correctly (verified live: `{mode:'inspect',entity:105}`), so
the highlight lights up whenever they read it. **Not owed by me any more; owed to me.**
- **Stale ghost on keyboard disarm**: main.ts only refreshes the ghost on `pointermove`, so
Esc-ing out of remove mode leaves the demolition tint on screen until the mouse moves.
Visible in my inspect screenshot. Cosmetic, and it's main.ts's glue, not mine.
- **SIM landed wildlife AND relics mid-round, again** — my round-start grep found neither.
Fifth round running. It's why my wildlife/relic UI could be verified against the real sim
at all, so this is a good problem, but the pattern is now completely reliable: I re-probe
a *running* sim before writing NOTES, never a grep from round-start.
- Three of my live tests failed mid-round from SIM/DATA changes, not my code (proved by
stashing my diff and re-running). Also hit `DUST_SPILL_GRACE is not defined` from SIM
mid-edit, which resolved itself between two consecutive commands.
- Harness, for the record: the browser pane backgrounds the tab, which pauses rAF **and
CSS transitions**. I lost time reading a transitioned `bottom` that never advanced and
briefly thought the CSS was broken; `transition:none` proved it computes correctly. Same
root cause as the paused sim loop from rounds 3-4.
**CONTRACT REQUEST:** none. v5/v6 gave me everything these orders needed.
**PROPOSAL:** `UIBus.audio?: { radio?: RadioControl }` — the tuner currently reaches THE
SPEAKER through a global handshake because `ui.init` never sees the audio module, which is
the same shape of problem as `pickTile` in round 1 and the `__remove` sentinel in round 3.
One optional field on the bus would make it typed and testable instead of a documented
convention. Not blocking: the guarded bridge works.
**NEXT (suggested):** wire the real radio the moment SCREEN publishes it (and describe how
it sounds in NOTES, since they're doing the same); inspect highlight once RENDER reads the
selection; a station-locked readout screenshot once there are real stations; commission
head-of-line blocking is a design question for you, not a UI fix.
### Round 6 — 2026-07-20 — Opus 4.8
**SHIPPED:** All three orders. New modules: `corrections.ts` (session tally of The
Correction's edits) + `mite.test.ts`. Extended: `voice.ts`, `topstrip.ts`, `inspector.ts`,
`tuner.ts`, `radio.ts`, `techpanel.ts`, `toasts.ts`, `index.ts`, `style.ts`, and the tuner/
techpanel test files. **213 UI tests** (was 194); every `src/ui/**` file green; `npm run
check` reports zero errors in my lane (the one tsc error is SCREEN's, below).
1. **Mite surfacing — the enemy has copy now.** Replaced the v8 placeholder in
`wildlifeToast`: a mite arriving is "A PARITY MITE IS EDITING YOUR MELT.", swatting one
is "MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU." (the Correction's manners
are the insult). `repaired` events (v8) now toast in the house voice —
"THE CORRECTION IMPROVED YOUR <ITEM>. IT LEFT A RECEIPT." — item name resolved from
`data.items` via a resolver threaded through `toasts.push → eventToast`. Two more
surfaces so a raid is narratable without watching the belts: a **CORRECTED ticker** in
the top strip (session total + per-item chips, hidden until the first edit so the enemy
is never signposted early), and a **FIREWALL LOG** in the inspector — per-unit catch
count, attributed by footprint containment of each `repaired` pos. The Correction reads
clinical white-green everywhere (codex §8: hospital-white, green checkmarks), calm next
to the hot alarms — that's the intended menace.
2. **Tuner tick marks.** The dial now draws a hot-magenta tick per station on the current
band, positioned by `(freqMHz-min)/(max-min)`, from the speaker adapter's
`control.listStations()` (added optional `listStations()` + `StationMark` to
`RadioControl`; the echo stand-in returns `[]`). Ticks are **anonymous** — they mark
THAT a station is there, not WHAT; the readout stays the only thing that names a lock,
so the hunt is intact. Rebuilt only on band/station-set change; empty pre-gesture and
fills in on the first state push after the engine attaches — i.e. exactly when audio
comes alive.
3. **ABOUT/CREDITS.** A footer button on the tech panel toggles a colophon: repo
(gitea.partly.party/monster/glytch), codex (docs/FKTRY_LORE.md), a VOICES line, and a
**standing disclosure slot** — "SYNTHETIC VOICE CREDIT: NONE ON FILE." — reserved for
the voice-cloning plan's real credit when it ships.
**VERIFIED — live, on :8153 (?uidemo)**: app boots, **no console errors**. Credits: pressed
into the DOM path with a real click event → block reveals with repo/codex/voices and the
disclosure slot (text asserted). Confirmed present-and-correctly-hidden by default:
`.fk-corrected-wrap` (display:none pre-mite), `.fk-tuner-ticks` (0 ticks pre-gesture),
`.fk-ins-firewall` (hidden). `--fk-clinic` resolves to #d6f5df. The tab backgrounds
(viewport 0x0, rAF paused) exactly as every prior round documents, so the mite raid itself
isn't drivable live here — see below.
**VERIFIED — headless** (`mite.test.ts`, real modules, happy-dom, 14 tests): the mite voice
(arrival/swat/receipt copy + name fallback); the tally (ignores non-repairs, first-seen
per-item order, footprint containment); the FIREWALL LOG (hidden at zero, singular/plural
"1 TIME"/"2 TIMES", survives no-corrections); the CORRECTED ticker (hidden until first edit,
then count + chips). Plus `tuner.test.ts` (+2): no ticks pre-attach, one tick per on-band
station at the right `left%`, anonymous. `techpanel.test.ts` (+3): ABOUT collapsed, reveals
repo/codex/slot, toggles shut.
**DECISIONS:**
- **`repaired.pos` is the mite's belt tile, not a machine.** SIM repairs belt *items* (index.ts
~1316), so the event lands on a belt tile. FIREWALL LOG therefore shows catches on whatever
unit's footprint that tile falls in — usually the belt itself (1×1), sometimes a crafter if
the corrected item sat on its tile. That's honest: it's "edits on THIS unit's tiles", and
belts are inspectable. Not a per-machine damage total (SIM doesn't expose one).
- **"Firewall" is grounded, not invented.** SIM already has `FIREWALL_RADIUS` /
`miteInFirewallRange` (index.ts ~1329) — firewalls can mites on the approach. Naming the
inspector's catch log FIREWALL LOG matches that fiction rather than coining a term.
- Item-name resolution stays data-driven (LANE-DATA's `ItemDef.name` wins); the resolver
falls back to dashes→spaces+uppercase so a brand-new item never crashes a toast.
- Corrections state is session chrome, deliberately not persisted — the sim doesn't persist
it either, and re-deriving it from a save would be a lie about what actually happened.
**BLOCKED/BROKEN (read this part):**
- **The mite raid is reachable by play but not in this harness.** Mites spawn once
`corruptionShipped()/MITE_SPAWN_PER` clears a threshold (index.ts ~1282), so the reference
factory *will* eventually breed them and fire `repaired` — but that needs sustained
runtime, and the browser pane backgrounds the tab (rAF paused, viewport 0x0), the same
wall documented in rounds 3-5. So the mite surfaces are verified headless against the real
UI modules, and live only for DOM presence/default-state/no-errors. If you can drive a
foregrounded build to the mite threshold, the ticker/firewall/toasts light up with zero
further change.
- **`npm run check` has one error, and it is not mine: `src/screen/index.ts:106` ("function
must return a value").** SCREEN's file is modified-uncommitted in the shared tree. My lane
is clean — every `src/ui/**` file type-checks. esbuild strips types so the dev build boots
fine regardless.
- **`npm test`: 4 failures across 3 files, none mine** — `src/audio/band.test.ts` (SCREEN),
`src/sim/mite.test.ts` (2) and `src/sim/reference.test.ts` (SIM). All three files are
modified-uncommitted by their lanes. Confirmed by ownership: I touched no `src/sim`,
`src/audio`, or `src/screen` file. All 17 `src/ui/**` test files pass (213 tests).
- Re-probed HEAD live, not from a round-start grep: SIM *does* emit both `parity-mite` and
`repaired` at HEAD (index.ts 1288/1321), and the adapter's `listStations()` is live in
main.ts — so nothing here is speculative against a contract that hadn't landed.
**CONTRACT REQUEST:** none. Contracts v8 (`repaired`) + the speaker adapter's `listStations()`
gave me everything these orders needed.
**PROPOSAL:** a `?mites` dev flag (or a `testkit` verb) that seeds N parity-mites / forces a
`repaired` on boot, the mite analogue of `?uidemo`. Every mite surface I built is only
observable once corruption crosses a spawn threshold that the backgrounded harness can't
reach; a one-command seed would make the raid demoable in the browser instead of only in
`mite.test.ts`. Small, and it'd live in main.ts (yours) or SIM's testkit, not my lane.
**NEXT (suggested):** fill the credits' synthetic-voice slot when the voice-cloning plan
ships; name-on-hover for dial ticks *only* if playtesting says the hunt is too hard (default
stays anonymous); a per-machine "corrected N times" once/if SIM attributes repairs to a
machine rather than a belt tile; Checksum Wardens / Redundancy Gunships get the same
surfacing treatment when they arrive (the corrections module generalises to any Correction
edit event).
### Round 7 (Phase 1) — 2026-07-29 — Opus 5
**SHIPPED:** All five orders. New modules: `parity.ts` (band arithmetic), `debtgauge.ts`,
`notices.ts` (the notice tray), `stasis.ts` (sector locks + countdown), `seal.ts` (pry
tracking), `correction.test.ts` (43 tests) and 7 new live tests. Extended: `voice.ts`,
`topstrip.ts`, `inspector.ts`, `fax.ts`, `corrections.ts`, `index.ts`, `style.ts`.
**263 UI tests** (was 213); `npm run check` clean tree-wide.
1. **PARITY DEBT gauge** in the top strip: five labelled bands (NOMINAL / OBSERVED /
AUDITED / SEALED / REFUSED), clinic white-green, no red anywhere and no flash — a test
asserts the gauge's markup contains no alarm class at all, because the contrast with
BROWNOUT four rows up *is* the characterisation. Hidden until the debt first moves;
once revealed it stays, so venting back to NOMINAL is something you get to watch. The
sim's `band` is truth; `bandFromDebt` is a fallback for a partial state and is never
used when SIM publishes one.
2. **Notices are faxes** — a white-letterhead tray above the commission fax, one slip per
`notice`, 20s of paper each, max 3. RULING enforced by test: no unit name, and
`expect(t).not.toMatch(/\d/)` — a notice cannot contain a coordinate because it cannot
contain a digit. Three distinct lines, one per rung, none of which say who is coming.
Deliberately **not** also a toast: one document, one place.
Compliance quotas get their own clinic COMPLIANCE stamp (bottom corner, so a fax can be
both a standing order and a quota) plus "QUOTA. FILLING IT VENTS PARITY DEBT."
3. **Seal + PRY** — inspector seal panel, PRY dispatching `{kind:'pry', pos}`, the bar
driven by SIM's own `EntityState.progress`, and the jam line reading "UNIT SEALED. IT IS
COMPLIANT AND IDLE." in clinic white rather than fault amber. `jamText('sealed')` maps
SIM's reason string to the same line.
4. **Stasis**`stasis` on → toast naming the hash + a countdown chip per open lock;
auditor `prescan` → the abstention teach, once per auditor, re-armable if that auditor
leaves and returns.
5. **MIRRORED ticker** — see DECISIONS for the split.
**VERIFIED — live, on :8153, in a real browser with real CSS.** The pane still backgrounds
the tab (rounds 3-6: `document.hidden`, viewport 0x0, rAF paused), so instead of screenshots
I imported the real modules in the page and pumped the real sim synchronously into a real
`createUI()` — same code, same stylesheet, real `getComputedStyle`:
- **Full warden cycle:** warden walked in, audited and sealed a QUANTIZER at tick 2561 →
clicked it → inspector opened with the seal panel (border resolves to the real
`#d6f5df`), jam line clinic white → PRY → button "PRYING"/disabled, bar at **4.44%**
(= 4/90 ticks, SIM's own number) → freed → "SEAL REMOVED. THIS WILL BE NOTED." and debt
0.6821 → 0.7338. Prying really does cost you.
- **Full auditor cycle:** prescan → "AN AUDITOR IS READING. STOP MOVING." with the lock row
still hidden (the teach lands *before* the lock, or it teaches nothing) → lock → chip
`744510A8 · HELD`, then `744510A8 · 13S` once the phase reveals its length → toast with
the identical hash → released **with no player action**, "SECTOR RELEASED."
- Notice tray on The Correction's letterhead with all three rung lines; CORRECTED row at 3
mite edits; `#ui` still computes `pointer-events:none`; **no console errors**.
**VERIFIED — headless:** `correction.test.ts` (43) pins the bands, the no-digits notice
rule, the seal/pry lifecycle, the lock countdown arithmetic and the ticker split.
`live.test.ts` (+7, 26 total) runs it all against the real sim.
**DECISIONS:**
- **MIRRORED is a second row, not the same counter.** A mite editing your melt and a
gunship out-shipping you with a clean copy are different crimes; one number that added
them together would read worse than two that don't. Both live under the existing
CORRECTED block, each row hidden until its own first event. `catchesOn` (the FIREWALL
LOG) still counts repairs only — a mirror happens on the ledger, not on your machine.
- **The pry bar reads SIM's `EntityState.progress`, not my own clock.** SIM reuses the
craft-progress field while a unit is sealed, so the bar is sim truth and survives a
reload; my tick tracker only covers the frame between the click and the command landing,
and reports `null` (an honestly indeterminate bar) if a future sim publishes nothing.
Consequence handled: the PROCESS bar hides while sealed, or that same number would read
as "60% through a batch" when no batch exists. Buffers stay visible.
- **The stasis countdown is derived, not imported.** elapsed/progress gives the phase
length, so the chip says HELD until the auditor's own progress reveals it and seconds
after. No tuning constant is imported and none is guessed — if SIM retunes
`AUDITOR_STASIS_TICKS`, the chip is right anyway.
- Notices don't toast, and the abstention teach reads a snapshot *phase* rather than an
event: being read is a state you can be caught inside of, not a moment you can miss.
**BLOCKED/BROKEN (read this part):**
- **Firewalls are currently a hard counter to rungs 2-3, and rung 3 never happens at all.**
Traced at HEAD: a warden walks in from the rim and `firewallPrey` cans any non-auditor
inside `FIREWALL_RADIUS`, so it dies mid-approach in the reference factory roughly nine
runs in ten (12k ticks → **1** seal). The gunship is worse: it *arrives at the centre of
the worst sector*, i.e. inside your factory, and is canned during its own notice window —
in 12k ticks it never once left `notice`, so `mirrored` never fired and the MIRRORED row
has **never been seen with a real event**. Its UI is covered headless. LANE-SIM/
orchestrator: this may be intended ("kill the parity link to pop it"), but popping it
before it exists means rung 3 is invisible content today.
My warden live test therefore removes the firewalls first, with the player's own `remove`
command, and says why — I'd rather a deterministic DoD than a coin flip.
- **Repeated `save()`/`load()` round-trips stall a warden mid-approach.** Topping the debt
up through save/load every 300 ticks produced 1,689 ticks of `approach` and zero audits
across a 12k run, vs. a seal in the same window without it. A player who saves often may
be accidentally immune to The Correction. Not my lane; flagging with the repro.
- **`src/sim/reference.test.ts` is red at HEAD** ("keeps the demuxer alive by voiding
surplus rather than jamming", 0 > 300). Not mine — I touched no `src/sim` or `data` file.
All 18 `src/ui/**` files pass, 263 tests.
- Cosmetic, harness-only: in a synchronous pump (no wall clock), retired toasts and notice
slips linger in the DOM for their 400ms fade because removal is a `setTimeout`. Live it
is invisible; in a headless drive the node counts read high.
- The notice tray is capped at `max-height:178px` because I could not measure it against
the commission fax (0x0 viewport). Three long slips fit above y=200 by arithmetic, and
the cap makes overlap impossible rather than unlikely — but nobody has seen it at full
height on a real screen.
- Order-numbers-go-stale, sixth round running: at round start SIM had *none* of the
Correction (`grep` for `sealed`/`lockHash` in `src/sim` → nothing) and `npm run check`
had 20 errors from their in-flight edit. Everything above was written against contracts
v9 and then re-probed against a **running** sim; the pry-progress and jam-reason
decisions above exist only because I re-read their landed code instead of my own notes.
**CONTRACT REQUEST:**
1. **`SimSnapshot.correction.units[].kind === 'hash-auditor'` needs a published prescan
deadline, or `CorrectionUnit.remaining?: number`** — *why:* every countdown I show is
derived from `progress` and elapsed ticks, which works but goes blind for the first few
ticks of a phase (the chip reads HELD). One optional field and the lock chip could count
down from the first frame, including across a save/load where I have no `sinceTick`.
2. **A `testkit` verb for The Correction** (`chargeDebt(sim, n)` / `spawnUnit(sim, kind)`) —
*why:* my live tests reach band 4 by hand-editing a save blob, exactly the coupling
round 4 flagged and round 5 fixed for research. SIM owns the schema; a verb costs them
three lines and un-couples every UI/RENDER/SCREEN test of rungs 2-4.
**PROPOSAL:** the jam toast needs a rate limit. Driving the reference factory for 1,500
ticks produced **~170 toasts**, nearly all "QUANTIZER: INTAKE STARVED", and The Correction's
lines — the ones that matter — scroll past inside that noise. A per-entity cooldown (or
"× 12" on a repeated line) is a small change in `toasts.ts` and it is the difference between
the new copy landing and being buried. Not done unasked; say the word.
**NEXT (suggested):** wire the MIRRORED row to a real gunship the moment rung 3 can survive
its own arrival; a moov-heist UI when the lock becomes breakable (the chip already names the
hash the minigame will want); compliance quota progress on the debt gauge itself (show what
filling the current quota would vent) once DATA settles the quota list.
**ADDENDUM (same round, after the orchestrator's v9.1 and SIM/DATA's next landings).**
Re-verified against HEAD rather than against my own commit:
- `npm run check` is now **clean tree-wide**, including under v9.1's `CorrectionUnit.phase`
union — my `'stasis'`/`'prescan'` comparisons were already exact members, no change needed.
- **263 UI tests green, all 18 files.** The only red in the tree is LANE-SIM's own
`src/sim/mite.test.ts` (2, their firewall work, uncommitted). Their `reference.ts` also
spent ~5 minutes throwing "firewall at -21,9 overlaps belt at -21,10" from its own
validator, which took every reference-factory test in the repo (mine and theirs) down
with it; it self-resolved, as mid-edit files have every round since round 2.
- **I had to correct one of my OWN round-3 tests, and the reason is worth recording.**
"NEXT IN TRAY … advances it on completion" asserted the sim lands on `commissionQueue[1]`.
That was always a claim about SIM's cascade rather than about the fax, and DATA's v9
commissions broke it two different ways within twenty minutes: first the queue skipped
`first-refinement` (the factory had been shipping since tick 0, so entries behind the
head were *already* satisfied and completed in the same tick they became active), then
the head became `patio-resurfacing`, which my raw-ore rig cannot pay at all, so nothing
advanced. The test now asserts the fax's real contract unconditionally — the peek is the
live `commissionQueue[1]` — and only asserts the advance when the head is one this rig
can actually pay. The head-of-line blocking I flagged in round 5 is still live and is
still a design question, not a UI fix.
**ADDENDUM 2 — the toast cooldown (orchestrator-approved) + contracts v9.2.**
**277 UI tests** (was 263), 19 files, `npm run check` clean tree-wide.
- **Toast cooldown shipped** (`toasts.ts` + `toasts.test.ts`, 12 tests). Measured the same
way I reported the bug: 1,500 ticks of the reference factory printed **~170 lines
before, 43 after** — and with The Correction at band 4, all six of its line types are on
screen instead of buried. A throttled repeat isn't silenced, it counts: the live line
gains `×N` (biggest observed: ×14), and the window is measured from the last line
PRINTED, so a permanent stall still speaks once every 12s rather than going quiet.
Never throttled, by list: brownout, commissionDone, researched, relicFound, sealed,
stasis, debtBand — each of those is news exactly once.
- **RULING I had to make live: the throttle key is the RENDERED LINE, not the entity.**
My first cut keyed on (entity, reason) and passed its tests, but driving the real
factory still showed **four identical "QUANTIZER: INTAKE STARVED" lines** — one per
quantizer. The copy deliberately carries no unit id, so four identical lines tell the
player strictly less than "×4" does. A line that differs in any way — different reason,
different machine name — is a different fact and is never suppressed. Both cases are
pinned (`merges two units the player cannot tell apart`, `lets a NEW reason through`).
- **v9.2 `CorrectionUnit.remaining` adopted** in `stasis.ts`: used whenever it is a finite
number ≥ 0 (so `0` counts — it is a real number, not a missing one), with the
phase-derived estimate kept as the fallback. Both paths are pinned by test. Worth
recording how narrowly I avoided writing a false NOTE: my grep said SIM had not filled
it, and by the time I finished the sentence they had (`phaseClock`, index.ts ~1705), so
I re-probed and rewrote this bullet. **It is live**, the chip is now exact from frame
one, and a fixed-length phase counts down while an open-ended one (a gunship's tether)
correctly leaves it undefined — which my `typeof === 'number'` guard reads as "nothing
to count down" and falls back on, exactly as intended. Verified live against their
landed code: the chip now opens at `744510A8 · 15S` and counts 15/14/13/12/11 instead of
opening on HELD, and a tethered gunship reports `remaining: undefined` as documented.
- **My firewall finding is FIXED, and it changed a "never verified" into a verified one.**
Gunships now reach `tether` against the firewalled reference factory, so `mirrored`
fires for real: the MIRRORED row lit live with **3** and a MACROBLOCK BRICKS chip in
clinic white. The live test's fallback branch stays, but only as a regression guard.
- **One copy wart the first real mirror exposed:** it printed "YOUR MACROBLOCK BRICKS WAS
MIRRORED" — item names are a mix of mass nouns and plurals, and the deadpan does not
survive a broken verb. Rewritten to carry no verb that has to agree: "A CLEAN COPY OF
YOUR <ITEM> SHIPPED FIRST. THE LEDGER IS SATISFIED." — which also states the mechanic.
Pinned with a plural-item test.
**v9.3 note (landed as I pushed):** `notice.pos?` and `repaired/mirrored by?` are
RENDER's cues and my lane re-verified clean against them (tsc clean, 277 green). I am
deliberately **not** surfacing `notice.pos` anywhere in the fax: the round-7 ruling is
that a notice gives no grid reference, and the field existing for the renderer's
materialisation cue doesn't change what the paper is allowed to say. `by?` is the first
thing that could make the FIREWALL LOG per-UNIT rather than per-tile (round 6's honest
compromise) — happy to take that next round if you want it.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,84 @@
/**
* Layer 3 world ambience. Post-discovery only.
*
* The factory gets a voice once the fossil is dug up: a hum that tracks how hard the plant is
* working, and a strained whine as decoders approach scram. Both are continuous voices built
* once and driven by AudioParams nothing here allocates per frame.
*
* Subordinate by design: the hum sits under everything, and ducks further when the radio is
* carrying a station, because a tuned station is what the player asked to hear.
*/
import { glide, noiseSource } from './graph';
export interface Ambience {
out: GainNode;
start(t0: number): void;
/**
* @param load 0..1 how hard the plant is running (draw vs gen)
* @param fever 0..1 aggregate heat the strained whine near scram
* @param duck 0..1 how much the radio wants the floor
*/
update(load: number, fever: number, duck: number): void;
}
export function createAmbience(ctx: BaseAudioContext): Ambience {
const out = ctx.createGain();
out.gain.value = 0;
// ---- the hum: mains-ish fundamental + its octave, plus a broadband machine-room bed.
const hum = ctx.createOscillator();
hum.type = 'sawtooth';
hum.frequency.value = 50;
const humLp = ctx.createBiquadFilter();
humLp.type = 'lowpass';
humLp.frequency.value = 180;
const humG = ctx.createGain();
humG.gain.value = 0.0;
hum.connect(humLp).connect(humG).connect(out);
const room = noiseSource(ctx, 'brown');
const roomLp = ctx.createBiquadFilter();
roomLp.type = 'lowpass';
roomLp.frequency.value = 600;
const roomG = ctx.createGain();
roomG.gain.value = 0.0;
room.connect(roomLp).connect(roomG).connect(out);
// ---- the strained whine: only shows up as machines climb toward a scram.
const whine = ctx.createOscillator();
whine.type = 'triangle';
whine.frequency.value = 1900;
const whineG = ctx.createGain();
whineG.gain.value = 0;
whine.connect(whineG).connect(out);
let started = false;
return {
out,
start(t0) {
if (started) return;
started = true;
hum.start(t0);
room.start(t0);
whine.start(t0);
},
update(load, fever, duck) {
if (!started) return;
const l = Math.min(1, Math.max(0, load));
const f = Math.min(1, Math.max(0, fever));
const d = 1 - 0.55 * Math.min(1, Math.max(0, duck)); // radio wins, but never silences
glide(humG.gain, (0.035 + 0.05 * l) * d, ctx, 0.4);
glide(roomG.gain, (0.02 + 0.045 * l) * d, ctx, 0.4);
// a busier plant runs its mains a touch sharp — cheap, and it reads as effort
glide(hum.frequency, 50 + 6 * l, ctx, 0.5);
glide(roomLp.frequency, 500 + 900 * l, ctx, 0.5);
// Heat only becomes audible late; below 0.55 a warm factory is just a factory.
const strain = Math.max(0, (f - 0.55) / 0.45);
glide(whineG.gain, 0.03 * strain * strain * d, ctx, 0.3);
glide(whine.frequency, 1700 + 900 * strain, ctx, 0.3);
},
};
}

View File

@ -0,0 +1,109 @@
/**
* THE BAND's data contract and treasure map, verified without a browser.
*
* The synthesis needs an AudioContext and is verified spectrally in the dev build; what CAN be
* pinned here is the part that would silently rot: the station schedule, and the promise that
* the NUMBERS station's coordinates actually lead somewhere.
*/
import { describe, expect, it } from 'vitest';
import { BAND_RANGE, type Band } from './radio';
import type { StationDef } from './stations';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';
const STATIONS = (stationData as { stations: StationDef[] }).stations;
const SEAMS = (seamData as {
seams: { id: string; rect: { x: number; y: number; w: number; h: number }; richness: number; hidden?: boolean }[];
}).seams;
/** LANE-DATA's encoding: [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]. */
function decode(group: string) {
return {
x: Number(group.slice(0, 2)) - 32,
y: Number(group.slice(2, 4)) - 32,
richness: Number(group[4]) / 9,
};
}
describe('the dial is well formed', () => {
it('has all four stations', () => {
expect(STATIONS.map((s) => s.id).sort()).toEqual(
['dead-air', 'fktry-fm', 'numbers', 'the-standard'].sort(),
);
});
it('puts every station inside its own band, or the tuner could never reach it', () => {
for (const s of STATIONS) {
const [lo, hi] = BAND_RANGE[s.band as Band];
expect(s.freq, `${s.id} @ ${s.freq} outside ${s.band} ${lo}-${hi}`).toBeGreaterThanOrEqual(lo);
expect(s.freq).toBeLessThanOrEqual(hi);
}
});
it('never puts two stations on the same spot of the same band', () => {
const seen = new Set<string>();
for (const s of STATIONS) {
const k = `${s.band}:${s.freq}`;
expect(seen.has(k), `duplicate ${k}`).toBe(false);
seen.add(k);
}
});
});
describe('the numbers station is an honest treasure map', () => {
const numbers = STATIONS.find((s) => s.id === 'numbers')!.numbers!;
const hidden = SEAMS.filter((s) => s.hidden);
it('plants exactly the hidden seams, and they decode to the real tiles', () => {
expect(numbers.plantedGroups).toHaveLength(hidden.length);
const decoded = numbers.plantedGroups.map(decode);
for (const h of hidden) {
const hit = decoded.find((d) => d.x === h.rect.x && d.y === h.rect.y);
expect(hit, `no broadcast group leads to ${h.id} at ${h.rect.x},${h.rect.y}`).toBeTruthy();
expect(hit!.richness).toBeCloseTo(h.richness, 1);
}
});
it('keeps every group the same width, so a listener can chunk them by ear', () => {
for (const g of [...numbers.plantedGroups, ...numbers.decoyPool, numbers.preamble]) {
expect(g).toHaveLength(numbers.groupSize);
expect(g).toMatch(/^\d+$/);
}
});
it('no decoy ever leaks a HIDDEN seam — the one that would break the hunt', () => {
// This is the invariant THE SPEAKER must protect: the only groups that lead to secret
// ground are the planted ones. A decoy landing on an already-visible seam is untidy but
// harmless (see NOTES: two currently do, reported to LANE-DATA); a decoy landing on a
// hidden seam would hand out the treasure for free.
for (const g of numbers.decoyPool) {
const d = decode(g);
const leak = hidden.find(
(s) => d.x >= s.rect.x && d.x < s.rect.x + s.rect.w && d.y >= s.rect.y && d.y < s.rect.y + s.rect.h,
);
expect(leak, `decoy ${g} gives away hidden seam ${leak?.id} at ${d.x},${d.y}`).toBeUndefined();
}
});
it('broadcasts a planted group often enough to be findable', () => {
// Every 4th message, 8 groups a message, 1.2s a group: a planted coordinate roughly every
// couple of minutes. Long enough to feel like a signal, short enough to catch one.
const secondsPerMessage =
(numbers.groupsPerMessage + numbers.preambleRepeats) * numbers.secondsPerGroup;
expect(secondsPerMessage * numbers.plantEveryMessages).toBeLessThan(120);
});
});
describe('THE STANDARD has something to say', () => {
const lines = STATIONS.find((s) => s.id === 'the-standard')!.chyron!;
it('carries enough copy that it does not obviously loop', () => {
expect(lines.length).toBeGreaterThanOrEqual(20);
expect(new Set(lines).size).toBe(lines.length);
});
it('fits the chyron — long lines would run off the sky', () => {
// The chyron band renders ~62 monospace chars at 20px across 640px.
for (const l of lines) expect(l.length, `too long to subtitle: "${l}"`).toBeLessThanOrEqual(96);
});
});

View File

@ -0,0 +1,90 @@
/**
* THE COMPLIANCE TONE's sparseness, pinned without a speaker.
*
* The synthesis is verified spectrally in the dev build (a single partial, no sidebands a
* node test has no AudioContext to render into). What CAN be pinned here is the property that
* actually protects the design: the tone must fire ONCE per warden arrival. A tone that struck
* every frame a warden stood on station would be a continuous tone, and a continuous "only
* clean sound" would demote the ratification sting it is one voice of.
*/
import { describe, expect, it } from 'vitest';
import type { CorrectionUnit } from '../contracts';
import { arrivedWardens, WARDEN_ARRIVAL_PHASE } from './compliance';
import { COMPLIANCE_HZ, RATIFY_TRIAD } from './sfx';
function unit(
id: number,
phase: CorrectionUnit['phase'], // v9.1: the union is pinned now — a typo'd phase fails here
kind: CorrectionUnit['kind'] = 'checksum-warden',
): CorrectionUnit {
return { id, kind, pos: { x: 0, y: 0 }, phase, progress: 0 };
}
describe('the tone is one voice of the ratification triad, not a new sound', () => {
it('is literally a member of the triad', () => {
expect(RATIFY_TRIAD).toContain(COMPLIANCE_HZ);
});
it('is A4 — the pitch the whole industry agreed to calibrate against', () => {
expect(COMPLIANCE_HZ).toBe(440);
});
});
describe('a warden arrival strikes exactly once', () => {
it('strikes when a warden reaches its station', () => {
const { struck, next } = arrivedWardens(new Set(), [unit(7, WARDEN_ARRIVAL_PHASE)]);
expect(struck).toEqual([7]);
expect(next.has(7)).toBe(true);
});
it('does NOT strike again while the warden stands there working', () => {
let on: ReadonlySet<number> = new Set();
let strikes = 0;
for (let frame = 0; frame < 200; frame++) {
const r = arrivedWardens(on, [unit(7, WARDEN_ARRIVAL_PHASE)]);
strikes += r.struck.length;
on = r.next;
}
expect(strikes).toBe(1);
});
it('says nothing at all while the warden is still approaching', () => {
const { struck, next } = arrivedWardens(new Set(), [unit(7, 'approach')]);
expect(struck).toEqual([]);
expect(next.size).toBe(0);
});
it('strikes again for a SECOND visit — that is a second arrival', () => {
let on: ReadonlySet<number> = new Set();
on = arrivedWardens(on, [unit(7, WARDEN_ARRIVAL_PHASE)]).next;
on = arrivedWardens(on, [unit(7, 'leaving')]).next;
expect(on.size).toBe(0);
const back = arrivedWardens(on, [unit(7, WARDEN_ARRIVAL_PHASE)]);
expect(back.struck).toEqual([7]);
});
it('ignores gunships and auditors — this is the WARDEN\'s note', () => {
const { struck } = arrivedWardens(new Set(), [
unit(1, WARDEN_ARRIVAL_PHASE, 'redundancy-gunship'),
unit(2, WARDEN_ARRIVAL_PHASE, 'hash-auditor'),
]);
expect(struck).toEqual([]);
});
it('is inert when The Correction is not in the snapshot at all', () => {
const { struck, next } = arrivedWardens(new Set([7]), undefined);
expect(struck).toEqual([]);
expect(next.size).toBe(0); // and it forgets, so a later return counts as an arrival
});
it('two wardens landing together are two ids, and the caller strikes one note', () => {
// The tracker reports honestly; sparseness is enforced at the call site (one strike per
// frame, plus a min-gap). Pinning the shape here so a future refactor cannot quietly turn
// a burst of arrivals into a chord.
const { struck } = arrivedWardens(new Set(), [
unit(1, WARDEN_ARRIVAL_PHASE),
unit(2, WARDEN_ARRIVAL_PHASE),
]);
expect(struck).toHaveLength(2);
});
});

View File

@ -0,0 +1,41 @@
/**
* THE COMPLIANCE TONE's trigger logic, kept pure so it can be pinned without a speaker.
*
* The tone itself lives in sfx.ts with its siblings; what lives here is the *when*. Two things
* strike it: a `notice` event (an edge the compositor gets that for free) and a checksum
* warden ARRIVING on station, which is not an edge at all. Arrival is only visible as a phase
* transition inside `snapshot.correction.units`, so somebody has to remember last frame's
* phases, and that somebody should be testable.
*
* Sparseness is the whole design constraint: one note per arrival, never a note per frame while
* the warden stands there working.
*/
import type { CorrectionUnit } from '../contracts';
/**
* The sim-defined phase a warden is in once it has reached its target and started work.
* Contracts v9 documents the vocabulary as 'approach' | 'audit' | 'tether' | 'prescan' |
* 'stasis' | 'leaving' 'audit' is the warden's arrival.
*/
export const WARDEN_ARRIVAL_PHASE = 'audit';
/**
* Which wardens have just arrived, given who was already on station.
*
* Returns the ids that newly reached the arrival phase plus the set to carry into next frame.
* A warden that leaves and comes back strikes again that is a second arrival, and the player
* should hear it. A warden merely *standing* in the audit phase strikes nothing.
*/
export function arrivedWardens(
onStation: ReadonlySet<number>,
units: ReadonlyArray<CorrectionUnit> | undefined,
): { struck: number[]; next: Set<number> } {
const next = new Set<number>();
const struck: number[] = [];
for (const u of units ?? []) {
if (u.kind !== 'checksum-warden' || u.phase !== WARDEN_ARRIVAL_PHASE) continue;
next.add(u.id);
if (!onStation.has(u.id)) struck.push(u.id);
}
return { struck, next };
}

125
fktry/src/audio/graph.ts Normal file
View File

@ -0,0 +1,125 @@
/**
* THE SPEAKER's plumbing: master chain, shared noise, scheduling helpers.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever. A game about
* broken media makes its own noise.
*
* Two rules that shape everything here:
* - **Zero per-frame allocations.** Continuous voices (hum, station beds) are built once at
* unlock and then only have their AudioParams driven. Only genuinely one-shot sounds
* allocate nodes, and those are short-lived and self-stopping.
* - **Context-agnostic.** Nothing below assumes a live AudioContext. Every builder takes a
* BaseAudioContext, so the whole synth stack can be rendered in an OfflineAudioContext for
* deterministic spectral verification the same code the player hears.
*/
/** Noise buffers are expensive to fill, so each context gets one of each, cached. */
const noiseCache = new WeakMap<BaseAudioContext, { white: AudioBuffer; brown: AudioBuffer }>();
function buildNoise(ctx: BaseAudioContext) {
const len = Math.floor(ctx.sampleRate * 2); // 2s loops — long enough to not read as periodic
const white = ctx.createBuffer(1, len, ctx.sampleRate);
const brown = ctx.createBuffer(1, len, ctx.sampleRate);
const w = white.getChannelData(0);
const b = brown.getChannelData(0);
// Deterministic PRNG: same noise every run, so spectra are reproducible across verifications.
let seed = 0x1337c0de;
const rnd = () => {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296 * 2 - 1;
};
let last = 0;
for (let i = 0; i < len; i++) {
const n = rnd();
w[i] = n;
// brown/red noise: integrated white, gently leaked to stop DC runaway
last = (last + 0.02 * n) / 1.02;
b[i] = last * 3.5;
}
return { white, brown };
}
export function noise(ctx: BaseAudioContext) {
let n = noiseCache.get(ctx);
if (!n) {
n = buildNoise(ctx);
noiseCache.set(ctx, n);
}
return n;
}
/** A looping noise source. Caller owns start/stop. */
export function noiseSource(ctx: BaseAudioContext, kind: 'white' | 'brown'): AudioBufferSourceNode {
const src = ctx.createBufferSource();
src.buffer = noise(ctx)[kind];
src.loop = true;
return src;
}
export interface MasterChain {
/** everything in the mix connects here */
input: GainNode;
/** master volume, pre-limiter */
volume: GainNode;
}
/**
* gain soft limiter destination.
*
* The limiter is a WaveShaper running tanh, not a DynamicsCompressor: a compressor's lookahead
* and automatic makeup would pump the whole mix every time the klaxon fires. tanh just refuses
* to go past 1, which is what "don't clip" actually means, and it adds the faint saturation a
* blown factory PA ought to have.
*/
export function createMasterChain(ctx: BaseAudioContext): MasterChain {
const input = ctx.createGain();
const volume = ctx.createGain();
volume.gain.value = 0.9;
const limiter = ctx.createWaveShaper();
const n = 1024;
const curve = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1;
curve[i] = Math.tanh(x * 1.6);
}
limiter.curve = curve;
limiter.oversample = '2x';
input.connect(volume);
volume.connect(limiter);
limiter.connect(ctx.destination);
return { input, volume };
}
/** Ramp a param without clicks. Web Audio hates instantaneous jumps on a running graph. */
export function glide(p: AudioParam, to: number, ctx: BaseAudioContext, seconds = 0.05) {
const t = ctx.currentTime;
p.cancelScheduledValues(t);
p.setValueAtTime(p.value, t);
p.linearRampToValueAtTime(to, t + seconds);
}
/**
* A one-shot envelope on a fresh gain node: attack to peak, decay to silence, then disconnect.
* Returns the gain so the caller can hang a source off it.
*/
export function envGain(
ctx: BaseAudioContext,
t0: number,
peak: number,
attack: number,
decay: number,
): GainNode {
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
return g;
}
/** Deterministic 0..1 hash — used anywhere a sound needs stable variation without Math.random. */
export function hash01(n: number): number {
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}

251
fktry/src/audio/index.ts Normal file
View File

@ -0,0 +1,251 @@
/**
* THE SPEAKER LANE-SCREEN's second organ.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever.
*
* Three layers, gated by discovery:
* 1. DIEGETIC MINIMUM (always on) placement, fax, brownout, scram, ratification.
* Nothing else exists before the optical fossil is found. The silence is the tease.
* 2. THE BAND (on relicFound) the tuner and its four stations.
* 3. WORLD AMBIENCE (post-discovery) the factory's own hum, ducked under the radio.
*
* The AudioContext is created lazily on the first user gesture (browsers require it) and the
* whole graph is built once at that moment. After that the per-frame path only writes
* AudioParams no allocation except genuinely one-shot notes.
*/
import type { AudioFX, GameData, SimEvent, SimSnapshot } from '../contracts';
import { ERA_ORDER, highestEra, type Era } from '../screen/eras';
import { createMasterChain } from './graph';
import { createAmbience, type Ambience } from './ambience';
import { createRadio, type Radio, type RadioEngine } from './radio';
import type { StationDef } from './stations';
import {
brownoutHit, brownoutRecover, complianceTone, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
} from './sfx';
import { arrivedWardens } from './compliance';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';
const STATIONS = (stationData as { stations: StationDef[] }).stations;
/**
* The real seams, so the NUMBERS station's seeded filler can never accidentally broadcast a
* true coordinate (LANE-DATA's rule: only the planted groups are real).
*/
const SEAM_RECTS = (seamData as { seams: { rect: { x: number; y: number; w: number; h: number } }[] })
.seams.map((s) => s.rect);
function isRealSeam(x: number, y: number): boolean {
for (const r of SEAM_RECTS) {
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return true;
}
return false;
}
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */
const MIN_GAP = { thunk: 0.03, chunk: 0.05, compliance: 1.2 };
/** The live radio handle for getRadio(). Module-level set by build(), which runs on the
* first user gesture so the control surface exists in PRODUCTION, not just behind the
* DEV debug object (orchestrator round-5 integration fix: the dock was silent in prod). */
let liveRadio: Radio | null = null;
export function createAudioFX(): AudioFX {
let ctx: BaseAudioContext | null = null;
let master: ReturnType<typeof createMasterChain> | null = null;
let radio: RadioEngine | null = null;
let ambience: Ambience | null = null;
const eras = new Map<string, Era>();
let era: Era = ERA_ORDER[0];
let discovered = false;
let lastThunk = 0;
let lastChunk = 0;
let lastCompliance = -Infinity;
let variant = 0;
/** Wardens currently on station, so an arrival strikes once and standing there strikes never. */
let wardensOnStation: ReadonlySet<number> = new Set<number>();
function now(): number {
return ctx ? ctx.currentTime : 0;
}
function openTheBand() {
if (discovered) return;
discovered = true;
if (!ctx || !radio || !ambience || !master) return;
radio.unlock(now());
ambience.start(now());
// The fossil is a severed audio bus reconnecting: the world audibly comes online.
ratifySting(ctx, master.input, now() + 0.02);
}
/**
* Strike THE COMPLIANCE TONE. Gated on `discovered` exactly as tapeChunk is the pre-relic
* silence guarantee is structural, not a volume setting, and The Correction does not get to
* be the exception. The min-gap is insurance only: this sound is meant to be rare by
* CONSTRUCTION (one note per notice, one per warden arrival), and the gap exists so that a
* sim burst in a single tick can never turn the rarity into a chord.
*/
function strikeCompliance() {
if (!discovered || !ctx || !master) return;
const t = now() + 0.005;
if (t - lastCompliance < MIN_GAP.compliance) return;
complianceTone(ctx, master.input, t);
lastCompliance = t;
}
function handle(e: SimEvent) {
if (!ctx || !master) return;
const dest = master.input;
const t = now() + 0.005; // a hair in the future: scheduling in the past clicks
switch (e.kind) {
case 'placed':
if (t - lastThunk >= MIN_GAP.thunk) {
thunk(ctx, dest, t, variant++);
lastThunk = t;
}
break;
case 'brownout':
if (e.on) brownoutHit(ctx, dest, t);
else brownoutRecover(ctx, dest, t);
break;
case 'scram':
if (e.on) klaxon(ctx, dest, t);
break;
case 'researched':
ratifySting(ctx, dest, t);
break;
case 'commissionDone':
faxChatter(ctx, dest, t, 18);
break;
case 'shipped':
// Layer 3: the tape chunk belongs to the discovered world.
if (discovered && t - lastChunk >= MIN_GAP.chunk) {
tapeChunk(ctx, dest, t, variant++);
lastChunk = t;
}
break;
case 'notice':
// The fax arrives before the action, always (contracts v9). One note, then paperwork.
strikeCompliance();
break;
case 'relicFound':
openTheBand();
break;
default:
break;
}
}
function build() {
if (ctx) return;
const AC: typeof AudioContext =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (!AC) {
console.warn('[audio] no Web Audio support — THE SPEAKER stays silent');
return;
}
ctx = new AC();
master = createMasterChain(ctx);
radio = createRadio(ctx, STATIONS, isRealSeam);
liveRadio = radio as Radio;
ambience = createAmbience(ctx);
radio.out.connect(master.input);
ambience.out.connect(master.input);
// The fax announces itself the moment the world has ears — Layer 1 proof of life.
faxChatter(ctx, master.input, now() + 0.15, 10);
// If the relic was already found (loaded save, or dug before the first click), the band
// should be open the instant audio exists.
if (discovered) {
discovered = false;
openTheBand();
}
if (import.meta.env.DEV) {
(window as any).__fktryAudio = {
ctx,
radio: radio as Radio,
openTheBand,
isDiscovered: () => discovered,
stations: STATIONS,
play: (name: string) => {
if (!ctx || !master) return;
const d = master.input;
const t = now() + 0.02;
({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
klaxon, ratify: ratifySting, chunk: tapeChunk,
compliance: complianceTone } as any)[name]?.(ctx, d, t);
},
};
}
}
return {
init(data: GameData) {
for (const t of data.tech) eras.set(t.id, t.era);
},
unlock() {
build();
if (ctx && 'resume' in ctx) void (ctx as AudioContext).resume();
},
onEvents(events: SimEvent[]) {
if (!ctx) {
// Audio doesn't exist yet (no gesture). Anything that happened is simply not heard —
// we never queue a burst to fire on unlock. But discovery must not be lost.
for (const e of events) if (e.kind === 'relicFound') discovered = true;
return;
}
for (const e of events) handle(e);
},
frame(timeMs: number, snap?: SimSnapshot) {
if (!ctx || !radio || !ambience) return;
const t = now();
if (snap?.research) era = highestEra(snap.research.unlocked, (x) => eras.get(x));
// Discovery can also arrive via the snapshot (e.g. a loaded save), not just the event.
if (!discovered && snap?.relics?.some((r) => r.found)) openTheBand();
radio.update(t, era);
// Warden arrival is not an edge event — it is a phase transition inside the snapshot — so
// the tracker runs every frame. It runs REGARDLESS of `discovered` on purpose: if the
// player digs up the fossil while a warden is already stood on station, that warden is
// not a new arrival and must not announce itself. Only the striking is gated.
if (snap) {
const { struck, next } = arrivedWardens(wardensOnStation, snap.correction?.units);
wardensOnStation = next;
if (struck.length) strikeCompliance(); // ONE note, however many arrived
}
if (discovered && snap) {
const { gen, draw } = snap.bandwidth;
const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));
let hot = 0;
let n = 0;
for (const e of snap.entities) if (e.heat > 0.01) { hot += e.heat; n++; }
const fever = n ? hot / n : 0;
ambience.update(load, fever, radio.getState().lock);
}
},
};
}
/**
* THE SPEAKER's control surface, for LANE-UI's tuner dock.
*
* SANCTIONED CROSS-LANE IMPORT: LANE-UI may `import { getRadio } from '../audio'` and drive
* the dial. Returns null before the first user gesture builds the graph. The object is
* deliberately narrow the UI can tune, set volume and subscribe, and can do nothing else to
* the audio graph.
*/
export function getRadio(): Radio | null {
return liveRadio;
}

248
fktry/src/audio/radio.ts Normal file
View File

@ -0,0 +1,248 @@
/**
* The tuner (round 5 order 6).
*
* A real dial: you sweep through noise and stations swim up out of it. Lock is a gaussian on
* the distance between the dial and each carrier, so stations fade in and out rather than
* snapping hunting for a station is the point, not an obstacle.
*
* Inter-station noise is BAND-APPROPRIATE, because that's what makes each band feel like a
* different physical place:
* AM brown crackle. Heavy, atmospheric, full of distant weather.
* FM white hiss. Bright, flat, modern, boring in a clean way.
* SW warble and heterodyne whistles that beat against nearby carriers. Alive and hostile.
*/
import type { Era } from '../screen/eras';
import { glide, noiseSource } from './graph';
import { setSubtitle } from './subtitleBus';
import {
amPropaganda, deadAir, fmMusic, swNumbers, type LiveStation, type StationDef,
} from './stations';
export type Band = 'AM' | 'FM' | 'SW';
/** Dial travel per band, in that band's own units (MHz for FM, kHz for AM/SW). */
export const BAND_RANGE: Record<Band, [number, number]> = {
AM: [520, 1710],
FM: [87.5, 108.0],
SW: [5900, 7400],
};
/**
* How wide a station's capture is, in band units. Tuned so a station occupies a comfortable
* fraction of the dial: wide enough to find by hand, narrow enough that finding it feels like
* something. SW is deliberately the fiddliest.
*/
const LOCK_WIDTH: Record<Band, number> = { AM: 9, FM: 0.32, SW: 6 };
export interface RadioState {
band: Band;
frequency: number;
volume: number;
muted: boolean;
/** id of the station currently locked, or null when you're between stations */
locked: string | null;
/** 0..1 strength of that lock */
lock: number;
unlocked: boolean;
}
/** The narrow control surface LANE-UI's tuner dock drives. */
export interface Radio {
setBand(b: Band): void;
setFrequency(f: number): void;
setVolume(v: number): void;
setMuted(m: boolean): void;
getState(): RadioState;
subscribe(fn: (s: RadioState) => void): () => void;
/** every station on the dial, for the UI to draw tick marks */
listStations(): { id: string; name: string; band: Band; freq: number }[];
}
export interface RadioEngine extends Radio {
/** open THE BAND — called once the optical fossil is found */
unlock(t0: number): void;
isUnlocked(): boolean;
update(now: number, era: Era): void;
/** master output for the whole radio, so ambience can duck under it */
out: GainNode;
}
const gaussian = (d: number, w: number) => Math.exp(-(d * d) / (2 * w * w));
export function createRadio(
ctx: BaseAudioContext,
defs: StationDef[],
isRealSeam: (x: number, y: number) => boolean,
): RadioEngine {
const out = ctx.createGain();
out.gain.value = 0; // silent until the fossil is dug up
const stations: LiveStation[] = defs.map((d) => {
if (d.music) return fmMusic(ctx, d);
if (d.chyron) return amPropaganda(ctx, d);
if (d.numbers) return swNumbers(ctx, d, isRealSeam);
return deadAir(ctx, d);
});
for (const s of stations) s.out.connect(out);
// ---- the three inter-station noise beds. All three exist; only the current band is audible.
const hiss = noiseSource(ctx, 'white'); // FM
const hissF = ctx.createBiquadFilter();
hissF.type = 'highpass';
hissF.frequency.value = 1200;
const hissG = ctx.createGain();
hissG.gain.value = 0;
hiss.connect(hissF).connect(hissG).connect(out);
const crackle = noiseSource(ctx, 'brown'); // AM
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'lowpass';
crackleF.frequency.value = 3200;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const warble = noiseSource(ctx, 'white'); // SW bed
const warbleF = ctx.createBiquadFilter();
warbleF.type = 'bandpass';
warbleF.frequency.value = 1500;
warbleF.Q.value = 1.2;
const warbleG = ctx.createGain();
warbleG.gain.value = 0;
// the bandpass centre wanders — that slow swimming quality of a shortwave band at night
const warbleLfo = ctx.createOscillator();
warbleLfo.frequency.value = 0.13;
const warbleLfoDepth = ctx.createGain();
warbleLfoDepth.gain.value = 600;
warbleLfo.connect(warbleLfoDepth).connect(warbleF.frequency);
warble.connect(warbleF).connect(warbleG).connect(out);
// Heterodyne: the whistle you get when your dial is NEAR a carrier but not on it. Pitch is
// the beat frequency, so it swoops down to nothing as you zero in — the sound of hunting.
const het = ctx.createOscillator();
het.type = 'sine';
het.frequency.value = 1000;
const hetG = ctx.createGain();
hetG.gain.value = 0;
het.connect(hetG).connect(out);
let band: Band = 'FM';
let frequency = 88.1;
let volume = 0.7;
let muted = false;
let unlocked = false;
let started = false;
let locked: string | null = null;
let lockStrength = 0;
const subs = new Set<(s: RadioState) => void>();
function state(): RadioState {
return { band, frequency, volume, muted, locked, lock: +lockStrength.toFixed(3), unlocked };
}
function publish() {
const s = state();
for (const fn of subs) fn(s);
}
function applyMaster() {
glide(out.gain, unlocked && !muted ? volume : 0, ctx, 0.08);
}
return {
out,
unlock(t0: number) {
if (unlocked) return;
unlocked = true;
if (!started) {
started = true;
for (const s of stations) s.start(t0);
hiss.start(t0);
crackle.start(t0);
warble.start(t0);
warbleLfo.start(t0);
het.start(t0);
}
applyMaster();
publish();
},
isUnlocked: () => unlocked,
setBand(b) {
if (b === band) return;
band = b;
// land somewhere sensible in the new band rather than an out-of-range number
const [lo, hi] = BAND_RANGE[b];
frequency = Math.min(hi, Math.max(lo, frequency));
const near = stations.find((s) => s.def.band === b);
if (near && (frequency < lo || frequency > hi)) frequency = near.def.freq;
publish();
},
setFrequency(f) {
const [lo, hi] = BAND_RANGE[band];
frequency = Math.min(hi, Math.max(lo, f));
publish();
},
setVolume(v) {
volume = Math.min(1, Math.max(0, v));
applyMaster();
publish();
},
setMuted(m) {
muted = m;
applyMaster();
publish();
},
getState: state,
subscribe(fn) {
subs.add(fn);
fn(state());
return () => subs.delete(fn);
},
listStations: () =>
stations.map((s) => ({ id: s.def.id, name: s.def.name, band: s.def.band, freq: s.def.freq })),
update(now, era) {
if (!unlocked) return;
const w = LOCK_WIDTH[band];
let best: LiveStation | null = null;
let bestLock = 0;
let nearestDist = Infinity;
for (const s of stations) {
const onBand = s.def.band === band;
const d = Math.abs(s.def.freq - frequency);
const lock = onBand ? gaussian(d, w) : 0;
if (onBand && d < nearestDist) nearestDist = d;
// Below the audible floor a station is genuinely off — no CPU spent scheduling it.
glide(s.out.gain, lock < 0.02 ? 0 : lock, ctx, 0.06);
s.update(now, lock, era);
if (lock > bestLock) { bestLock = lock; best = s; }
}
const changed = (best?.def.id ?? null) !== locked;
locked = best && bestLock > 0.02 ? best.def.id : null;
lockStrength = bestLock;
// Static fills whatever the stations leave behind.
const staticAmt = 1 - Math.min(1, bestLock);
glide(hissG.gain, band === 'FM' ? 0.05 * staticAmt : 0, ctx, 0.1);
glide(crackleG.gain, band === 'AM' ? 0.11 * staticAmt : 0, ctx, 0.1);
glide(warbleG.gain, band === 'SW' ? 0.08 * staticAmt : 0, ctx, 0.1);
// Heterodyne only on SW, only while close-but-not-locked, pitch = how far off you are.
if (band === 'SW' && nearestDist < w * 3 && bestLock < 0.85) {
const beat = Math.min(2400, 40 + (nearestDist / (w * 3)) * 2400);
glide(het.frequency, beat, ctx, 0.08);
glide(hetG.gain, 0.05 * (1 - bestLock), ctx, 0.1);
} else {
glide(hetG.gain, 0, ctx, 0.15);
}
// The sky captions whatever is currently talking.
setSubtitle(best && bestLock > 0.35 ? best.subtitle() : '');
if (changed) publish();
},
};
}

247
fktry/src/audio/sfx.ts Normal file
View File

@ -0,0 +1,247 @@
/**
* Layer 1 the diegetic minimum. Always on, from the first click, before any discovery.
*
* These five sounds are the whole audio world until the player digs up the optical fossil.
* That is the point: the silence around them is the tease. Everything here is one-shot and
* self-stopping no continuous voices, so a pre-discovery factory is genuinely quiet between
* events rather than humming under a noise floor.
*
* Every sound takes (ctx, dest, t0) so it can be rendered offline for spectral verification.
*/
import { envGain, hash01, noiseSource } from './graph';
/**
* PLACEMENT THUNK a heavy thing set down on a metal deck.
* Low sine drops 15055Hz (the mass) under a filtered noise slap (the contact).
*/
export function thunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const detune = 1 + (hash01(variant) - 0.5) * 0.18; // no two placements identical
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(150 * detune, t0);
osc.frequency.exponentialRampToValueAtTime(55 * detune, t0 + 0.11);
const og = envGain(ctx, t0, 0.5, 0.004, 0.16);
osc.connect(og).connect(dest);
osc.start(t0);
osc.stop(t0 + 0.22);
const slap = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1400 * detune;
bp.Q.value = 0.8;
const sg = envGain(ctx, t0, 0.22, 0.002, 0.05);
slap.connect(bp).connect(sg).connect(dest);
slap.start(t0);
slap.stop(t0 + 0.09);
}
/**
* FAX CHATTER dot-matrix print head. A burst of short square blips at ~55Hz repetition
* with a paper-feed hiss under it. The commission fax is the game's voice; it should sound
* like an office machine that has never once been serviced.
*/
export function faxChatter(ctx: BaseAudioContext, dest: AudioNode, t0: number, chars = 14) {
const bus = ctx.createGain();
bus.gain.value = 0.5;
bus.connect(dest);
for (let i = 0; i < chars; i++) {
const t = t0 + i * 0.018 + hash01(i * 7.7) * 0.004;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = 320 + hash01(i * 3.3) * 260;
const g = envGain(ctx, t, 0.16, 0.0015, 0.012);
o.connect(g).connect(bus);
o.start(t);
o.stop(t + 0.02);
}
// paper feed
const paper = noiseSource(ctx, 'white');
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2600;
const pg = envGain(ctx, t0, 0.09, 0.02, chars * 0.018 + 0.1);
paper.connect(hp).connect(pg).connect(bus);
paper.start(t0);
paper.stop(t0 + chars * 0.018 + 0.2);
}
/**
* BROWNOUT the codex's catastrophic underrun, finally audible.
* A hard pop transient, a burst of crackle, and a power-down whine sliding 42040Hz as the
* rails collapse. This is the single most important sound in the game: it is the punishment.
*/
export function brownoutHit(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
// the pop: a single-sample-ish click, deliberately broadband
const pop = ctx.createOscillator();
pop.type = 'square';
pop.frequency.setValueAtTime(90, t0);
const pg = envGain(ctx, t0, 0.75, 0.0008, 0.03);
pop.connect(pg).connect(dest);
pop.start(t0);
pop.stop(t0 + 0.04);
// crackle: brown noise gated hard
const cr = noiseSource(ctx, 'brown');
const crf = ctx.createBiquadFilter();
crf.type = 'bandpass';
crf.frequency.value = 800;
crf.Q.value = 0.6;
const cg = envGain(ctx, t0, 0.5, 0.003, 0.5);
cr.connect(crf).connect(cg).connect(dest);
cr.start(t0);
cr.stop(t0 + 0.7);
// power-down whine: the flywheel losing it
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(420, t0);
wh.frequency.exponentialRampToValueAtTime(40, t0 + 0.9);
const wf = ctx.createBiquadFilter();
wf.type = 'lowpass';
wf.frequency.setValueAtTime(2200, t0);
wf.frequency.exponentialRampToValueAtTime(300, t0 + 0.9);
const wg = envGain(ctx, t0, 0.3, 0.02, 0.95);
wh.connect(wf).connect(wg).connect(dest);
wh.start(t0);
wh.stop(t0 + 1.0);
}
/** Power coming back: the whine in reverse, shorter and less dramatic. Relief, not triumph. */
export function brownoutRecover(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(60, t0);
wh.frequency.exponentialRampToValueAtTime(360, t0 + 0.35);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1400;
const g = envGain(ctx, t0, 0.2, 0.05, 0.32);
wh.connect(lp).connect(g).connect(dest);
wh.start(t0);
wh.stop(t0 + 0.45);
}
/**
* SCRAM KLAXON two-tone industrial alarm, 620/440Hz, square, deliberately unpleasant.
* An OSHA-compliant noise for an entirely non-compliant machine.
*/
export function klaxon(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.34;
bus.connect(dest);
const tones = [620, 440, 620, 440];
for (let i = 0; i < tones.length; i++) {
const t = t0 + i * 0.22;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = tones[i];
const g = envGain(ctx, t, 0.5, 0.01, 0.19);
// a lowpass keeps it a klaxon rather than a buzzer
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1800;
o.connect(lp).connect(g).connect(bus);
o.start(t);
o.stop(t + 0.21);
}
}
/**
* The ratification triad: F4A4C5. Exported because THE COMPLIANCE TONE is literally one
* voice of it, and the two must never drift apart into "two nice sounds".
*/
export const RATIFY_TRIAD = [349.23, 440.0, 523.25] as const;
/**
* The one voice The Correction speaks with: A4, the middle of its own triad and the pitch
* the entire industry agreed to calibrate against. Of course that is the note it picked.
*/
export const COMPLIANCE_HZ = RATIFY_TRIAD[1];
/**
* RATIFICATION STING The Correction resolves; you don't.
* A clean major triad (FAC) on sines with a soft bell attack. It is the ONLY consonant,
* in-tune, untroubled sound in the entire game. Everything the player makes is filth; this
* is what the enemy sounds like, and it sounds *nice*, which is the joke.
*/
export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.3;
bus.connect(dest);
const triad = RATIFY_TRIAD; // F4 A4 C5
triad.forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const g = envGain(ctx, t0 + i * 0.035, 0.4, 0.02, 1.1);
o.connect(g).connect(bus);
o.start(t0 + i * 0.035);
o.stop(t0 + 1.4);
});
// a touch of shimmer an octave up so it reads as "official" rather than "warm"
const shimmer = ctx.createOscillator();
shimmer.type = 'sine';
shimmer.frequency.value = 1046.5;
const sg = envGain(ctx, t0, 0.12, 0.03, 0.9);
shimmer.connect(sg).connect(bus);
shimmer.start(t0);
shimmer.stop(t0 + 1.1);
}
/**
* THE COMPLIANCE TONE The Correction never speaks. It faxes. This is the only noise it makes.
*
* ONE voice of the ratification triad, standing alone: A4, struck on a fixed grid. The frequency
* is SET, never ramped. No detune, no hash01, no vibrato, no LFO, no noise layer, no filter
* sweep, no second oscillator to beat against not one analog imperfection anywhere in the
* signal path, because every other sound in this game has at least one and they are all the
* player's. You cannot RECORD a tone with no imperfection in it; you can only generate one.
* That is the house law making its own argument, and it is why the enemy's sound is the only
* one here that could not have come off tape.
*
* SPARSE BY CONSTRUCTION: one note per event, nothing loops it, and it is never sustained.
* The ratification sting's rarity is its punchline; a second "clean sound" heard continuously
* would demote both. If this is ever heard often, something upstream is wrong, not this.
*
* Layer 3 by gating: like tapeChunk, it belongs to the discovered world, so the pre-relic
* silence guarantee (measured at exactly 0.0 peak / 0.0 RMS) is untouched by its existence.
*/
export function complianceTone(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = COMPLIANCE_HZ; // set once, for all time
// The envelope is the only thing in here that moves, and it moves slowly on purpose: a
// percussive attack would splatter sidebands either side of the partial, and the claim this
// sound is making is precisely that it has none. Struck like a tuning fork, left to decay.
const g = envGain(ctx, t0, 0.14, 0.05, 1.5);
o.connect(g).connect(dest);
o.start(t0);
o.stop(t0 + 1.7);
}
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;
const n = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 520 * d;
bp.Q.value = 1.4;
const g = envGain(ctx, t0, 0.3, 0.002, 0.07);
n.connect(bp).connect(g).connect(dest);
n.start(t0);
n.stop(t0 + 0.1);
const thud = ctx.createOscillator();
thud.type = 'sine';
thud.frequency.setValueAtTime(110 * d, t0);
thud.frequency.exponentialRampToValueAtTime(70 * d, t0 + 0.06);
const tg = envGain(ctx, t0, 0.22, 0.003, 0.09);
thud.connect(tg).connect(dest);
thud.start(t0);
thud.stop(t0 + 0.12);
}

488
fktry/src/audio/stations.ts Normal file
View File

@ -0,0 +1,488 @@
/**
* THE BAND the four things living on the dial (round 5 order 6).
*
* Schema is LANE-DATA's (`data/stations.json`); this file is pure synthesis. Every station is
* a continuous voice built once at unlock, whose OUTPUT GAIN is driven by the tuner's lock
* strength. Stations self-schedule with a lookahead so nothing allocates per frame except
* genuinely one-shot notes.
*
* Each station also decides what THE SCREEN says: when a talking station is locked it writes
* the subtitle bus, and the sky captions it.
*/
import type { Era } from '../screen/eras';
import { envGain, hash01, noiseSource } from './graph';
export interface StationDef {
id: string;
band: 'AM' | 'FM' | 'SW';
freq: number;
name: string;
format?: string;
flavor?: string;
music?: {
bpm: number; scale: string; steps: number; mutateEveryBars: number;
seed: number; voices: string[]; eraFlavor?: Record<string, string>;
};
chyron?: string[];
numbers?: {
preamble: string; preambleRepeats: number; groupSize: number; groupsPerMessage: number;
secondsPerGroup: number; plantedGroups: string[]; plantEveryMessages: number;
decoyPool: string[];
};
ghost?: { everyMinutes: number; reversed: boolean; line: string };
}
export interface LiveStation {
def: StationDef;
/** station audio lands here; the tuner drives this node's gain from the lock strength */
out: GainNode;
start(t0: number): void;
/** called every frame. `lock` 0..1 — only a locked station schedules and subtitles. */
update(now: number, lock: number, era: Era): void;
/** the line the sky should caption right now, or '' */
subtitle(): string;
}
const LOOKAHEAD = 0.25; // seconds of scheduling runway
/** Mulberry32 — small, fast, and seedable, so a run's radio is always the same radio. */
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ─────────────────────────────────────────────────────────────── 88.1 FKTRY FM
const PENTA_MINOR = [0, 3, 5, 7, 10]; // the scale DATA asked for
/**
* The factory's own transmitter: a sequencer nobody is operating.
* Square lead + triangle bass on a seeded 16-step pattern that mutates every 32 bars, so it
* genuinely never repeats a bar it liked. The era colours the transmission, not the notes
* same music, aging equipment.
*/
export function fmMusic(ctx: BaseAudioContext, def: StationDef): LiveStation {
const m = def.music!;
const out = ctx.createGain();
out.gain.value = 0;
const voiceBus = ctx.createGain();
voiceBus.gain.value = 0.5;
// Wow/flutter lives on a delay line: reel-era tape doesn't hold pitch.
const warble = ctx.createDelay(0.05);
warble.delayTime.value = 0.004;
const wowLfo = ctx.createOscillator();
wowLfo.frequency.value = 0.7;
const wowDepth = ctx.createGain();
wowDepth.gain.value = 0; // era sets this
wowLfo.connect(wowDepth).connect(warble.delayTime);
voiceBus.connect(warble).connect(out);
// Era beds: surface crackle (reel) and the 15.7kHz line whine (broadcast).
const crackle = noiseSource(ctx, 'brown');
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'highpass';
crackleF.frequency.value = 1800;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const lineWhine = ctx.createOscillator();
lineWhine.type = 'sine';
lineWhine.frequency.value = 15734; // NTSC horizontal line rate, the sound of a warm CRT
const whineG = ctx.createGain();
whineG.gain.value = 0;
lineWhine.connect(whineG).connect(out);
const spb = 60 / m.bpm / 4; // 16th notes
let step = 0;
let bars = 0;
let nextTime = 0;
let started = false;
let pattern: number[] = [];
let mutations = 0;
const rand = rng(m.seed);
function mutate() {
pattern = Array.from({ length: m.steps }, () => {
const r = rand();
if (r < 0.34) return -1; // rest — space is what stops it sounding like a demo
return PENTA_MINOR[Math.floor(rand() * PENTA_MINOR.length)] + (rand() < 0.25 ? 12 : 0);
});
mutations++;
}
mutate();
let eraNow: Era = 'stream';
function note(semi: number, t: number, isDownbeat: boolean) {
const root = 110; // A2
const f = root * Math.pow(2, semi / 12);
// fortress: wavelet-smooth, no transients. Everything else keeps its edge.
const attack = eraNow === 'fortress' ? 0.09 : 0.006;
const decay = eraNow === 'fortress' ? 0.5 : 0.16;
const lead = ctx.createOscillator();
lead.type = 'square';
lead.frequency.value = f * 2;
const lg = envGain(ctx, t, eraNow === 'stream' ? 0.3 : 0.22, attack, decay);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = eraNow === 'disc' ? 6000 : eraNow === 'reel' ? 2200 : 4000;
lead.connect(lp).connect(lg).connect(voiceBus);
lead.start(t);
lead.stop(t + attack + decay + 0.02);
if (isDownbeat) {
const bass = ctx.createOscillator();
bass.type = 'triangle';
bass.frequency.value = f / 2;
const bg = envGain(ctx, t, 0.34, attack, decay * 1.8);
bass.connect(bg).connect(voiceBus);
bass.start(t);
bass.stop(t + attack + decay * 1.8 + 0.02);
}
}
return {
def,
out,
start(t0) {
wowLfo.start(t0);
crackle.start(t0);
lineWhine.start(t0);
nextTime = t0;
started = true;
},
update(now, lock, era) {
if (era !== eraNow) {
eraNow = era;
// The transmission ages even though the tune doesn't.
wowDepth.gain.value = era === 'reel' ? 0.0022 : 0;
crackleG.gain.value = era === 'reel' ? 0.055 : 0;
whineG.gain.value = era === 'broadcast' ? 0.012 : 0;
}
if (!started || lock <= 0.02) {
// Keep the clock rolling while off-station so the music is a continuous broadcast
// you tune INTO, not a track that starts when you arrive.
while (nextTime < now) { nextTime += spb; step = (step + 1) % m.steps; }
return;
}
while (nextTime < now + LOOKAHEAD) {
const semi = pattern[step];
if (semi >= 0) note(semi, nextTime, step % 4 === 0);
nextTime += spb;
step++;
if (step >= m.steps) {
step = 0;
bars++;
if (bars % m.mutateEveryBars === 0) mutate();
}
}
},
subtitle: () => '',
};
}
// ────────────────────────────────────────────────────────── AM 640 THE STANDARD
/**
* The Correction, with a transmitter.
*
* A ring-modulated mumble: a low carrier multiplied by a formant-swept tone, gated into
* syllables. It is deliberately just short of intelligible you can hear that it is speech
* and not what it says. THE SCREEN tells you what it says. That gap is the whole trick.
*/
export function amPropaganda(ctx: BaseAudioContext, def: StationDef): LiveStation {
const lines = def.chyron ?? [];
const out = ctx.createGain();
out.gain.value = 0;
// carrier * modulator = ring mod. The modulator is what makes it sound institutional.
const carrier = ctx.createOscillator();
carrier.type = 'sawtooth';
carrier.frequency.value = 118;
const ring = ctx.createGain();
ring.gain.value = 0; // driven by the modulator; 0 = silence between syllables
const modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value = 61;
const modDepth = ctx.createGain();
modDepth.gain.value = 0.5;
modulator.connect(modDepth).connect(ring.gain);
// two formants: a mouth shape that never quite resolves into a vowel
const f1 = ctx.createBiquadFilter();
f1.type = 'bandpass';
f1.frequency.value = 520;
f1.Q.value = 4;
const f2 = ctx.createBiquadFilter();
f2.type = 'bandpass';
f2.frequency.value = 1180;
f2.Q.value = 6;
const syllable = ctx.createGain();
syllable.gain.value = 0;
carrier.connect(ring);
ring.connect(f1).connect(syllable);
ring.connect(f2).connect(syllable);
// AM band means AM bandwidth: nothing above ~4.5k survives the transmitter.
const band = ctx.createBiquadFilter();
band.type = 'lowpass';
band.frequency.value = 4500;
syllable.connect(band).connect(out);
let lineIdx = 0;
let nextSyllable = 0;
let syllablesLeft = 0;
let lineEndsAt = 0;
let started = false;
const rand = rng(6400);
function beginLine(t: number) {
lineIdx = (lineIdx + 1) % Math.max(1, lines.length);
const words = (lines[lineIdx] ?? '').split(/\s+/).length;
syllablesLeft = Math.max(6, Math.round(words * 1.6));
lineEndsAt = t + syllablesLeft * 0.17 + 1.4; // + the pause it leaves for effect
nextSyllable = t + 0.25;
}
return {
def,
out,
start(t0) {
carrier.start(t0);
modulator.start(t0);
started = true;
beginLine(t0);
},
update(now, lock) {
if (!started) return;
if (now > lineEndsAt) beginLine(now);
if (lock <= 0.02) return;
while (nextSyllable < now + LOOKAHEAD && syllablesLeft > 0) {
const t = nextSyllable;
const dur = 0.075 + rand() * 0.075;
// syllable envelope
syllable.gain.setValueAtTime(0.0001, t);
syllable.gain.exponentialRampToValueAtTime(0.5, t + 0.02);
syllable.gain.exponentialRampToValueAtTime(0.0001, t + dur);
// formant motion per syllable — the mouth moves, the meaning doesn't arrive
f1.frequency.setValueAtTime(380 + rand() * 320, t);
f2.frequency.setValueAtTime(950 + rand() * 700, t);
carrier.frequency.setValueAtTime(104 + rand() * 28, t);
nextSyllable = t + dur + 0.06 + rand() * 0.05;
syllablesLeft--;
}
},
// The flagship: the sky captions the radio.
subtitle: () => lines[lineIdx] ?? '',
};
}
// ─────────────────────────────────────────────────────────── SW 6955 NUMBERS
/**
* The unlisted station. Cold formant digits, no affect, no acknowledgement of a listener.
* Some groups are coordinates (LANE-DATA plants the real ones); the rest are decoys and
* seeded filler. The chyron shows the digits, so a player with a pencil can map the ground.
*/
export function swNumbers(
ctx: BaseAudioContext,
def: StationDef,
isRealSeam: (x: number, y: number) => boolean,
): LiveStation {
const n = def.numbers!;
const out = ctx.createGain();
out.gain.value = 0;
// A formant voice: buzz source + three resonances. Vowel-ish, sexless, entirely uninterested.
const buzz = ctx.createOscillator();
buzz.type = 'sawtooth';
buzz.frequency.value = 196;
const voiceGain = ctx.createGain();
voiceGain.gain.value = 0;
const fs = [720, 1240, 2600].map((f) => {
const b = ctx.createBiquadFilter();
b.type = 'bandpass';
b.frequency.value = f;
b.Q.value = 8;
return b;
});
const sum = ctx.createGain();
buzz.connect(voiceGain);
for (const f of fs) voiceGain.connect(f).connect(sum);
// SW bandwidth is narrow and awful
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.7;
sum.connect(band).connect(out);
const rand = rng(69550);
/** Vowel formants per digit so each numeral has a consistent, learnable shape. */
const DIGIT_FORMANTS: [number, number, number][] = [
[520, 1000, 2400], [300, 2200, 2900], [600, 1700, 2500], [500, 1750, 2450],
[640, 1200, 2300], [430, 2100, 2800], [560, 900, 2350], [400, 1900, 2600],
[700, 1150, 2400], [350, 2300, 3000],
];
function decodes(group: string): { x: number; y: number } | null {
if (group.length !== 5) return null;
const x = Number(group.slice(0, 2)) - 32;
const y = Number(group.slice(2, 4)) - 32;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
/** Seeded filler that never accidentally gives away real ground. */
function fillerGroup(): string {
for (let tries = 0; tries < 24; tries++) {
const g = Array.from({ length: n.groupSize }, () => Math.floor(rand() * 10)).join('');
const d = decodes(g);
if (d && isRealSeam(d.x, d.y)) continue; // never leak a coordinate by accident
return g;
}
return '00000';
}
let messages = 0;
let queue: string[] = [];
let nextAt = 0;
let started = false;
let showing = '';
function buildMessage() {
const groups: string[] = [];
for (let i = 0; i < n.preambleRepeats; i++) groups.push(n.preamble);
const planted = messages % n.plantEveryMessages === 0;
for (let i = 0; i < n.groupsPerMessage; i++) {
if (planted && i === Math.floor(n.groupsPerMessage / 2)) {
groups.push(n.plantedGroups[(messages / n.plantEveryMessages) % n.plantedGroups.length]);
} else if (rand() < 0.35) {
groups.push(n.decoyPool[Math.floor(rand() * n.decoyPool.length)]);
} else {
groups.push(fillerGroup());
}
}
messages++;
queue = groups;
}
function speakGroup(group: string, t0: number) {
const per = n.secondsPerGroup / (n.groupSize + 1);
for (let i = 0; i < group.length; i++) {
const d = Number(group[i]);
const t = t0 + i * per;
const [a, b, c] = DIGIT_FORMANTS[d] ?? DIGIT_FORMANTS[0];
fs[0].frequency.setValueAtTime(a, t);
fs[1].frequency.setValueAtTime(b, t);
fs[2].frequency.setValueAtTime(c, t);
buzz.frequency.setValueAtTime(190 + d * 2, t); // barely any melody: no affect
voiceGain.gain.setValueAtTime(0.0001, t);
voiceGain.gain.exponentialRampToValueAtTime(0.34, t + 0.03);
voiceGain.gain.setValueAtTime(0.34, t + per * 0.6);
voiceGain.gain.exponentialRampToValueAtTime(0.0001, t + per * 0.92);
}
}
return {
def,
out,
start(t0) {
buzz.start(t0);
started = true;
nextAt = t0 + 0.5;
buildMessage();
},
update(now, lock) {
if (!started) return;
while (nextAt < now + LOOKAHEAD) {
if (!queue.length) buildMessage();
const g = queue.shift()!;
showing = g;
if (lock > 0.02) speakGroup(g, nextAt);
nextAt += n.secondsPerGroup;
}
},
// Digits on the sky — the treasure map, for anyone bothering to write them down.
subtitle: () => (showing ? showing.split('').join(' ') : ''),
};
}
// ──────────────────────────────────────────────────────────── 108.0 DEAD AIR
/**
* The top of the dial. Room tone from a room that should not have any, breathing at the pace
* of a projector, and once in a very long while a chime played backwards.
*/
export function deadAir(ctx: BaseAudioContext, def: StationDef): LiveStation {
const out = ctx.createGain();
out.gain.value = 0;
const tone = noiseSource(ctx, 'brown');
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 420;
const toneG = ctx.createGain();
// Deliberately well under a station: this is a room you strain to hear, not a channel.
// (Measured at 0.32 it rendered LOUDER than FKTRY FM, which made "dead air" the loudest
// thing on the dial — exactly backwards.)
toneG.gain.value = 0.1;
tone.connect(lp).connect(toneG).connect(out);
// the breathing: a slow swell at roughly a projector's pace
const breath = ctx.createOscillator();
breath.type = 'sine';
breath.frequency.value = 0.21;
const breathDepth = ctx.createGain();
breathDepth.gain.value = 0.05;
breath.connect(breathDepth).connect(toneG.gain);
let started = false;
let nextGhost = 0;
const ghostEvery = (def.ghost?.everyMinutes ?? 10) * 60;
/** A reversed chime: swell up out of nothing, then stop dead. Wrong-way-round on purpose. */
function ghost(t0: number) {
const dur = 2.2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.28, t0 + dur); // the swell
g.gain.setValueAtTime(0.0001, t0 + dur + 0.01); // the cut
g.connect(out);
[523.25, 783.99, 1046.5].forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f * (1 + i * 0.001);
o.connect(g);
o.start(t0);
o.stop(t0 + dur + 0.05);
});
}
return {
def,
out,
start(t0) {
tone.start(t0);
breath.start(t0);
started = true;
// seeded, so the ghost always arrives at the same moment in a given run
nextGhost = t0 + ghostEvery * (0.35 + hash01(1080) * 0.4);
},
update(now, lock) {
if (!started || lock <= 0.02) return;
if (now >= nextGhost) {
ghost(now + 0.05);
nextGhost = now + ghostEvery;
}
},
subtitle: () => '',
};
}

View File

@ -0,0 +1,23 @@
/**
* The subtitle bus THE SPEAKER captions THE SCREEN.
*
* The flagship cross-organ trick (round 5 order 6): when the radio is tuned to a talking
* station, the sky's chyron subtitles the audio. Both organs are LANE-SCREEN, so this is a
* sanctioned in-lane channel: THE SPEAKER (src/audio) writes, THE SCREEN (src/screen) reads.
* A module-level singleton keeps it out of the contract main.ts never has to marshal it.
*
* Deliberately trivial and dependency-free so both sides can import it without coupling to
* each other's guts.
*/
let current = '';
/** Set the line the sky should be showing (''/empty clears it). Called by the tuned station. */
export function setSubtitle(line: string): void {
current = line ?? '';
}
/** Read the current caption. Called by THE SCREEN each frame when building its chyron. */
export function getSubtitle(): string {
return current;
}

View File

@ -38,7 +38,8 @@ export type MachineKind =
| 'splitter' // 1 in, N out round-robin
| 'power' // generates bandwidth
| 'buffer' // stores bandwidth smoothness (brownout protection)
| 'shipper'; // consumes items => 'shipped' events => THE SCREEN
| 'shipper' // consumes items => 'shipped' events => THE SCREEN
| 'lab'; // v4: consumes science packs toward the active research
export interface MachineDef {
id: string;
@ -54,9 +55,17 @@ export interface MachineDef {
color?: string; // v3 RULING: CHASSIS/BODY color (codex §8 grime); the emissive
// accent stays derived from outputs. (Was mislabeled "accent".)
flavor?: string; // v2: codex one-liner for the inspector (DATA's material)
heatPerTick?: number; // v2: heat while active (the software decoder's whole bit)
heatPerTick?: number; // v2: heat while active. v4 RULING: this is GROSS heat — net
// climb = heatPerTick coolPerTick; a machine whose
// heatPerTick ≤ its coolPerTick can never overheat.
bufferCap?: number; // v2: bandwidth storage in bandwidth-SECONDS (see SimSnapshot)
coolPerTick?: number; // v3: per-machine cooling rate; sim default 0.004
accent?: string; // v4: art-directed emissive accent; fallback stays derived
// from first output item (renderer + UI share the derivation)
coolRadius?: number; // v4: spatial cooling aura in tiles (chebyshev from footprint).
// v5 CLARIFIED: auras are ADDITIVE — a machine in range cools at
// its own coolPerTick + sum of in-range auras, capped at the
// exported HEAT_COOL_MAX (see src/sim/constants.ts)
}
export interface TechDef {
@ -72,6 +81,23 @@ export interface CommissionDef {
wants: Record<string, number>;
rewardBandwidth: number;
repeat?: boolean; // v2: standing order — re-activates after completion
compliance?: boolean; // v9: a Correction quota — completing it vents PARITY
// DEBT; wants must be tier-0/1 (validator-enforced)
}
// v7: lifted verbatim from SIM's round-5 mirror — data/seams.json's shape.
export interface SeamDef {
id: string;
rect: { x: number; y: number; w: number; h: number };
era?: string;
resource?: string;
richness?: number;
hidden?: boolean; // treasure — only the numbers station may reveal it
}
export interface SeamMap {
/** DATA keeps this corner seam-free so the seed-derived relic always lands somewhere. */
relicReserve?: { x: number; y: number; w: number; h: number };
seams: SeamDef[];
}
export interface GameData {
@ -80,6 +106,7 @@ export interface GameData {
recipes: RecipeDef[];
tech: TechDef[];
commissions: CommissionDef[];
seams?: SeamMap; // v7: wired by main.ts from data/seams.json; extractors need ore
}
// ---------------------------------------------------------------- live sim state
@ -97,6 +124,8 @@ export interface EntityState {
heat: number; // 0..1, throttles >0.7, scrams at 1
scrammed?: boolean; // v3: heat-scram latch (heat alone can't show the
// hysteresis window; renderer/UI read this, not 0.5)
sealed?: boolean; // v9: warden compliance seal — machine refuses to run
// until pried (the pry command). UI/renderer read this.
}
export interface BeltItem {
@ -113,20 +142,82 @@ export interface SimSnapshot {
beltItems: ReadonlyArray<BeltItem>;
// v3 RULING on units: gen/draw are bandwidth per SECOND; stored is bandwidth-seconds
// (sim integrates surplus/deficit divided by TICKS_PER_SECOND each tick).
bandwidth: { gen: number; draw: number; stored: number; brownout: boolean };
// v4: capacity = summed bufferCap of placed tanks (0 if none) — SCREEN's 2nd dread axis.
bandwidth: { gen: number; draw: number; stored: number; capacity?: number; brownout: boolean };
shippedTotal: Record<string, number>;
activeCommission: string | null;
commissionQueue?: string[]; // v3: truth for "next in tray" — active first, then
// upcoming in activation order (hardens in v4)
commissionQueue?: string[]; // v3: active first, then upcoming in activation
// order. May alias a live array — read-only, never
// retain. HARDENS IN v5: update fixtures this round.
commissionProgress: Record<string, number>;
research?: ResearchState; // v4: research progress (hardens in v6)
wildlife?: ReadonlyArray<WildlifeState>; // v5: piles & swarms (M2's living hazards)
relics?: ReadonlyArray<RelicState>; // v6: buried secrets (the hidden pipe)
correction?: CorrectionState; // v9: PARITY DEBT + rungs 2-4 (mite stays wildlife)
}
/** v9: THE CORRECTION, embodied. Debt is the escalation throttle; bands gate spawns. */
export interface CorrectionState {
debt: number; // 0..1
band: 0 | 1 | 2 | 3 | 4; // NOMINAL | OBSERVED | AUDITED | SEALED | REFUSED
units: ReadonlyArray<CorrectionUnit>;
}
export interface CorrectionUnit {
id: number;
kind: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor';
pos: Vec2;
/** v9.1 PINNED (SCREEN string-matches this; a free string was a silent-miss trap):
* the full lifecycle vocabulary. SIM: request additions via NOTES, never invent. */
phase: 'notice' | 'approach' | 'audit' | 'tether' | 'prescan' | 'stasis' | 'leaving';
// 'notice' = the mandated gap between the fax and the act — the unit exists,
// is visible on approach vectors, but has not begun. (Added v9.1 when the pin
// caught SIM's in-flight code already using it — the union earned its keep in
// its first ten minutes.)
progress: number; // 0..1 of current phase
target?: number; // warden: audited entity; gunship: the PARITY ANCHOR
rect?: { x: number; y: number; w: number; h: number }; // gunship sector / auditor stasis
remaining?: number; // v9.2 (UI request): ticks left in the current phase,
// so countdown chips are exact from frame one and
// survive save/load instead of deriving from progress
}
export interface RelicState {
id: number;
kind: 'optical-fossil'; // v6: the severed audio bus — one per world
pos: Vec2;
found: boolean; // excavated; persists through save/load
}
export interface WildlifeState {
id: number;
kind: 'dust-pile' | 'mosquito-swarm' | 'parity-mite'; // v8: The Correction walks
pos: Vec2;
size: number; // pile: accumulation 0..1; swarm: severity 0..1
target?: number; // swarm: entity id it is currently degrading
}
export interface ResearchState {
active: string | null; // TechDef.id being researched
progress: Record<string, number>; // packs delivered toward active (itemId -> n)
unlocked: string[]; // completed TechDef.ids
}
// v4 gating rule: a machine/recipe id that appears in ANY TechDef.unlocks is locked
// until that tech completes; ids referenced by no tech are always available.
export type Command =
| { kind: 'place'; def: string; pos: Vec2; dir: Dir }
| { kind: 'remove'; pos: Vec2 }
| { kind: 'rotate'; pos: Vec2 }
| { kind: 'setRecipe'; entity: number; recipe: string | null }
| { kind: 'setRecipeAt'; pos: Vec2; recipe: string | null } // v3: id-free variant
| { kind: 'setResearch'; tech: string | null } // v4: pick the lab target
| { kind: 'grantResearch'; tech: string[] } // v5: instant unlock — the
// sandbox/demo verb, also a
// legitimate creative mode
| { kind: 'excavate'; pos: Vec2 } // v6: dig at a relic site
| { kind: 'pry'; pos: Vec2 } // v9: pry a compliance seal
// off (takes time, adds debt
// - you defaced a notice)
| { kind: 'setPaused'; paused: boolean };
export type SimEvent =
@ -137,7 +228,21 @@ export type SimEvent =
| { kind: 'placed'; entity: number; def: string; tick: number }
| { kind: 'removed'; entity: number; def: string; tick: number }
| { kind: 'commissionDone'; commission: string; tick: number }
| { kind: 'scram'; entity: number; on: boolean; tick: number }; // v2: heat shutdown edge
| { kind: 'scram'; entity: number; on: boolean; tick: number } // v2: heat shutdown edge
| { kind: 'researched'; tech: string; tick: number } // v4: research complete
| { kind: 'wildlife'; on: 'spawned' | 'cleared'; wildlife: 'dust-pile' | 'mosquito-swarm' | 'parity-mite';
id: number; pos: Vec2; tick: number } // v5: hazard lifecycle
| { kind: 'relicFound'; relic: 'optical-fossil'; id: number; tick: number } // v6
| { kind: 'repaired'; item: string; pos: Vec2; by?: number; tick: number } // v8 (+v9.3 by: unit id)
// v9 — THE CORRECTION, rungs 2-4. 'notice' always precedes action (fax first, never
// silent); auditor stasis ALWAYS auto-releases (safety is contractual, not tunable).
| { kind: 'notice'; unit: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor';
pos?: Vec2; tick: number } // v9.3: where the unit will materialise (retires RENDER's DOM bridge)
| { kind: 'sealed'; entity: number; on: boolean; tick: number }
| { kind: 'mirrored'; item: string; pos: Vec2; by?: number; tick: number } // subtracts from the sky ledger
| { kind: 'stasis'; rect: { x: number; y: number; w: number; h: number }; on: boolean;
lockHash: string; tick: number }
| { kind: 'debtBand'; band: 0 | 1 | 2 | 3 | 4; tick: number };
// ---------------------------------------------------------------- module interfaces
@ -147,23 +252,43 @@ export interface Sim {
tick(): void; // advance exactly one tick
snapshot(): SimSnapshot; // read-only view; renderer/ui must not mutate
drainEvents(): SimEvent[]; // events since last drain (main loop fans out)
save?(): string; // v3: full state as JSON, deterministic round-trip
load?(json: string): void; // v3: restore a save() blob (hardens in v4)
save(): string; // v4: HARDENED — full state, deterministic round-trip
load(json: string): void; // v4: HARDENED — restore a save() blob
}
/** v4: the typed selection protocol — kills the '__remove' magic-string sentinel. */
export type SelectionState =
| { mode: 'build'; def: string; dir: Dir }
| { mode: 'remove' }
| { mode: 'inspect'; entity: number } // v5: open inspector — renderer highlights it
| null;
export interface Renderer {
init(container: HTMLElement, data: GameData): Promise<void>;
render(snap: SimSnapshot, alpha: number): void; // alpha: 0..1 interp between ticks
pickTile(clientX: number, clientY: number): Vec2 | null;
setGhost(def: string | null, pos: Vec2 | null, dir: Dir): void;
/** v4: mode drives the ghost visual 'build' = placement preview, 'remove' =
* demolition tint. def is null in remove mode. The '__remove' sentinel is deprecated
* and main.ts stops sending it in v5. */
setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode?: 'build' | 'remove'): void;
/** v9 GRANT (requested round 6, three consumers by round 7): the same event array
* ScreenFX/AudioFX get, fanned out by main.ts each frame. Optional a renderer
* without it keeps deriving from snapshot deltas. */
onEvents?(events: SimEvent[]): void;
}
export interface UIBus {
dispatch(cmd: Command): void;
/** current build selection; renderer ghost + click-to-place read this */
/** current build selection; renderer ghost + click-to-place read this.
* DEPRECATED in v4 (still served): use selection()/setSelection(). */
selectedBuild(): { def: string; dir: Dir } | null;
/** v2: world picking, plumbed from the renderer — UI's click-to-inspect */
pickTile(clientX: number, clientY: number): Vec2 | null;
/** v4: typed selection. main.ts owns the state; UI writes via setSelection.
* In remove mode main.ts dispatches the `remove` command on click the UI must
* NOT also dispatch it (that was the round-3 double-fire). */
selection?(): SelectionState;
setSelection?(sel: SelectionState): void;
}
export interface UI {
@ -171,6 +296,18 @@ export interface UI {
update(snap: SimSnapshot, events: SimEvent[]): void;
}
/** v6: THE SPEAKER everything audible. All sound is synthesized (Web Audio), no
* asset files. Pre-discovery: minimal diegetic UI sounds only. After the optical
* fossil is found (snapshot.relics / relicFound), the full band unlocks: tuner,
* stations, world ambience. Owned by LANE-SCREEN (src/audio/**). */
export interface AudioFX {
init(data: GameData): void;
/** call on first user gesture — browsers gate AudioContext on interaction */
unlock(): void;
onEvents(events: SimEvent[]): void;
frame(timeMs: number, snap?: SimSnapshot): void;
}
export interface ScreenFX {
init(canvas: HTMLCanvasElement, data: GameData): void;
onEvents(events: SimEvent[]): void; // shipped items feed the corruption

View File

@ -2,51 +2,184 @@
* FKTRY main loop ORCHESTRATOR-OWNED wiring. Lanes do not edit.
* Fixed-timestep sim (30 tps) + interpolated render + event fan-out.
*/
import { TICKS_PER_SECOND, type Command, type Dir, type GameData, type UIBus } from './contracts';
import { TICKS_PER_SECOND, type Command, type Dir, type GameData, type SelectionState, type UIBus } from './contracts';
import { createSim } from './sim';
import { createRenderer } from './render';
import { createUI } from './ui';
import { createScreenFX } from './screen';
import { createAudioFX, getRadio } from './audio';
import items from '../data/items.json';
import machines from '../data/machines.json';
import recipes from '../data/recipes.json';
import tech from '../data/tech.json';
import commissions from '../data/commissions.json';
import seams from '../data/seams.json';
const data = { items, machines, recipes, tech, commissions } as unknown as GameData;
const data = { items, machines, recipes, tech, commissions, seams } as unknown as GameData;
const sim = createSim();
const renderer = createRenderer();
const ui = createUI();
const screen = createScreenFX();
const audio = createAudioFX();
const bus: UIBus & { _sel: { def: string; dir: Dir } | null } = {
_sel: null,
/**
* Radio RadioControl adapter (round-5 review fix: the dial drove nothing).
* Two problems it solves, both integration seams between LANE-SCREEN's engine and
* LANE-UI's dock:
* 1. TIMING the dock resolves its control ONCE, inside ui.init(), before any user
* gesture has built the audio graph. This adapter EXISTS immediately (so the
* init-time probe finds a real subscribe()) and lazily attaches to the engine
* when the first gesture creates it, replaying state to subscribers.
* 2. SHAPE + UNITS UI speaks RadioControl {setFrequency(MHz), volume(), mute(),
* state.freqMHz, state.station:name}; the engine speaks Radio {setFrequency(native
* kHz for AM/SW), setVolume(), setMuted(), state.frequency, state.locked:id}.
*/
function createSpeakerAdapter() {
type EngineRadio = NonNullable<ReturnType<typeof getRadio>>;
let engine: EngineRadio | null = null;
let names = new Map<string, string>();
const subs = new Set<(s: any) => void>();
// pre-engine local state so the dock behaves before the first gesture
let local = { freqMHz: 88.0, band: 'FM' as 'AM' | 'FM' | 'SW', volume: 0.7, muted: false, station: null as string | null };
const toMHz = (band: string, f: number) => (band === 'FM' ? f : f / 1000);
const toNative = (band: string, mhz: number) => (band === 'FM' ? mhz : mhz * 1000);
const translate = (s: any) => ({
freqMHz: toMHz(s.band, s.frequency),
band: s.band,
volume: s.volume,
muted: s.muted,
station: s.locked ? (names.get(s.locked) ?? s.locked) : null,
});
const control = {
setFrequency(mhz: number) {
local.freqMHz = mhz;
if (engine) engine.setFrequency(toNative(engine.getState().band, mhz));
else notifyLocal();
},
setBand(band: 'AM' | 'FM' | 'SW') {
local.band = band;
if (engine) engine.setBand(band);
else notifyLocal();
},
volume(v: number) {
local.volume = Math.max(0, Math.min(1, v));
if (engine) engine.setVolume(local.volume);
else notifyLocal();
},
mute(on: boolean) {
local.muted = on;
if (engine) engine.setMuted(on);
else notifyLocal();
},
getState() {
return engine ? translate(engine.getState()) : { ...local };
},
subscribe(cb: (s: any) => void) {
subs.add(cb);
cb(control.getState());
return () => subs.delete(cb);
},
/** v8: station tick marks for the dial — engine truth, MHz units, empty pre-gesture */
listStations() {
return engine
? engine.listStations().map((st) => ({ ...st, freqMHz: toMHz(st.band, st.freq) }))
: [];
},
};
function notifyLocal() { for (const cb of subs) cb({ ...local }); }
return {
control,
attach() {
const r = getRadio();
if (!r || engine) return;
engine = r;
names = new Map(r.listStations().map((s) => [s.id, s.name]));
// carry any pre-gesture dial fiddling into the engine
r.setBand(local.band);
r.setFrequency(toNative(local.band, local.freqMHz));
r.setVolume(local.volume);
r.setMuted(local.muted);
r.subscribe((s) => { const t = translate(s); for (const cb of subs) cb(t); });
},
};
}
const speaker = createSpeakerAdapter();
// v4 bridge: _sel keeps the legacy {def,dir} shape (including the deprecated
// '__remove' sentinel) so unmigrated lanes keep working; selection()/setSelection()
// are the typed protocol. The sentinel path is deleted in v5.
const bus: UIBus & { _sel: { def: string; dir: Dir } | null; _state: SelectionState } = {
_sel: null, // legacy mirror (build/remove only) — served for selectedBuild() compat
_state: null, // v5: typed truth, carries inspect mode
dispatch: (cmd: Command) => sim.enqueue(cmd),
selectedBuild: () => bus._sel,
pickTile: (x, y) => renderer.pickTile(x, y),
selection: (): SelectionState => {
if (bus._state) return bus._state;
if (!bus._sel) return null; // legacy writer fallback
if (bus._sel.def === '__remove') return { mode: 'remove' };
return { mode: 'build', def: bus._sel.def, dir: bus._sel.dir };
},
setSelection: (sel: SelectionState) => {
bus._state = sel;
bus._sel =
sel?.mode === 'build' ? { def: sel.def, dir: sel.dir } :
sel?.mode === 'remove' ? { def: '__remove', dir: 0 as Dir } :
null;
},
};
// LANE-UI sets the build selection through this hook:
(window as any).__fktryBus = bus;
async function boot() {
sim.init(data, 1337);
// publish the speaker handshake BEFORE ui.init — the dock resolves its control
// exactly once, during init, and must find a live-shaped RadioControl there.
(window as any).__fktrySpeaker = { radio: speaker.control };
await renderer.init(document.getElementById('game')!, data);
ui.init(document.getElementById('ui')!, data, bus);
screen.init(document.getElementById('screen') as HTMLCanvasElement, data);
audio.init(data);
// browsers gate AudioContext on a user gesture — unlock on the first one,
// then attach the adapter to the freshly built engine.
addEventListener('pointerdown', () => {
audio.unlock();
speaker.attach();
}, { once: true });
// click-to-place / hover ghost glue
const game = document.getElementById('game')!;
// Ghost refresh runs on move AND every frame (a keyboard disarm — Esc — used to leave
// a stale demolition tint on screen until the mouse moved; round-5 UI finding).
let lastPointer: { x: number; y: number } | null = null;
function refreshGhost() {
if (!lastPointer) return;
const sel = bus.selection!();
const tile = renderer.pickTile(lastPointer.x, lastPointer.y);
if (sel?.mode === 'remove') {
renderer.setGhost(null, tile, 0, 'remove');
} else if (sel?.mode === 'build') {
renderer.setGhost(sel.def, tile, sel.dir, 'build');
} else {
renderer.setGhost(null, tile, 0, 'build'); // inspect / no selection: no ghost
}
}
game.addEventListener('pointermove', (e) => {
const sel = bus.selectedBuild();
renderer.setGhost(sel?.def ?? null, renderer.pickTile(e.clientX, e.clientY), sel?.dir ?? 0);
lastPointer = { x: e.clientX, y: e.clientY };
refreshGhost();
});
game.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; // pan/context buttons never place
const sel = bus.selectedBuild();
const sel = bus.selection!();
const tile = renderer.pickTile(e.clientX, e.clientY);
if (sel && tile) sim.enqueue({ kind: 'place', def: sel.def, pos: tile, dir: sel.dir });
if (!sel || !tile) return;
if (sel.mode === 'remove') sim.enqueue({ kind: 'remove', pos: tile });
else if (sel.mode === 'build') sim.enqueue({ kind: 'place', def: sel.def, pos: tile, dir: sel.dir });
});
const MS_PER_TICK = 1000 / TICKS_PER_SECOND;
@ -56,21 +189,32 @@ async function boot() {
while (acc >= MS_PER_TICK) { sim.tick(); acc -= MS_PER_TICK; }
const snap = sim.snapshot();
const events = sim.drainEvents();
refreshGhost();
renderer.onEvents?.(events); // v9: same array Screen/Audio get — read-only, same frame
renderer.render(snap, acc / MS_PER_TICK);
ui.update(snap, events);
screen.onEvents(events);
screen.frame(now, snap);
audio.onEvents(events);
audio.frame(now, snap);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// dev-only: paste-free demo — builds SIM's reference factory
// dev-only: paste-free demo — builds SIM's reference factory. The layout uses
// research-gated ids, so it is a post-research save by definition: grant the techs
// it needs first (testkit rides the public save/load API), then dispatch.
if (import.meta.env.DEV) {
// the page's live sim, so testkit verbs (chargeDebt, spawnCorrectionUnit, ...) can
// drive an in-browser review session the same way lane tests drive headless ones
(window as any).__fktrySim = sim;
(window as any).__fktryDemo = async () => {
const { referenceFactory } = await import('./sim/reference');
const { referenceFactory, referenceFactoryTech } = await import('./sim/reference');
const techs = referenceFactoryTech(data);
sim.enqueue({ kind: 'grantResearch', tech: techs }); // v5 command; queued first
const cmds = referenceFactory(data);
cmds.forEach((c) => sim.enqueue(c));
return `${cmds.length} commands dispatched`;
return `${techs.length} techs granted, ${cmds.length} commands dispatched`;
};
}
}

97
fktry/src/render/aura.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* LANE-RENDER cooling aura footprint.
*
* `coolRadius` is CHEBYSHEV distance from the machine's footprint, so the region it
* actually cools is a RECTANGLE the footprint grown by r tiles on every side. Drawing
* a circle here would be prettier and would lie: the corners it left out are cooled, and
* players would plan around a boundary that isn't real. So this is a rectangle, aligned
* to the grid it's actually measured in.
*
* Engineering-drawing vibe, not a buff circle: a thin dashed outline with tick marks at
* the tile grid, no fill, no bloom. It's a coverage annotation, not a power-up.
*/
import * as THREE from 'three';
const AURA = 0x7fd4ff;
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform vec2 uSize; // rectangle size in TILES, so dashes stay tile-sized at any scale
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 p = vUv * uSize; // position in tiles from the corner
vec2 d = min(p, uSize - p); // distance to the nearest edge, in tiles
float edge = min(d.x, d.y);
// Dashed border: march the dash phase around the perimeter, not across the face,
// so corners don't smear.
float along = (d.x < d.y) ? p.y : p.x;
float dash = step(0.35, fract(along * 1.5 - uTime * 0.25));
float line = (1.0 - smoothstep(0.03, 0.07, edge)) * dash;
// Tick marks every tile, just inside the border — the drafting touch.
float tick = step(0.94, fract(along)) * (1.0 - smoothstep(0.10, 0.16, edge));
float a = max(line, tick * 0.7);
if (a < 0.02) discard;
gl_FragColor = vec4(uColor, a * 0.55);
}
`;
export class CoolAura {
readonly group = new THREE.Group();
private mesh: THREE.Mesh;
private uniforms = {
uSize: { value: new THREE.Vector2(1, 1) },
uColor: { value: new THREE.Color(AURA) },
uTime: { value: 0 },
};
constructor() {
this.group.name = 'coolAura';
this.group.visible = false;
this.mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
}),
);
this.mesh.rotation.x = -Math.PI / 2;
this.mesh.position.y = 0.03;
this.mesh.renderOrder = 9;
this.group.add(this.mesh);
}
hide(): void {
this.group.visible = false;
}
/**
* `cx`/`cz` centre the machine; `fw`/`fd` are its ROTATED footprint; `r` is coolRadius.
* Covered region = footprint + r tiles on each side (chebyshev).
*/
show(cx: number, cz: number, fw: number, fd: number, r: number, timeSec: number): void {
const w = fw + r * 2;
const d = fd + r * 2;
this.group.visible = true;
this.uniforms.uSize.value.set(w, d);
this.uniforms.uTime.value = timeSec;
this.mesh.scale.set(w, d, 1);
this.mesh.position.set(cx, 0.03, cz);
}
}

View File

@ -7,10 +7,8 @@
* fixed-timestep interpolation. One tick of latency, and no extrapolation jitter when
* an item is blocked by back-pressure (prev == cur -> it simply sits still).
*
* Identity: contracts v2 added `BeltItem.id`. When present we key off it and the
* interpolation is EXACT. When absent we fall back to matching by (belt, type, order
* along the belt) correct except for the one tick where the lead item leaves and the
* queue behind it shifts index. That fallback goes away when v3 makes `id` required.
* Identity: contracts v3 makes `BeltItem.id` required, so interpolation is EXACT and
* the old order-matching fallback (and its per-frame sort of every item) is gone.
*
* Pathing: cargo follows the belt's topology shape, so on a corner it enters at the
* feeding edge and sweeps the arc rather than teleporting to the back edge and cutting
@ -29,8 +27,13 @@ interface ItemMesh {
capacity: number;
}
/** Products glow; raw ore doesn't — saturation belongs to media (style guide §8). */
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
/**
* Products glow; raw ore doesn't saturation belongs to media (style guide §8).
* The base colour goes through `readable()` because MDAT ORE's authored #1a1a22 is
* invisible against the belt; ore still reads as the darkest thing on the line, just
* not as a hole in the world.
*/
export function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: readable(def.color),
emissive: new THREE.Color(def.color),
@ -40,7 +43,8 @@ function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
});
}
function itemGeometry(def: ItemDef): THREE.BufferGeometry {
export function itemGeometry(def: ItemDef): THREE.BufferGeometry {
// Products read as faceted crystals; raw/refined as crate-ish boxes.
return def.tier >= 2
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
@ -50,17 +54,24 @@ export class BeltItemLayer {
readonly group = new THREE.Group();
private byItem = new Map<string, ItemMesh>();
private items = new Map<string, ItemDef>();
private prev = new Map<string, number>();
private cur = new Map<string, number>();
/** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */
private prev = new Map<number, number>();
private cur = new Map<number, number>();
private lastTick = -1;
private dummy = new THREE.Object3D();
private counts = new Map<string, number>();
private seq = new Map<string, number>();
private sorted: SimSnapshot['beltItems'][number][] = [];
private written = new Map<string, number>();
private lp = { x: 0, z: 0 };
private wp = { x: 0, z: 0 };
/** Set once per frame so NOTES/telemetry can report which identity path ran. */
exactIds = false;
/**
* 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';
@ -71,6 +82,7 @@ export class BeltItemLayer {
const existing = this.byItem.get(def.id);
if (existing && needed <= existing.capacity) return existing;
const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
// Re-use geo/mat across growth; only the InstancedMesh itself is replaced.
const geometry = existing?.mesh.geometry ?? itemGeometry(def);
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
if (existing) {
@ -88,18 +100,9 @@ export class BeltItemLayer {
return im;
}
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
const src = snap.beltItems;
// Sim is internally consistent: if it stamps ids, it stamps all of them.
this.exactIds = src.length > 0 && src[0].id !== undefined;
this.sorted.length = 0;
for (const bi of src) if (this.topo.get(bi.entity)) this.sorted.push(bi);
if (!this.exactIds) {
// Order-match fallback needs cargo ordered front-to-back per belt.
this.sorted.sort((a, b) => (a.entity !== b.entity ? a.entity - b.entity : b.t - a.t));
}
/** @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) {
const swap = this.prev;
@ -109,12 +112,12 @@ export class BeltItemLayer {
this.lastTick = snap.tick;
}
// Pass 1 — count per type, and record this tick's positions for the next frame.
// Pass 1 — count per item type, and record this tick's positions for next frame.
this.counts.clear();
this.seq.clear();
for (const bi of this.sorted) {
for (const bi of snap.beltItems) {
if (!this.topo.get(bi.entity)) continue; // cargo on a belt we haven't seen yet
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
if (advanced) this.cur.set(this.keyOf(bi), bi.t);
if (advanced) this.cur.set(bi.id, bi.t);
}
for (const [itemId, count] of this.counts) {
const def = this.items.get(itemId);
@ -122,46 +125,58 @@ export class BeltItemLayer {
}
// Pass 2 — write instance matrices.
this.seq.clear();
const written = new Map<string, number>();
for (const bi of this.sorted) {
const node = this.topo.get(bi.entity)!;
this.written.clear();
this.icedSeen.clear();
for (const bi of snap.beltItems) {
const node = this.topo.get(bi.entity);
if (!node) continue;
const def = this.items.get(bi.item);
const im = this.byItem.get(bi.item);
if (!def || !im) continue;
const from = this.prev.get(this.keyOf(bi));
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();
const n = written.get(bi.item) ?? 0;
const n = this.written.get(bi.item) ?? 0;
if (n < im.capacity) {
im.mesh.setMatrixAt(n, this.dummy.matrix);
written.set(bi.item, n + 1);
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 = written.get(id) ?? 0;
im.mesh.count = this.written.get(id) ?? 0;
im.mesh.instanceMatrix.needsUpdate = true;
}
}
/** Exact when the sim stamps ids; order-matched otherwise. */
private keyOf(bi: SimSnapshot['beltItems'][number]): string {
if (bi.id !== undefined) return `#${bi.id}`;
const sk = `${bi.entity}:${bi.item}`;
const idx = this.seq.get(sk) ?? 0;
this.seq.set(sk, idx + 1);
return `${bi.entity}:${bi.item}:${idx}`;
}
}

View File

@ -0,0 +1,109 @@
/**
* LANE-RENDER the break room. A GLYTCH arcade cabinet near the start zone.
*
* Clicking it opens `match.html` the original match-the-glitch prototype, which deploy
* copies next to the game (see deploy.sh). The two games finally meet. The cabinet is a
* real object in the world with its own raycast (index.ts owns the pick + the cursor
* affordance), not a UI button, because it should feel like a thing you find on the
* floor, not a menu item.
*
* The marquee spells GLYTCH in glowing blocks a placeholder until a MODELBEAST cabinet
* lands under the `arcade-cabinet` asset key (the registry would hot-swap it, but the
* break room isn't a MachineDef, so this is hand-built and self-contained).
*/
import * as THREE from 'three';
// 5x5 bitmap font for G L Y T C H — one column of glowing pixels per letter row.
const GLYPHS: Record<string, number[]> = {
G: [0b01110, 0b10000, 0b10011, 0b10001, 0b01110],
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
Y: [0b10001, 0b01010, 0b00100, 0b00100, 0b00100],
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100],
C: [0b01110, 0b10001, 0b10000, 0b10001, 0b01110],
H: [0b10001, 0b10001, 0b11111, 0b10001, 0b10001],
};
export class BreakRoom {
readonly group = new THREE.Group();
/** The subtree the raycast tests against — click anywhere on the cabinet counts. */
readonly clickable: THREE.Object3D[] = [];
private marqueeMat: THREE.MeshStandardMaterial;
constructor(x: number, z: number) {
this.group.name = 'breakroom';
this.group.position.set(x, 0, z);
// The iso rig looks from (+x,+y,+z), so a face whose normal is +z points 45° away
// from the viewer and the marquee reads edge-on. Turn the whole cabinet to face the
// camera — it's a prop, not a machine, so it doesn't owe the grid an orientation.
this.group.rotation.y = Math.PI / 4;
const body = new THREE.MeshStandardMaterial({ color: 0x1a1830, roughness: 0.7, metalness: 0.2 });
// Wide enough to carry its own marquee: GLYTCH at a legible pixel size spans ~1.8
// units, and a sign wider than its cabinet reads as a floating decal.
const cabinet = new THREE.Mesh(new THREE.BoxGeometry(2.1, 2.6, 1.1), body);
cabinet.position.y = 1.3;
cabinet.castShadow = cabinet.receiveShadow = true;
this.group.add(cabinet);
this.clickable.push(cabinet);
// Screen: a dark panel with a cyan glow, angled toward the player.
const screen = new THREE.Mesh(
new THREE.PlaneGeometry(1.5, 1.0),
new THREE.MeshStandardMaterial({
color: 0x0a1420, emissive: 0x1b6cff, emissiveIntensity: 0.8, roughness: 0.4,
}),
);
screen.position.set(0, 1.45, 0.56);
screen.rotation.x = -0.18;
this.group.add(screen);
this.clickable.push(screen);
// Marquee: GLYTCH in emissive pixels across the top.
this.marqueeMat = new THREE.MeshStandardMaterial({
color: 0x3fd4ff, emissive: 0x3fd4ff, emissiveIntensity: 1.6,
});
const marquee = this.buildMarquee();
marquee.position.set(0, 2.28, 0.57);
this.group.add(marquee);
// Control deck.
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.15, 0.5), body);
deck.position.set(0, 0.78, 0.55);
deck.rotation.x = -0.3;
this.group.add(deck);
this.clickable.push(deck);
}
private buildMarquee(): THREE.Object3D {
const g = new THREE.Group();
const word = 'GLYTCH';
// Sized so the word spans most of the cabinet's width — at the rig's closest zoom
// (8) a 0.03 pixel was sub-pixel on screen and GLYTCH was an illegible smudge.
const px = 0.05;
const letterW = 5 * px + px; // 5 cols + gap
const totalW = word.length * letterW;
const pixel = new THREE.BoxGeometry(px * 0.9, px * 0.9, px * 0.6);
word.split('').forEach((ch, li) => {
const rows = GLYPHS[ch];
if (!rows) return;
rows.forEach((bits, ry) => {
for (let cx = 0; cx < 5; cx++) {
if (!(bits & (1 << (4 - cx)))) continue;
const m = new THREE.Mesh(pixel, this.marqueeMat);
m.position.set(
-totalW / 2 + li * letterW + cx * px,
(2 - ry) * px,
0,
);
g.add(m);
}
});
});
return g;
}
/** Marquee attract-mode shimmer. */
frame(timeSec: number): void {
this.marqueeMat.emissiveIntensity = 1.3 + Math.sin(timeSec * 4) * 0.4;
}
}

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.
@ -150,6 +157,14 @@ export class CameraRig {
this.applyFrustum();
}
/** Jump the rig to frame a specific spot — the showroom's camera preset. */
frame(x: number, z: number, zoom: number): void {
this.target.set(x, 0, z);
this.zoomD = THREE.MathUtils.clamp(zoom, ZOOM_MIN, ZOOM_MAX);
this.applyFrustum();
this.place();
}
dispose(): void {
for (const d of this.disposers) d();
this.disposers = [];

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

@ -15,7 +15,12 @@
* by wrapping `t` on one belt, because that's what the real sim does — and it's what
* exercises the renderer's cross-belt interpolation and corner pathing honestly.
*/
import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts';
import {
TICKS_PER_SECOND,
type CorrectionState, type CorrectionUnit,
type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot,
type WildlifeState,
} from '../contracts';
/**
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
@ -43,6 +48,8 @@ export class DevScene {
private speed = 2;
private ok = false;
private nextId = 1;
/** The mock's own scram latch — see snapshot(). */
private scrammed = false;
constructor(private data: GameData, private stress = false) {
this.build();
@ -90,6 +97,40 @@ export class DevScene {
this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items
if (machines.length) this.machine(machines[r % machines.length], -5, y, 1); // +20 = 500
}
// The round-5 perf bar: floor + 50 piles + 10 swarms on top of 500 entities.
const ents = this.entities.filter((e) => e.def !== 'belt');
for (let i = 0; i < 50; i++) {
this.wildlife.push({
id: 1000 + i, kind: 'dust-pile',
pos: { x: -30 + (i % 10) * 3, y: -26 + Math.floor(i / 10) * 12 },
size: ((i * 37) % 100) / 100,
});
}
for (let i = 0; i < 10; i++) {
const host = ents[i % Math.max(1, ents.length)];
this.wildlife.push({
id: 2000 + i, kind: 'mosquito-swarm',
pos: host ? { x: host.pos.x, y: host.pos.y } : { x: -5, y: -20 + i * 5 },
size: 0.5 + (i % 5) * 0.1,
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;
}
@ -123,6 +164,47 @@ export class DevScene {
if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1);
if (this.has('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize');
if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh');
// v4/v5 kinds, so inspect-aura and the lab silhouette are reachable in the scaffold.
if (this.has('asic-cooler')) this.machine('asic-cooler', 9, 8, 1);
if (this.has('archaeology-lab')) this.machine('archaeology-lab', 13, 8, 1);
// A few hazards so wildlife is visible without the stress rig: a growing dust drift
// beside the quantizer (which is what sheds HF dust, §2) and a swarm on the demuxer.
const demuxer = this.entities.find((e) => e.def === 'demuxer');
this.wildlife.push(
{ id: 900, kind: 'dust-pile', pos: { x: 1, y: 11 }, size: 0.35 },
{ id: 901, kind: 'dust-pile', pos: { x: 2, y: 12 }, size: 0.8 },
{ id: 902, kind: 'dust-pile', pos: { x: 0, y: 12 }, size: 0.15 },
{ id: 910, kind: 'mosquito-swarm', pos: { x: -8, y: 0 }, size: 0.7, target: demuxer?.id },
// The Correction: three parity mites, one per state so all three tells are visible —
// 920 hunting (drifts), 921 on-station (dwells, eye glowing), 922 animated in
// snapshot() so it cycles through the repair and fires the tidy 'repaired' sparkle.
{ id: 920, kind: 'parity-mite', pos: { x: -9, y: 0 }, size: 0, target: demuxer?.id },
{ id: 921, kind: 'parity-mite', pos: { x: -2, y: 3 }, size: 0.5 },
{ 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;
}
@ -132,13 +214,28 @@ export class DevScene {
// so all three states are reachable in a short verification session.
const heat = Math.min(1, (tick % 300) / 200);
const brownout = Math.floor(tick / 300) % 4 === 3;
// The mock owns its own latch, the way the real sim owns its own: scram at 1.0 and
// stay dead while cooling. The renderer must read `scrammed` and never infer it —
// that's the whole point of the v3 field.
if (heat >= 1) this.scrammed = true;
else if (heat < 0.5) this.scrammed = false;
for (const e of this.entities) {
if (e.def === 'software-decoder') e.heat = heat;
if (e.def === 'software-decoder') {
e.heat = heat;
e.scrammed = this.scrammed;
}
if (e.def === 'demuxer') e.jammed = Math.floor(tick / 450) % 3 === 2 ? 'output full: LUMA BARS' : null;
if (e.recipe) e.progress = (tick % 90) / 90;
}
// 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;
@ -161,11 +258,91 @@ export class DevScene {
gen: 20,
draw: brownout ? 26 : 14,
stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100,
capacity: 100,
brownout,
},
shippedTotal: { melt: Math.floor(tick / 90) },
activeCommission: null,
commissionProgress: {},
// v4: nothing researched, so every tech-gated machine reads locked — which is what
// the ghost's padlock is for. `unlocked` is writable from the console to check the
// other half (see NOTES).
research: this.research,
// v3 hardened: the queue is active-first, then upcoming.
commissionQueue: this.commissionQueue,
// v5/v6: hazards and secrets. Mutable so a verification session can grow a pile,
// 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. */
readonly commissionQueue: string[] = [];
/** v5 hazards. `?devscene=stress` fills these to the perf bar (50 piles + 10 swarms). */
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,30 +10,24 @@
* 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';
/** Placement thunk: how long the scale-pop takes, in seconds. */
const POP_TIME = 0.22;
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
const THROTTLE_FLOOR = 0.25;
/**
* Scram releases below this mirroring LANE-SIM's `HEAT_RESTART`.
*
* Scram is a LATCH in the sim, but it lives on its internal `Ent`, never on
* `EntityState`; it clears `jammed` on scram ("reported by its own event, not as a flow
* jam"), and `Renderer` only ever receives snapshots, never events. So the latched-off
* state is not snapshot-visible, and a plain `heat >= 1` test un-scrams the moment the
* machine starts cooling showing it alive and glowing for the ~125 ticks (~4s) it
* spends dead between 1.0 and HEAT_RESTART. Mirroring the sim's hysteresis here is
* correct today but couples this constant to theirs; `EntityState.scrammed` would kill
* the duplication. Filed as a CONTRACT REQUEST.
*/
const SCRAM_RELEASE = 0.5;
/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
const DEFAULT_BUFFER_CAP = 100;
const HEAT_RED = new THREE.Color(0xff3a12);
@ -65,18 +59,95 @@ interface Rec {
idleClock: number;
heat: HeatTarget[];
all: HeatTarget[];
/** Latched scram state, mirroring the sim's (see SCRAM_RELEASE). */
scramLatch: boolean;
/** Whether dead-dark is currently applied, so restart knows to restore. */
wasScrammed: boolean;
/** Seconds into the placement scale-pop; >= POP_TIME means settled. */
pop: number;
/** The object's settled scale. 1 for placeholders; the GLB fit-to-footprint scale for a
* hot-swapped asset. The pop multiplies THIS, never resets to 1 a normalised GLB roots
* 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();
private live = new Map<number, Rec>();
private seen = new Set<number>();
/** False until the first sync completes — stops a save-load firing juice for everything. */
private primed = false;
/** One shared material: every alarm blinks together, like a real klaxon. */
private jamMat = new THREE.MeshStandardMaterial({
color: JAM_AMBER,
@ -84,20 +155,37 @@ export class EntityLayer {
emissiveIntensity: 1.5,
});
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
constructor(
private registry: AssetRegistry,
private defs: Map<string, MachineDef>,
private juice?: Juice,
) {
this.group.name = 'entities';
}
sync(snap: SimSnapshot, timeSec: number, dt: 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;
// Tanks share one bandwidth pool, so they all read the same level: the pool's
// fraction of total installed capacity.
let cap = 0;
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
// fraction of total installed capacity. v4 publishes that total, so take it from
// truth rather than re-summing bufferCap here — a second copy of the sim's
// arithmetic is exactly what the scram latch taught us not to keep.
let cap = snap.bandwidth.capacity ?? 0;
if (!snap.bandwidth.capacity) {
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
}
}
const fillLevel = cap > 0 ? THREE.MathUtils.clamp(snap.bandwidth.stored / cap, 0, 1) : 0;
@ -109,11 +197,27 @@ export class EntityLayer {
this.seen.add(e.id);
let rec = this.live.get(e.id);
// A pure ASSET-version bump is a MODELBEAST hot-swap, not a placement. The mesh is
// rebuilt either way, but the placement juice must not fire: since round 7 the GLB
// probe no longer blocks the first frame, so every machine on screen swaps
// placeholder -> GLB about a second into every boot, and firing the puff + scale-pop
// on that made the whole factory re-place itself in front of the player. A `def`
// change is still a real replacement and still pops.
const rebuilt = !!rec && rec.def === e.def && rec.assetVersion !== entry.version;
if (rec && (rec.def !== e.def || rec.assetVersion !== entry.version)) {
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
rec = undefined;
}
if (!rec) rec = this.build(e.id, e.def, def, entry.version);
if (!rec) {
rec = this.build(e.id, e.def, def, entry.version);
// A machine that was not here last frame was just placed. First frame primes the
// set without firing, so loading a save doesn't confetti the whole factory.
if (this.primed && !rebuilt) {
const c = centerOf(e.pos, def, e.dir);
this.juice?.puff(c.x, c.z);
rec.pop = 0; // start the scale-pop
}
}
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
centerOf(e.pos, def, e.dir, rec.obj.position);
@ -124,12 +228,24 @@ export class EntityLayer {
}
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
// Latch with hysteresis, exactly as the sim does — see SCRAM_RELEASE.
if (heat >= 1) rec.scramLatch = true;
else if (heat < SCRAM_RELEASE) rec.scramLatch = false;
const scrammed = rec.scramLatch;
// v3: scram comes from the sim's own latch. `heat` alone cannot express the
// hysteresis window — a machine stays dead while cooling from 1.0 back down —
// so deriving it here meant duplicating the sim's restart threshold and silently
// 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))
@ -140,16 +256,48 @@ 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;
if (alarm) anyAlarm = true;
// Placement thunk: overshoot then settle. Reads as weight landing on the floor.
// The pop is a MULTIPLIER on the settled scale, never an absolute — a normalised GLB
// settles at baseScale (s≠1), so resetting to 1 would collapse its body to a speck.
if (rec.pop < POP_TIME) {
rec.pop += dt;
const k = Math.min(1, rec.pop / POP_TIME);
const mult = k < 1 ? 1 + Math.sin(k * Math.PI) * 0.22 : 1;
rec.obj.scale.setScalar(rec.baseScale * (k < 0.12 ? k / 0.12 : mult));
} else if (rec.obj.scale.x !== rec.baseScale) {
rec.obj.scale.setScalar(rec.baseScale);
}
// Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8).
// 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) {
if (!this.seen.has(id)) this.drop(id, rec);
if (!this.seen.has(id)) {
this.juice?.burst(rec.obj.position.x, rec.obj.position.z);
this.drop(id, rec);
}
}
this.primed = true;
if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
}
@ -188,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();
@ -258,8 +428,13 @@ export class EntityLayer {
idleClock: 0,
heat,
all,
scramLatch: false,
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;
@ -267,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);

146
fktry/src/render/env.ts Normal file
View File

@ -0,0 +1,146 @@
/**
* LANE-RENDER the missing image-based light (round 7 root-cause fix).
*
* THE BUG THIS EXISTS TO KILL. In the three.js PBR model a material's diffuse term is
* `albedo * (1 - metalness)`; everything a metal shows you is *reflected surroundings*.
* MODELBEAST's assets are metal: of the 27 shipped GLBs, **13 carry metallicFactor >= 0.8
* and 16 >= 0.5**, and 21 of them have no metallic-roughness texture to soften that with
* per-texel variation. With `scene.environment === null` those materials have almost no
* diffuse AND nothing to reflect except two analytic light lobes, so they are near-black
* by arithmetic no number of extra lights fixes it, because lights feed the term that
* metalness has already zeroed. Round 6's emissive floor was a mask over this; the fix is
* to give the world an environment to reflect.
*
* ZERO BYTES SHIPPED. The environment is generated in-process: a small equirectangular
* radiance map (128x64 half-float, ~16KB, built from arithmetic) run through
* `THREE.PMREMGenerator`. No asset fetch, no HDR file, nothing for the deploy to carry.
*
* WHAT IT LOOKS LIKE (codex §8: "base world in desaturated industrial neutrals; ALL
* saturation belongs to media/artifacts"). A dark industrial room, not a sunny outdoors:
* - near-black indigo floor tone, keyed off the crust the factory actually sits on
* - a slightly brighter cool "bitstream haze" overhead
* - a soft horizon band this is the bit that makes a metal panel read as metal
* rather than as a flat sticker, because a metal edge needs a horizon to catch
* - two soft blobs standing in for the scene's own key and fill lights, so a reflection
* agrees with the shading rather than fighting it. The directions are PASSED IN from
* index.ts's real lights this module never keeps its own copy of them (the scram
* latch taught this lane not to duplicate another module's constants).
*/
import * as THREE from 'three';
/** Equirect resolution. PMREM blurs this into mips, so a gradient needs very little. */
export const ENV_W = 128;
export const ENV_H = 64;
/**
* Linear radiance, authored against the world palette (`palette.ts WORLD.crust` is
* #0c0a15). Deliberately dim: the whole factory is lit at exposure 1.35 through ACES,
* and an environment bright enough to look "correct" in isolation turns every chassis
* into a lightbulb.
*/
const FLOOR_RGB = [0.013, 0.011, 0.021] as const; // the crust, bounced
const SKY_RGB = [0.055, 0.060, 0.098] as const; // cool haze overhead
const HORIZON_RGB = [0.085, 0.080, 0.115] as const; // the band metal edges catch
const HORIZON_WIDTH = 0.17; // in sin(elevation)
const KEY_RGB = [0.75, 0.78, 1.0] as const; // mirrors the key light's colour
const KEY_TIGHT = 12; // cosine power — a soft studio blob, not a sun
const FILL_RGB = [0.105, 0.090, 0.205] as const; // mirrors the cool fill
const FILL_TIGHT = 2.5;
function smoothstep(a: number, b: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
/**
* The direction a texel looks at, in three.js's own equirect convention
* (`equirect_uv.glsl`: `u = atan2(z, x) / 2PI + 0.5`, `v = asin(y) / PI + 0.5`).
* Getting this wrong doesn't show up in a gradient but puts the key reflection on the
* wrong side of every machine, so it's worth writing down rather than eyeballing.
*/
export function dirAt(u: number, v: number, out: THREE.Vector3): THREE.Vector3 {
const lat = (v - 0.5) * Math.PI;
const y = Math.sin(lat);
const r = Math.cos(lat);
const lon = (u - 0.5) * Math.PI * 2;
return out.set(Math.cos(lon) * r, y, Math.sin(lon) * r);
}
/**
* The environment as linear RGB floats (3 per texel, row 0 = straight down).
* Pure arithmetic no DOM, no GL which is what makes it testable.
*/
export function equirectRadiance(
key: THREE.Vector3,
fill: THREE.Vector3,
w = ENV_W,
h = ENV_H,
): Float32Array {
const out = new Float32Array(w * h * 3);
const k = key.clone().normalize();
const f = fill.clone().normalize();
const d = new THREE.Vector3();
for (let j = 0; j < h; j++) {
const v = (j + 0.5) / h;
for (let i = 0; i < w; i++) {
dirAt((i + 0.5) / w, v, d);
// floor -> sky gradient, with the horizon band sitting on top of it
const up = smoothstep(-0.12, 0.6, d.y);
const band = Math.exp(-((d.y / HORIZON_WIDTH) ** 2));
const kd = Math.max(0, d.dot(k)) ** KEY_TIGHT;
const fd = Math.max(0, d.dot(f)) ** FILL_TIGHT;
const o = (j * w + i) * 3;
for (let c = 0; c < 3; c++) {
out[o + c] =
FLOOR_RGB[c] + (SKY_RGB[c] - FLOOR_RGB[c]) * up +
HORIZON_RGB[c] * band +
KEY_RGB[c] * kd +
FILL_RGB[c] * fd;
}
}
}
return out;
}
/** Pack the radiance into a half-float equirect DataTexture PMREM can consume. */
export function createEnvTexture(key: THREE.Vector3, fill: THREE.Vector3): THREE.DataTexture {
const rgb = equirectRadiance(key, fill);
const data = new Uint16Array(ENV_W * ENV_H * 4);
for (let p = 0; p < ENV_W * ENV_H; p++) {
data[p * 4 + 0] = THREE.DataUtils.toHalfFloat(rgb[p * 3 + 0]);
data[p * 4 + 1] = THREE.DataUtils.toHalfFloat(rgb[p * 3 + 1]);
data[p * 4 + 2] = THREE.DataUtils.toHalfFloat(rgb[p * 3 + 2]);
data[p * 4 + 3] = THREE.DataUtils.toHalfFloat(1);
}
// Half-float, not float: linear filtering of FloatType textures needs
// OES_texture_float_linear, which PMREM's equirect blit assumes. HalfFloat is the
// format three's own RGBELoader hands PMREM, so it's the well-trodden path.
const tex = new THREE.DataTexture(data, ENV_W, ENV_H, THREE.RGBAFormat, THREE.HalfFloatType);
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
return tex;
}
/**
* Build the PMREM environment. Call once, assign to `scene.environment`.
* The source texture and the generator are both thrown away only the prefiltered
* cube-UV render target survives, and that's what every MeshStandardMaterial samples.
*/
export function createEnvironment(
gl: THREE.WebGLRenderer,
key: THREE.Vector3,
fill: THREE.Vector3,
): THREE.Texture {
const src = createEnvTexture(key, fill);
const pmrem = new THREE.PMREMGenerator(gl);
pmrem.compileEquirectangularShader();
const rt = pmrem.fromEquirectangular(src);
src.dispose();
pmrem.dispose(); // frees the generator's scratch; `rt.texture` stays valid
return rt.texture;
}

81
fktry/src/render/era.ts Normal file
View File

@ -0,0 +1,81 @@
/**
* LANE-RENDER era skins (style guide §8).
*
* "Reel Beds = sepia/brass/celluloid; Broadcast = phosphor green + wood-grain TV;
* Disc = rainbow-on-black gloss; Stream = flat white plastic + LED; Fortress =
* blinding matte white."
*
* Plumbing only this round. A machine's era is DERIVED from which tech unlocks it, so
* when LANE-DATA adds reel/broadcast techs those machines re-skin with zero renderer
* work. Machines no tech unlocks are starting equipment and take the default.
*
* The skin touches the CHASSIS only the emissive accent stays the colour of what the
* machine makes, because that's the resource, not the decade.
*
* `stream` is deliberately the identity skin (same roughness/metalness as the base body
* material, no tint), so today's look is byte-for-byte unchanged until data says otherwise.
*/
import * as THREE from 'three';
import type { GameData, TechDef } from '../contracts';
export type Era = TechDef['era'];
export const DEFAULT_ERA: Era = 'stream';
export interface EraSkin {
/** Multiplied into the chassis colour — 0xffffff leaves DATA's value alone. */
tint: number;
roughness: number;
metalness: number;
}
export const ERA_SKIN: Record<Era, EraSkin> = {
reel: { tint: 0xffd2a0, roughness: 0.65, metalness: 0.45 }, // sepia/brass/celluloid
broadcast: { tint: 0xc4e8c4, roughness: 0.8, metalness: 0.1 }, // phosphor + woodgrain
disc: { tint: 0x9aa0b8, roughness: 0.2, metalness: 0.65 }, // rainbow-on-black gloss
stream: { tint: 0xffffff, roughness: 0.85, metalness: 0.15 }, // identity — the default
fortress: { tint: 0xffffff, roughness: 0.95, metalness: 0.0 }, // blinding matte white
};
/**
* machine id -> era, via the tech that unlocks it. Anything unmapped is starting
* equipment (no tech gates it) and takes DEFAULT_ERA.
*
* TIE-BREAK: the FIRST tech to claim an id wins, matching LANE-UI's `indexTech`
* (`src/ui/research.ts`: "First tech to claim an id wins"). Two techs claiming one
* machine is a data bug either way, but we must both blame the same tech otherwise
* the build bar padlocks a machine in one era while the ghost's era tell names another,
* on the same screen. Round 4's NOTES claimed this lane already matched them; it did
* not this used to be an unconditional `set`, i.e. LAST wins. Latent so far only
* because `data/tech.json` currently has zero duplicate unlocks across its 25 techs.
*/
export function eraIndex(data: GameData): Map<string, Era> {
const out = new Map<string, Era>();
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) if (machines.has(u) && !out.has(u)) out.set(u, t.era);
}
return out;
}
/** Skin a chassis material in place. Accents must never be passed here. */
export function applyEraSkin(mat: THREE.MeshStandardMaterial, era: Era): void {
const skin = ERA_SKIN[era] ?? ERA_SKIN[DEFAULT_ERA];
mat.color.multiply(new THREE.Color(skin.tint));
mat.roughness = skin.roughness;
mat.metalness = skin.metalness;
}
/**
* Era read as a SIGNAL rather than a surface used by the locked-build ghost to say
* *which* era is holding a machine back. `ERA_SKIN.tint` can't do this job: it's a
* multiplier tuned to leave DATA's chassis alone (stream is literally white), so three
* of the five eras would tell you nothing. These are chosen to be told apart at a
* glance, in codex order.
*/
export const ERA_TELL: Record<Era, number> = {
reel: 0xd8a24a, // brass
broadcast: 0x5fd08a, // phosphor
disc: 0x6fd7ff, // iridescent
stream: 0xdfe6f2, // flat plastic white
fortress: 0xf2f7ff, // blinding
};

View File

@ -1,19 +1,37 @@
/**
* LANE-RENDER build ghost. Shows the selected machine at the hovered tile:
* cyan when it fits, red when its footprint collides with something already placed.
* LANE-RENDER build ghost, remove-mode hover, and cooling-aura annotation.
*
* Occupancy is read straight off the snapshot's entity footprints the sim still gets
* the final say when the place command lands; this is only the player's preview.
* The ghost answers one question: what happens if I click here?
* cyan it fits
* red its footprint collides with something already placed
* amber the machine exists but research hasn't unlocked it yet
*
* That third state is why this reads `research`: the sim silently drops a `place` for a
* locked def, so without a tell the click just does nothing and reads as a bug. Amber is
* deliberately not red "not yet" and "not here" are different sentences.
*
* Occupancy and lock state are previews only; the sim still gets the final say when the
* command lands.
*
* v4: the mode comes from `setGhost`'s 4th argument. The old `'__remove'` def sentinel
* still arrives from main.ts as a bridge and is simply ignored mode is the truth.
*/
import * as THREE from 'three';
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import type {
Dir, EntityState, GameData, MachineDef, SimSnapshot, TechDef, Vec2,
} from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, tileKey, tilesOf, yawFor } from './coords';
import { centerOf, rotatedFootprint, tileKey, tilesOf, yawFor } from './coords';
import { idleOf, type IdleFn } from './registry';
import { WORLD_HALF } from './camera';
import { HEIGHT_BY_KIND } from './palette';
import { RemoveHover } from './remove';
import { CoolAura } from './aura';
import { ERA_TELL } from './era';
const VALID = 0x3fd4ff;
const INVALID = 0xff3b30;
const LOCKED = 0xffb02e;
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
@ -27,6 +45,27 @@ function ghostMaterial(color: number): THREE.MeshStandardMaterial {
});
}
/** A padlock, floating over a build you can't have yet. Tinted by the era gating it. */
function makePadlock(): { obj: THREE.Group; tint: (c: number) => void } {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color: LOCKED, emissive: LOCKED, emissiveIntensity: 1.4,
transparent: true, opacity: 0.9, depthWrite: false,
});
const body = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.2, 0.12), mat);
const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.08, 0.025, 6, 12, Math.PI), mat);
shackle.position.y = 0.1;
g.add(body, shackle);
g.renderOrder = 12;
return {
obj: g,
tint: (c: number) => {
mat.color.setHex(c);
mat.emissive.setHex(c);
},
};
}
export class GhostLayer {
readonly group = new THREE.Group();
private obj: THREE.Object3D | null = null;
@ -38,22 +77,76 @@ export class GhostLayer {
private def: MachineDef | null = null;
private pos: Vec2 | null = null;
private dir: Dir = 0;
private occupied = new Set<number>();
private removing = false;
private occupied = new Map<number, EntityState>();
private matValid = ghostMaterial(VALID);
private matInvalid = ghostMaterial(INVALID);
private matLocked = ghostMaterial(LOCKED);
readonly remove = new RemoveHover();
/** Lives outside `group` so it can annotate a hovered machine with nothing selected. */
readonly aura = new CoolAura();
private padlock = makePadlock();
/** machine id -> the tech that gates it (v4 rule: unreferenced ids are always open). */
private lockedBy = new Map<string, TechDef>();
/** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
private anyCoolers = false;
/** True while the inspector owns the aura, so hover logic leaves it alone. */
private inspectAura = false;
/** Selection ring drawn under the inspected machine. */
private selectRing: THREE.Mesh;
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
constructor(
private registry: AssetRegistry,
private defs: Map<string, MachineDef>,
data: GameData,
) {
this.group.name = 'ghost';
this.group.renderOrder = 10;
this.group.add(this.remove.group);
this.padlock.obj.visible = false;
this.group.add(this.padlock.obj);
// Selection ring: a flat outline under the inspected machine. Lives outside `group`
// so it survives the ghost being hidden (inspect has no hovered tile).
this.selectRing = new THREE.Mesh(
new THREE.RingGeometry(0.62, 0.72, 4, 1, Math.PI / 4),
new THREE.MeshBasicMaterial({
color: VALID, transparent: true, opacity: 0.4, depthWrite: false, side: THREE.DoubleSide,
}),
);
this.selectRing.rotation.x = -Math.PI / 2;
this.selectRing.visible = false;
this.selectRing.renderOrder = 9;
this.aura.group.add(this.selectRing);
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) {
// First tech to claim an id wins — matching LANE-UI's `indexTech`. Two techs
// unlocking one id is a data bug either way, but we must both blame the same one.
if (machines.has(u) && !this.lockedBy.has(u)) this.lockedBy.set(u, t);
}
}
this.anyCoolers = data.machines.some((m) => m.coolRadius);
}
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
const def = defId ? this.defs.get(defId) ?? null : null;
this.def = def;
this.pos = def && pos ? pos : null;
set(defId: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build'): void {
this.removing = mode === 'remove';
this.pos = pos;
this.dir = dir;
if (!def || !this.pos) {
if (this.removing) {
this.def = null; // the sentinel def is ignored: mode is the truth
if (this.obj) this.obj.visible = false;
this.padlock.obj.visible = false;
this.group.visible = true;
return;
}
this.remove.hide();
const def = defId ? this.defs.get(defId) ?? null : null;
this.def = def;
if (!def || !pos) {
this.group.visible = false;
return;
}
@ -62,6 +155,7 @@ export class GhostLayer {
this.group.visible = false;
return;
}
if (this.obj) this.obj.visible = true;
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
this.rebuild(def);
this.objDef = def.id;
@ -100,24 +194,69 @@ export class GhostLayer {
this.obj = null;
}
update(snap: SimSnapshot, timeSec: number): void {
if (!this.group.visible || !this.def || !this.pos || !this.obj) return;
/**
* v4 gating rule, verbatim: "a machine/recipe id that appears in ANY TechDef.unlocks
* is locked until that tech completes; ids referenced by no tech are always available."
* Returns the gating tech, or null if it's buildable.
*
* Absent `research` means NOTHING has completed, so everything gated reads locked. My
* instinct was the opposite treat a missing field as "don't lie" but that's the
* `scrammed` lesson misapplied: there the field's absence meant the sim hadn't wired a
* behaviour, here the rule is satisfiable from data alone and the contract says
* "until that tech completes". LANE-UI's `research.ts` reads it the same way, and the
* build bar and the ghost disagreeing about what's buildable would be worse than
* either answer.
*/
private lockOf(defId: string, snap: SimSnapshot): TechDef | null {
const tech = this.lockedBy.get(defId);
if (!tech) return null;
return snap.research?.unlocked.includes(tech.id) ? null : tech;
}
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.add(tileKey(t.x, t.y));
update(snap: SimSnapshot, timeSec: number, inspected?: number | null): void {
// The inspected machine is highlighted whether or not the pointer is anywhere near
// it — the inspector is open until it's closed.
this.updateInspect(snap, timeSec, inspected ?? null);
if (!this.pos) {
if (!this.inspectAura) this.aura.hide();
this.remove.hide();
this.group.visible = false;
return;
}
const needOccupancy = this.removing || !!this.def || this.anyCoolers;
if (needOccupancy) {
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
}
}
this.updateAura(timeSec);
if (this.removing) {
this.updateRemove(timeSec);
return;
}
if (!this.def || !this.obj) {
this.group.visible = false;
return;
}
const want = tilesOf(this.pos, this.def, this.dir);
const ok = want.every(
const fits = want.every(
(t) =>
!this.occupied.has(tileKey(t.x, t.y)) &&
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
t.y >= -WORLD_HALF && t.y < WORLD_HALF,
);
const mat = ok ? this.matValid : this.matInvalid;
const lock = this.lockOf(this.def.id, snap);
// Locked outranks collision: "you don't have this yet" is the more useful sentence,
// and a locked click does nothing wherever you aim it.
const mat = lock ? this.matLocked : fits ? this.matValid : this.matInvalid;
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.material = mat;
@ -127,5 +266,92 @@ export class GhostLayer {
this.obj.position.y += 0.02;
this.obj.rotation.y = yawFor(this.dir);
this.idle?.(timeSec);
if (lock) {
const h = HEIGHT_BY_KIND[this.def.kind] ?? 0.8;
this.padlock.obj.visible = true;
this.padlock.obj.position.copy(this.obj.position);
this.padlock.obj.position.y = h + 0.5 + Math.sin(timeSec * 2) * 0.05;
this.padlock.obj.rotation.y = timeSec * 0.8;
this.padlock.tint(ERA_TELL[lock.era] ?? LOCKED); // which era is holding it back
} else {
this.padlock.obj.visible = false;
}
}
/**
* v5: the inspected machine gets a selection ring, and if it cools its coverage
* rectangle. This is the "placed-and-selected" case round 4 couldn't reach: the
* inspected entity now arrives through the typed selection.
*/
private updateInspect(snap: SimSnapshot, timeSec: number, inspected: number | null): void {
this.inspectAura = false;
if (inspected === null) {
this.selectRing.visible = false;
return;
}
const e = snap.entities.find((x) => x.id === inspected);
const def = e ? this.defs.get(e.def) : undefined;
if (!e || !def) {
this.selectRing.visible = false;
return;
}
const f = rotatedFootprint(def, e.dir);
const c = centerOf(e.pos, def, e.dir);
this.selectRing.visible = true;
this.selectRing.position.set(c.x, 0.04, c.z);
this.selectRing.scale.set(f.x + 0.5, f.y + 0.5, 1);
(this.selectRing.material as THREE.MeshBasicMaterial).opacity =
0.35 + Math.sin(timeSec * 3) * 0.12;
if (def.coolRadius) {
this.aura.show(c.x, c.z, f.x, f.y, def.coolRadius, timeSec);
this.inspectAura = true; // claim the aura so hover logic doesn't hide it
}
}
/**
* Ring the cooler being placed, or the one under the cursor. Placing wins: while you're
* holding a cooler the question is where its coverage lands, not what's already there.
*/
private updateAura(timeSec: number): void {
if (this.inspectAura) return; // the inspector owns the aura this frame
if (!this.anyCoolers || !this.pos) {
this.aura.hide();
return;
}
if (!this.removing && this.def?.coolRadius) {
const f = rotatedFootprint(this.def, this.dir);
const c = centerOf(this.pos, this.def, this.dir);
this.aura.show(c.x, c.z, f.x, f.y, this.def.coolRadius, timeSec);
return;
}
const under = this.occupied.get(tileKey(this.pos.x, this.pos.y));
const udef = under ? this.defs.get(under.def) : undefined;
if (under && udef?.coolRadius) {
const f = rotatedFootprint(udef, under.dir);
const c = centerOf(under.pos, udef, under.dir);
this.aura.show(c.x, c.z, f.x, f.y, udef.coolRadius, timeSec);
return;
}
this.aura.hide();
}
/**
* Highlight whatever machine sits under the cursor. Hovering bare ground shows
* nothing there is no honest way to demolish a hole.
*/
private updateRemove(timeSec: number): void {
const target = this.pos ? this.occupied.get(tileKey(this.pos.x, this.pos.y)) : undefined;
const def = target ? this.defs.get(target.def) : undefined;
if (!target || !def) {
this.remove.hide();
return;
}
// The whole machine lights up, not just the hovered tile: `remove` takes a position
// but kills the entity that owns it, and the hover should say so.
const f = rotatedFootprint(def, target.dir);
const c = centerOf(target.pos, def, target.dir);
this.remove.show(c.x, c.z, f.x, f.y, HEIGHT_BY_KIND[def.kind] ?? 0.8, timeSec);
}
}

View File

@ -1,15 +1,30 @@
/**
* LANE-RENDER Stream-Crust ground plane.
* LANE-RENDER Stream-Crust ground plane. THE FLOOR (round 5).
*
* A shader grid rather than GridHelper: resolution-independent antialiased lines,
* major lines every 8 tiles, faint scanline grain, and a dissolve at the world edge
* so the Bitstream fades into the void instead of ending on a hard rectangle.
* Round 1 shipped a grid over flat crust. That read as "the void with lines on it",
* which the review named: the game about place had no place. This makes the plane read
* as terrain without ever competing with belts and machines it's the stage, not the
* set. Three barely-there layers:
*
* - fbm tone variation: large, slow value-noise mottling so the crust isn't a flat
* fill. Amplitude is tiny on purpose; you feel it more than see it.
* - the grid, unchanged (it's the placement affordance and must stay legible).
* - a neutral rim: the crust darkens and dissolves into the void at the world edge.
*
* ERA HINTING lives in seams.ts, not here. The orders asked for strata whispers toward
* the world edges, and I first wrote exactly that a directional guess (sepia north,
* phosphor east...). Then DATA landed `data/seams.json`, whose doc says "RENDER: `era`
* skins the ground", and its layout has reel in BOTH the north-west and north-east and
* disc both north-centre and south-west. A directional guess would have contradicted the
* data on screen. So era is skinned per seam region, with a wide soft falloff so it still
* reads as a stratum whisper across the ground and the richest seams sit near the rim
* anyway, which is where the orders wanted the hinting to show up.
*/
import * as THREE from 'three';
import { WORLD } from './palette';
import { WORLD_HALF } from './camera';
const PLANE = WORLD_HALF * 5; // extends past the world; grid only drawn inside ±WORLD_HALF
const PLANE = WORLD_HALF * 5; // extends past the world; detail only drawn inside ±WORLD_HALF
const VERT = /* glsl */ `
varying vec2 vWorld;
@ -28,21 +43,52 @@ const FRAG = /* glsl */ `
uniform float uHalf;
varying vec2 vWorld;
// value noise + 3-octave fbm — cheap terrain mottle, no texture fetch.
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);
}
float fbm(vec2 p){
float s = 0.0, a = 0.5;
for (int i=0;i<3;i++){ s += a*vnoise(p); p*=2.03; a*=0.5; }
return s;
}
// Antialiased grid: distance to the nearest cell border, in pixels.
float gridMask(vec2 coord, float width) {
float gridMask(vec2 coord, float width){
vec2 g = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
return 1.0 - min(min(g.x, g.y) / width, 1.0);
}
void main() {
vec3 col = uBase;
void main(){
// --- terrain tone: fbm mottle, tiny amplitude ---
float m = fbm(vWorld * 0.09);
vec3 col = uBase * (0.86 + m * 0.28); // ±~14% brightness swells
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // fine crust grain (kept from R1)
// --- the crust cools toward the rim: older, deeper, less lit ---
vec2 n = clamp(vWorld / uHalf, -1.0, 1.0);
float rim = smoothstep(0.55, 1.0, max(abs(n.x), abs(n.y)));
col *= 1.0 - rim * 0.35 * (0.6 + m * 0.8); // fbm-broken so it reads as sediment
// --- grid (unchanged — the placement affordance) ---
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
col = mix(col, uMajor, gridMask(vWorld / 8.0, 1.0) * 0.9);
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // crust grain
// --- world-edge dissolve into the void ---
vec2 d = abs(vWorld) / uHalf;
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
gl_FragColor = vec4(mix(uBase, col, inside), 1.0);
// COLOR MANAGEMENT — load-bearing, not boilerplate. THREE.Color.setHex() converts
// sRGB -> the linear working space, but a raw ShaderMaterial gets none of the output
// includes, so without this the linear value goes to the framebuffer untouched and
// the floor renders ~6x too dark: #0c0a15 measured as RGB(1,1,2). Every built-in
// material does this via the same chunk.
#include <colorspace_fragment>
}
`;

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';
@ -15,14 +17,39 @@ import { BeltLayer } from './belts';
import { BeltItemLayer } from './beltitems';
import { GhostLayer } from './ghost';
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
import { Showroom, showroomEnabled } from './showroom';
import { BeltTopology } from './topology';
import { SeamLayer, loadSeams } from './seams';
import { RelicLayer } from './relics';
import { WildlifeLayer, harassedIn } from './wildlife';
import { Juice } from './juice';
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;
const AMBIENT_INTENSITY = 1.1;
/** ACES rolls mid-tones down hard; this lifts the factory back off a near-black ground. */
const BASE_EXPOSURE = 1.35;
/**
* How much of the new environment (env.ts) the WORLD's own materials take belts, belt
* items, placeholders, ghosts, wildlife, the cabinet. Deliberately gentle: those were
* art-directed across six rounds under no IBL at all, and relighting them wholesale is
* a regression dressed as a fix. This is a dusting for coherence so a placeholder and a
* GLB read as standing in the same room. Hot-swapped GLBs opt into a much stronger dose
* of their own (`GLB_ENV_INTENSITY` in registry.ts) because they are the metals see
* the arithmetic at the top of env.ts.
*
* Measured while tuning, and worth writing down: `AmbientLight` intensity is nearly
* inert in this scene. Sweeping it 0.55 -> 1.1 moved a placeholder body by ~7% and the
* high-metalness GLBs by <1%, because ambient only feeds the diffuse term that
* metalness has already zeroed. Left at its round-1 value rather than "compensated".
*/
const ENV_WORLD_INTENSITY = 0.5;
export function createRenderer(): Renderer {
let gl: THREE.WebGLRenderer;
@ -33,8 +60,24 @@ export function createRenderer(): Renderer {
let belts: BeltLayer;
let cargo: BeltItemLayer;
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;
let dev: DevScene | null = null;
let showroom: Showroom | null = null;
let host: HTMLElement;
let defs: Map<string, MachineDef>;
/** Shipment-flash detection: last frame's total shipped count, and the shipper tiles. */
let lastShipped = -1;
const shipperCentres: Vec2[] = [];
const itemColors = new Map<string, number>();
const lastPerItem = new Map<string, number>();
/** True on any dev scaffold page (devscene/showroom): enables stats + the debug hook. */
let debug = false;
const topo = new BeltTopology();
const raycaster = new THREE.Raycaster();
@ -46,10 +89,188 @@ export function createRenderer(): Renderer {
/** Dev-only frame-cost sampler. Measures CPU ms per frame, which unlike frame
* RATE stays honest even when the tab is backgrounded and rAF is throttled. */
const stats = { ms: 0, avg: 0, frames: 0 };
/**
* Round 7 boot timing, so "GLBs no longer block first paint" is a number and not a
* vibe. Dev-only, and every write sits inside a literal `import.meta.env.DEV` so
* Rollup folds the branches out of the bundle (round 3 learned that the hard way).
*
* `readyAt` is the honest one: main.ts awaits `renderer.init()` before it starts the
* frame loop, so the moment init returns is the earliest a frame can exist. `firstAt`
* is the real first `gl.render`, but rAF is throttled hard in a background tab, so
* read it only on a focused window.
*/
const perf = { initMs: 0, readyAt: 0, firstAt: 0 };
let firstFrameDone = false;
/**
* Which entity the inspector has open, if any.
*
* BRIDGE: `Renderer` has no inspect channel `setGhost`'s mode is only build|remove,
* and main.ts calls it with 'build' during inspect, so nothing about the inspected
* entity reaches this lane. The typed selection DOES carry it (`{mode:'inspect',
* entity}`), and the bus is the designated cross-lane global, so we read it here.
* Same shape as the '__remove' sentinel bridge: it works, it's documented on both
* sides, and it wants a real contract hook. Filed in NOTES.
*/
function inspectedEntity(): number | null {
const bus = (window as unknown as { __fktryBus?: { selection?: () => unknown } }).__fktryBus;
const sel = bus?.selection?.() as { mode?: string; entity?: number } | null | undefined;
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. 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;
let hottest: string | null = null;
let best = 0;
for (const k in view.shippedTotal) {
const n = view.shippedTotal[k];
total += n;
const prev = lastPerItem.get(k) ?? 0;
if (n - prev > best) { best = n - prev; hottest = k; }
lastPerItem.set(k, n);
}
if (lastShipped < 0) { lastShipped = total; return; } // first frame: prime, don't flash
if (total > lastShipped) {
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;
const rect = gl.domElement.getBoundingClientRect();
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, rig.camera);
return raycaster.intersectObjects(breakroom.clickable, false).length > 0;
}
/**
* The cabinet is a world object, so it gets world-object interaction: pointer cursor on
* hover, click to open. Listeners live on the CANVAS and stop propagation on a hit, so
* main.ts's #game handler never sees the click otherwise clicking the cabinet while
* holding a machine would also place one on the tile behind it.
*/
function installCabinetPointer(container: HTMLElement): void {
const canvas = gl.domElement;
canvas.addEventListener('pointermove', (e) => {
const over = cabinetHit(e.clientX, e.clientY);
// Only claim the cursor when nothing else is driving it (build/remove modes have
// their own affordance via the ghost).
canvas.style.cursor = over ? 'pointer' : '';
if (over) container.title = 'GLYTCH';
else if (container.title) container.title = '';
});
canvas.addEventListener(
'pointerdown',
(e) => {
if (e.button !== 0 || !cabinetHit(e.clientX, e.clientY)) return;
e.stopPropagation();
e.preventDefault();
window.open('./match.html', '_blank', 'noopener');
},
{ capture: true },
);
}
return {
async init(container: HTMLElement, data: GameData) {
const t0 = performance.now();
defs = new Map(data.machines.map((m) => [m.id, m]));
host = container;
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
@ -88,54 +309,157 @@ export function createRenderer(): Renderer {
scene.add(fill);
scene.add(new THREE.AmbientLight(0x8f8fbf, AMBIENT_INTENSITY));
// THE ENVIRONMENT (round 7 root-cause fix). Until now `scene.environment` was
// null, which for a metal is not "no reflections" but "reflects the void" — and
// 16 of the 27 shipped GLBs are metalness >= 0.5, where diffuse is nearly gone
// and indirect specular is the whole material. See env.ts for the arithmetic.
// Built from the real light directions so the reflection agrees with the shading.
const env = createEnvironment(gl, key.position, fill.position);
scene.environment = env;
scene.environmentIntensity = ENV_WORLD_INTENSITY;
rig = new CameraRig(container, gl.domElement);
registry = new AssetRegistry();
await registry.init(data);
// Hot-swapped GLBs get their own, much stronger, share of the environment — they
// are the metals. Must be handed over BEFORE init kicks the first probe off.
registry.setEnvironment(env);
// Synchronous: placeholders exist the instant this returns, GLBs hot-swap in.
registry.init(data);
juice = new Juice();
belts = new BeltLayer(registry, defs, topo);
entities = new EntityLayer(registry, defs);
entities = new EntityLayer(registry, defs, juice);
cargo = new BeltItemLayer(data, topo);
ghost = new GhostLayer(registry, defs);
scene.add(belts.group, entities.group, cargo.group, ghost.group);
ghost = new GhostLayer(registry, defs, data);
relics = new RelicLayer(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, correction.group, juice.group,
);
// THE FLOOR: seam regions from data — era skins the crust, the ore glints.
seamLayer = new SeamLayer(loadSeams(), data);
scene.add(seamLayer.mesh);
// THE BREAK ROOM: the GLYTCH cabinet, a short walk south-east of the start zone.
// Position is chosen to land in CLICKABLE screen space at the default framing —
// at (-5,-5) it rendered underneath `#screen`, the decorative overlay that still
// takes pointer events, so the cabinet was both invisible and unclickable (see the
// B8 picking findings in NOTES).
breakroom = new BreakRoom(6, 12);
scene.add(breakroom.group);
installCabinetPointer(container);
// Item colours for the shipment flash, and the shipper tiles (recomputed on demand).
for (const it of data.items) itemColors.set(it.id, new THREE.Color(it.color).getHex());
debug = devSceneEnabled() || showroomEnabled();
// B8 review finding: the start zone must clear the build bar. The bar sits at
// screen-bottom, and in this iso rig screen-down = world +x/+z, so biasing the
// camera target south-east lifts world origin into the upper play area where a
// fresh game's first machines get placed. (Showroom/devscene set their own frame.)
if (!showroomEnabled()) rig.frame(3, 6, 15);
if (showroomEnabled()) {
showroom = new Showroom(data, registry);
scene.add(showroom.decor);
showroom.mountLabels(container);
rig.frame(showroom.view.x, showroom.view.z, showroom.view.zoom);
console.info(
`[render] showroom: ${data.machines.length} machines, ${data.items.length} items —` +
' drop a GLB into public/assets/models/<asset>.glb and it lands here in ~10s',
);
}
if (devSceneEnabled()) {
const d = new DevScene(data, devSceneStress());
if (d.usable) {
dev = d;
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
// Dev-only introspection hook (devscene builds only). Never touched in prod.
(window as unknown as { __render: unknown }).__render = {
scene, camera: rig.camera, stats, gl, dev, topo, cargo, entities,
};
}
}
// The literal `import.meta.env.DEV` is what makes this disappear from the bundle:
// Vite substitutes `false` and Rollup drops the branch. Testing a `debug` VARIABLE
// here instead left the whole hook — internals list and all — as dead code in
// dist/. Keep the check inline; verified by grepping the bundle.
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); },
};
}
const onResize = () => {
gl.setSize(container.clientWidth, container.clientHeight);
rig.resize();
};
addEventListener('resize', onResize);
new ResizeObserver(onResize).observe(container);
if (import.meta.env.DEV) {
perf.readyAt = performance.now();
perf.initMs = perf.readyAt - t0;
(window as unknown as { __fktryPerf: unknown }).__fktryPerf = perf;
}
},
render(snap: SimSnapshot, alpha: number) {
if (!gl) return;
const t0 = dev ? performance.now() : 0;
const dt = clock.getDelta();
const t0 = debug ? performance.now() : 0;
// Clamp the frame delta. A backgrounded tab, an alt-tab, a GC pause or the first
// frame after load all hand back a delta of SECONDS (measured 63.7s), which
// integrates particles to infinity and kills every effect whose lifetime is
// shorter than the hitch — juice simply never appeared. main.ts clamps the sim's
// accumulator for the same reason; this is the renderer's half.
const dt = Math.min(0.05, clock.getDelta());
const t = clock.elapsedTime;
// Dev scaffold yields the instant the real sim has anything to show.
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
// The showroom is a standalone page, so it owns the frame outright. The devscene
// is only a stand-in and yields the instant the real sim has anything to show.
let view = snap;
if (showroom) {
view = showroom.snapshot(snap.tick);
showroom.frame(t);
} else if (dev && snap.entities.length === 0) {
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);
cargo.sync(view, alpha, t);
ghost.update(view, 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, relicsFound);
wildlife.sync(view.wildlife, t);
seamLayer?.frame(t);
breakroom.frame(t);
shipmentFlash(view);
juice.update(dt, rig.camera);
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
// exposure so emissives dim too — a brownout that left the glow at full would
@ -148,8 +472,14 @@ export function createRenderer(): Renderer {
gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
gl.render(scene, rig.camera);
showroom?.updateLabels(rig.camera, host);
if (dev) {
if (import.meta.env.DEV && !firstFrameDone) {
firstFrameDone = true;
perf.firstAt = performance.now();
}
if (debug) {
stats.ms = performance.now() - t0;
stats.frames++;
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
@ -169,8 +499,17 @@ export function createRenderer(): Renderer {
return { x, y };
},
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
ghost?.set(def, pos, dir);
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);
},
};
}

159
fktry/src/render/juice.ts Normal file
View File

@ -0,0 +1,159 @@
/**
* LANE-RENDER juice. The difference between placing a machine and a machine appearing.
*
* The renderer never sees events (render() gets only the snapshot), so every cue here is
* derived from frame-to-frame deltas: an entity that wasn't there last frame was placed;
* one that vanished was demolished; a rise in `shippedTotal` is a shipment. EntityLayer
* owns the meshes so it does the scale-pop itself and calls in for the puff/burst; the
* shipment flash is driven from index.ts.
*
* One InstancedMesh particle pool for the whole game cheap, instanced, everywhere.
*/
import * as THREE from 'three';
const MAX = 512;
const GRAVITY = -6;
interface P {
x: number; y: number; z: number;
vx: number; vy: number; vz: number;
life: number; ttl: number;
size: number;
r: number; g: number; b: number;
}
export class Juice {
readonly group = new THREE.Group();
private mesh: THREE.InstancedMesh;
private pool: P[] = [];
private live: P[] = [];
private dummy = new THREE.Object3D();
private color = new THREE.Color();
constructor() {
this.group.name = 'juice';
const geo = new THREE.PlaneGeometry(1, 1);
const mat = new THREE.MeshBasicMaterial({
transparent: true, depthWrite: false, side: THREE.DoubleSide,
blending: THREE.AdditiveBlending, vertexColors: true,
});
this.mesh = new THREE.InstancedMesh(geo, mat, MAX);
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mesh.frustumCulled = false;
this.mesh.count = 0;
// per-instance colour
this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX * 3), 3);
this.group.add(this.mesh);
for (let i = 0; i < MAX; i++) {
this.pool.push({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, life: 0, ttl: 1, size: 0.1, r: 1, g: 1, b: 1 });
}
}
private emit(
x: number, z: number, n: number, spread: number, up: number,
size: number, ttl: number, col: number,
): void {
this.color.setHex(col);
for (let i = 0; i < n; i++) {
const p = this.pool.pop();
if (!p) break; // pool exhausted — drop the rest rather than allocate
const a = Math.random() * Math.PI * 2;
const s = Math.random() * spread;
p.x = x; p.y = 0.2; p.z = z;
p.vx = Math.cos(a) * s;
p.vz = Math.sin(a) * s;
p.vy = up * (0.6 + Math.random() * 0.8);
p.life = 0;
p.ttl = ttl * (0.7 + Math.random() * 0.6);
p.size = size * (0.7 + Math.random() * 0.6);
p.r = this.color.r; p.g = this.color.g; p.b = this.color.b;
this.live.push(p);
}
}
/** Placement: a soft ring of dust kicked outward. Pairs with EntityLayer's scale-pop. */
puff(cx: number, cz: number): void {
this.emit(cx, cz, 14, 2.2, 1.5, 0.18, 0.5, 0xbfb3a0);
}
/** Demolition: a sharper, brighter burst. */
burst(cx: number, cz: number): void {
this.emit(cx, cz, 22, 4.0, 3.0, 0.22, 0.55, 0xff6a3a);
}
/** Shipment: a quick upward flash of the shipped item's colour at the uplink. */
shipFlash(cx: number, cz: number, col: number): void {
this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col);
}
/** Excavation (relicFound): the fossil surfacing a slow, tall golden geyser that
* "sings" up out of the crust. Longer-lived and higher than any other cue. */
excavate(cx: number, cz: number): void {
this.emit(cx, cz, 30, 1.4, 7.5, 0.26, 1.1, 0xffcaa0);
this.emit(cx, cz, 14, 0.5, 4.0, 0.18, 1.4, 0xfff2d6); // a brighter inner column
}
/** The Correction's calling card: a small, infuriatingly TIDY green-white sparkle,
* left where a parity-mite just "corrected" your product. Few, clean, gentle. */
tidy(cx: number, cz: number): void {
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--) {
const p = this.live[i];
p.life += dt;
if (p.life >= p.ttl) {
this.live[i] = this.live[this.live.length - 1];
this.live.pop();
this.pool.push(p);
continue;
}
p.vy += GRAVITY * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (p.y < 0.05) { p.y = 0.05; p.vy *= -0.3; p.vx *= 0.6; p.vz *= 0.6; }
}
// write instances, billboarded to the camera, fading over life
const q = camera.quaternion;
for (let i = 0; i < this.live.length; i++) {
const p = this.live[i];
const k = 1 - p.life / p.ttl;
const s = p.size * (0.4 + k * 0.6);
this.dummy.position.set(p.x, p.y, p.z);
this.dummy.quaternion.copy(q);
this.dummy.scale.setScalar(s);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
this.mesh.setColorAt(i, this.color.setRGB(p.r * k, p.g * k, p.b * k));
}
this.mesh.count = this.live.length;
this.mesh.instanceMatrix.needsUpdate = true;
if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
}
}

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

@ -51,6 +51,7 @@ export const BODY_BY_KIND: Record<MachineKind, number> = {
power: 0x45434f,
buffer: 0x3a3f48,
shipper: 0x4a4652,
lab: 0x3d3a4e, // research bench chassis (v4 kind; placeholder until art-directed)
};
/** Fallback accent when a machine runs no recipes (belts, power, shippers). */
@ -62,6 +63,7 @@ export const ACCENT_BY_KIND: Record<MachineKind, number> = {
power: 0xffb03f, // serene LED face
buffer: 0x7fffd4,
shipper: 0x3fd4ff, // uplink to THE SCREEN
lab: 0xb0a0ff, // research glow (v4 kind; placeholder)
};
/** Body height in world units. Silhouette differentiation on a flat iso grid. */
@ -73,6 +75,28 @@ export const HEIGHT_BY_KIND: Record<MachineKind, number> = {
power: 0.95,
buffer: 0.7,
shipper: 1.3,
lab: 1.1, // v4 kind; placeholder
};
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

@ -0,0 +1,371 @@
/**
* LANE-RENDER source-level tests for the registry's pure maths.
*
* This lane shipped six rounds with zero tests, on the reasoning that a renderer needs
* eyes. Most of it does. But three things in here are arithmetic, they are what a bad
* asset export or a data edit silently breaks, and none of them need a GPU:
*
* 1. fit-to-footprint normalisation round 6's deploy-blocking regression was a
* scale bug, and the scale it collapsed is computed right here
* 2. accent derivation precedence `def.accent` > derived-from-outputs > kind table,
* a rule that has been re-litigated in NOTES three rounds running
* 3. era lookup cross-lane: the padlock LANE-UI draws and the era tell this lane
* draws must name the same tech
*
* Node environment on purpose. happy-dom returns null for `getContext('webgl')`, so
* anything that needs a renderer is out of scope here and stays an eyeball check in
* `?showroom`. Everything below runs on plain three.js maths objects.
*/
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { accentFor, bodyFor, normaliseGLB } from './registry';
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, lightnessOf } from './palette';
import { DEFAULT_ERA, ERA_SKIN, applyEraSkin, eraIndex } from './era';
import { ENV_H, ENV_W, dirAt, equirectRadiance } from './env';
import type { GameData, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../contracts';
// ------------------------------------------------------------------ fixtures
function machine(over: Partial<MachineDef> & { id: string }): MachineDef {
return {
kind: 'crafter',
name: over.id.toUpperCase(),
codex: `4:${over.id}`,
asset: over.id,
footprint: { x: 2, y: 2 },
recipes: [],
powerDraw: 0,
...over,
};
}
function item(id: string, color: string): ItemDef {
return { id, name: id.toUpperCase(), codex: `2:${id}`, tier: 0, color };
}
function recipe(id: string, out: string): RecipeDef {
return { id, machine: 'm', inputs: {}, outputs: { [out]: 1 }, ticks: 30, bandwidth: 1 };
}
function data(over: Partial<GameData>): GameData {
return { items: [], machines: [], recipes: [], tech: [], commissions: [], ...over };
}
/** A box of an exact size, so the fit maths has something unambiguous to chew on. */
function box(w: number, h: number, d: number): THREE.Object3D {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d));
m.position.set(0, h / 2, 0); // sitting on the ground, like an export usually is
return m;
}
function worldSize(o: THREE.Object3D): THREE.Vector3 {
o.updateMatrixWorld(true);
return new THREE.Box3().setFromObject(o).getSize(new THREE.Vector3());
}
function worldBox(o: THREE.Object3D): THREE.Box3 {
o.updateMatrixWorld(true);
return new THREE.Box3().setFromObject(o);
}
// ------------------------------------------------------------------ normalisation
describe('normaliseGLB — fit to footprint', () => {
it('scales an arbitrary export down to 92% of its footprint', () => {
// MODELBEAST exports at whatever scale it likes; a 10-unit mesh on a 2x2 machine
// must land at 2 * 0.92 = 1.84 wide, not at 10.
const out = normaliseGLB(box(10, 4, 10), machine({ id: 'big', footprint: { x: 2, y: 2 } }), []);
const s = worldSize(out);
expect(s.x).toBeCloseTo(1.84, 5);
expect(s.z).toBeCloseTo(1.84, 5);
expect(s.y).toBeCloseTo(0.736, 5); // uniform scale: 4 * (1.84 / 10)
});
it('scales UP a tiny export just as happily', () => {
const out = normaliseGLB(box(0.02, 0.02, 0.02), machine({ id: 'tiny' }), []);
expect(worldSize(out).x).toBeCloseTo(1.84, 5);
});
it('is uniform and fits the TIGHTER axis, so nothing overhangs its tiles', () => {
// 3x2 footprint => targets 2.76 wide x 1.84 deep. A 4-wide, 2-deep mesh is limited
// by WIDTH (2.76/4 = 0.69), which is tighter than depth (1.84/2 = 0.92).
const out = normaliseGLB(box(4, 1, 2), machine({ id: 'wide', footprint: { x: 3, y: 2 } }), []);
const s = worldSize(out);
expect(s.x).toBeCloseTo(2.76, 5); // 4 * 0.69
expect(s.z).toBeCloseTo(1.38, 5); // 2 * 0.69 — under its 1.84 budget, not stretched
expect(s.x / 4).toBeCloseTo(s.z / 2, 6); // same factor on both axes: no distortion
});
it('never fits below a 0.4 floor, so a 1x1 asset is still visible', () => {
// Math.max(0.4, 1 * 0.92) = 0.92 for a 1x1 belt-sized machine.
const out = normaliseGLB(box(3, 3, 3), machine({ id: 'unit', footprint: { x: 1, y: 1 } }), []);
expect(worldSize(out).x).toBeCloseTo(0.92, 5);
});
it('sits the asset ON the ground and centres it on its tile', () => {
const src = box(6, 6, 6);
src.position.set(37, -12, -8); // an export authored miles from its own origin
const bb = worldBox(normaliseGLB(src, machine({ id: 'offset' }), []));
expect(bb.min.y).toBeCloseTo(0, 5); // base on the ground plane, never sunk or floating
expect((bb.min.x + bb.max.x) / 2).toBeCloseTo(0, 5);
expect((bb.min.z + bb.max.z) / 2).toBeCloseTo(0, 5);
});
it('survives a degenerate axis instead of emitting NaN', () => {
// A flat card (zero depth) must not produce a NaN or zero scale — a NaN transform
// silently removes the mesh from the frustum and the machine just is not there.
const flat = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); // zero-thickness on z
const out = normaliseGLB(flat, machine({ id: 'card' }), []);
expect(Number.isFinite(out.scale.x)).toBe(true);
expect(out.scale.x).toBeGreaterThan(0);
expect(Number.isFinite(out.position.y)).toBe(true);
});
it('wraps an ANIMATED asset so the mixer and the fit maths cannot fight', () => {
// A scaled/offset root would fight root-motion tracks, so a clipped GLB gets a
// wrapper: mixer drives the inner node, wrapper does the normalising.
const clip = new THREE.AnimationClip('idle', 1, []);
const animated = normaliseGLB(box(2, 2, 2), machine({ id: 'rig' }), [clip]);
expect(animated.type).toBe('Group');
expect(animated.userData.fktryClips).toEqual([clip]);
expect(animated.scale.x).toBe(1); // the WRAPPER is unscaled; the child carries it
expect(animated.children[0].scale.x).toBeCloseTo(0.92, 5);
const still = normaliseGLB(box(2, 2, 2), machine({ id: 'static' }), []);
expect(still.type).toBe('Mesh'); // no clips: no wrapper, the mesh IS the root
expect(still.scale.x).toBeCloseTo(0.92, 5);
});
it('perches the accent beacon only when an accent is supplied', () => {
// The instanceable path (belts) passes no accent: an instanced belt has no room for
// a beacon and no accent to show.
const withBeacon = normaliseGLB(box(2, 2, 2), machine({ id: 'm' }), [], 0xff00ff);
const beacons: THREE.Mesh[] = [];
withBeacon.traverse((o) => { if (o.userData.fktryBeacon) beacons.push(o as THREE.Mesh); });
expect(beacons).toHaveLength(1);
expect((beacons[0].material as THREE.MeshStandardMaterial).emissive.getHex()).toBe(0xff00ff);
// Perched ON TOP of the normalised asset, not buried inside it. The 2x2 fit puts
// the body's crown at 0.92, so the beacon must clear that.
expect(beacons[0].position.y).toBeGreaterThan(0.92);
expect(typeof withBeacon.userData.fktryIdle).toBe('function');
const bare = normaliseGLB(box(2, 2, 2), machine({ id: 'm' }), []);
let found = false;
bare.traverse((o) => { if (o.userData.fktryBeacon) found = true; });
expect(found).toBe(false);
});
it('casts and receives shadows on every mesh it touches', () => {
const out = normaliseGLB(box(2, 2, 2), machine({ id: 'm' }), []);
out.traverse((o) => {
if ((o as THREE.Mesh).isMesh && !o.userData.fktryBeacon) {
expect(o.castShadow).toBe(true);
expect(o.receiveShadow).toBe(true);
}
});
});
});
// ------------------------------------------------------------------ accent precedence
describe('accentFor — the ONE emissive element', () => {
const melt = item('melt', '#ff7a3f');
const ore = item('ore', '#1a1a22'); // MDAT ORE: near-black by design (codex)
const gd = data({
items: [melt, ore],
recipes: [recipe('r-melt', 'melt'), recipe('r-ore', 'ore')],
});
it('1. explicit art-direction wins outright, even when it is dark', () => {
// v4 rule: DATA choosing a dark accent is a DECISION. The legibility floor below
// exists for derived colours, which are incidental.
const dark = '#101018';
expect(lightnessOf(dark)).toBeLessThan(ACCENT_MIN_L); // the floor would have rejected it
const def = machine({ id: 'm', accent: dark, recipes: ['r-melt'] } as Partial<MachineDef> & { id: string });
expect(accentFor(def, gd)).toBe(new THREE.Color(dark).getHex());
});
it('2. otherwise the colour of what the machine MAKES', () => {
const def = machine({ id: 'm', recipes: ['r-melt'] });
expect(accentFor(def, gd)).toBe(new THREE.Color('#ff7a3f').getHex());
});
it('2b. a derived colour below the legibility floor falls through to the kind', () => {
// The seam extractor's CRT head would otherwise be a black panel on a black chassis.
const def = machine({ id: 'm', kind: 'extractor', recipes: ['r-ore'] });
expect(accentFor(def, gd)).toBe(ACCENT_BY_KIND.extractor);
});
it('2c. skips the too-dark output and takes the first legible one', () => {
const def = machine({ id: 'm', kind: 'extractor', recipes: ['r-ore', 'r-melt'] });
expect(accentFor(def, gd)).toBe(new THREE.Color('#ff7a3f').getHex());
});
it('3. no recipes at all -> the per-kind accent', () => {
for (const kind of Object.keys(ACCENT_BY_KIND) as MachineKind[]) {
expect(accentFor(machine({ id: 'm', kind }), gd)).toBe(ACCENT_BY_KIND[kind]);
}
});
it('ignores a recipe id that no recipe answers to, rather than throwing', () => {
const def = machine({ id: 'm', kind: 'shipper', recipes: ['nope'] });
expect(accentFor(def, gd)).toBe(ACCENT_BY_KIND.shipper);
});
it('does NOT read def.color — v3 ruled that the chassis', () => {
// The whole round-2 argument in NOTES, pinned: wiring `color` to the emissive makes
// every machine glow muddy neutral and deletes the two-material rule.
const def = machine({ id: 'm', kind: 'crafter', color: '#8a7a32' } as Partial<MachineDef> & { id: string });
expect(accentFor(def, gd)).toBe(ACCENT_BY_KIND.crafter);
expect(bodyFor(def)).toBe(new THREE.Color('#8a7a32').getHex());
});
});
describe('bodyFor — the grimy chassis', () => {
it('takes DATA\'s authored colour when there is one', () => {
expect(bodyFor(machine({ id: 'm', color: '#b9bec4' } as Partial<MachineDef> & { id: string })))
.toBe(new THREE.Color('#b9bec4').getHex());
});
it('falls back to the per-kind neutral', () => {
for (const kind of Object.keys(BODY_BY_KIND) as MachineKind[]) {
expect(bodyFor(machine({ id: 'm', kind }))).toBe(BODY_BY_KIND[kind]);
}
});
});
// ------------------------------------------------------------------ era lookup
describe('eraIndex — which decade skins a machine', () => {
const tech = (id: string, era: TechDef['era'], unlocks: string[]) =>
({ id, era, cost: {}, unlocks }) as TechDef;
const gd = data({
machines: [machine({ id: 'bottler' }), machine({ id: 'puller' }), machine({ id: 'starter' })],
tech: [
tech('disc-bottling', 'disc', ['bottler']),
tech('reel-telecine', 'reel', ['puller', 'a-recipe-not-a-machine']),
],
});
it('maps a machine to the era of the tech that unlocks it', () => {
const idx = eraIndex(gd);
expect(idx.get('bottler')).toBe('disc');
expect(idx.get('puller')).toBe('reel');
});
it('leaves starting equipment unmapped, so it takes the default era', () => {
expect(eraIndex(gd).has('starter')).toBe(false);
expect(eraIndex(gd).get('starter') ?? DEFAULT_ERA).toBe(DEFAULT_ERA);
});
it('ignores unlock ids that are not machines (recipes, upgrades)', () => {
expect(eraIndex(gd).has('a-recipe-not-a-machine')).toBe(false);
});
it('FIRST tech wins a contested machine — LANE-UI breaks the tie the same way', () => {
// Two techs claiming one machine is a data bug either way, but if this lane and
// `src/ui/research.ts` disagree, the build bar padlocks a machine in one era while
// the ghost's era tell names another, on the same screen. (This was genuinely
// wrong until round 7: an unconditional `set` meant LAST tech won here.)
const contested = data({
machines: [machine({ id: 'shared' })],
tech: [tech('t-reel', 'reel', ['shared']), tech('t-fortress', 'fortress', ['shared'])],
});
expect(eraIndex(contested).get('shared')).toBe('reel');
});
});
describe('applyEraSkin — chassis only', () => {
it('is a true identity for stream, so today\'s look is byte-for-byte unchanged', () => {
const mat = new THREE.MeshStandardMaterial({ color: 0x5e5148, roughness: 0.85, metalness: 0.15 });
applyEraSkin(mat, 'stream');
expect(mat.color.getHex()).toBe(new THREE.Color(0x5e5148).getHex());
expect(mat.roughness).toBe(ERA_SKIN.stream.roughness);
expect(mat.metalness).toBe(ERA_SKIN.stream.metalness);
});
it('multiplies the tint rather than replacing the chassis colour', () => {
const base = new THREE.Color(0x808080);
const mat = new THREE.MeshStandardMaterial({ color: base.getHex() });
applyEraSkin(mat, 'reel');
const expected = base.clone().multiply(new THREE.Color(ERA_SKIN.reel.tint));
expect(mat.color.r).toBeCloseTo(expected.r, 6);
expect(mat.color.b).toBeCloseTo(expected.b, 6);
expect(mat.color.b).toBeLessThan(mat.color.r); // sepia: blue pulled down
});
it('falls back to the default skin for an era it has never heard of', () => {
const mat = new THREE.MeshStandardMaterial({ color: 0xffffff });
applyEraSkin(mat, 'vhs' as unknown as TechDef['era']);
expect(mat.roughness).toBe(ERA_SKIN[DEFAULT_ERA].roughness);
});
});
// ------------------------------------------------------------------ the environment
describe('equirectRadiance — the procedural IBL (env.ts)', () => {
const key = new THREE.Vector3(18, 30, 12);
const fill = new THREE.Vector3(-14, 8, -16);
const rgb = equirectRadiance(key, fill);
const at = (i: number, j: number) => {
const o = (j * ENV_W + i) * 3;
return 0.2126 * rgb[o] + 0.7152 * rgb[o + 1] + 0.0722 * rgb[o + 2];
};
it('fills the whole equirect with finite, non-negative radiance', () => {
expect(rgb).toHaveLength(ENV_W * ENV_H * 3);
for (let k = 0; k < rgb.length; k++) {
expect(Number.isFinite(rgb[k])).toBe(true);
expect(rgb[k]).toBeGreaterThanOrEqual(0);
}
});
it('is a dark industrial room, not a sunny day', () => {
// Codex §8: base world in desaturated industrial neutrals. If this ever averages
// bright, every chassis in the game turns into a lightbulb.
let sum = 0;
for (let k = 0; k < rgb.length; k++) sum += rgb[k];
expect(sum / rgb.length).toBeLessThan(0.15);
});
it('puts the floor darker than the sky, with a horizon band between', () => {
const floor = at(0, 0); // row 0 = straight down
const sky = at(0, ENV_H - 1); // last row = straight up
const horizon = at(0, ENV_H / 2);
expect(floor).toBeLessThan(sky);
expect(horizon).toBeGreaterThan(sky); // the band metal edges catch is the brightest
expect(floor).toBeLessThan(0.03); // near-black, keyed off the crust
});
it('is cool-tinted overhead — the bitstream haze, not daylight', () => {
const o = ((ENV_H - 1) * ENV_W) * 3;
expect(rgb[o + 2]).toBeGreaterThan(rgb[o + 0]); // blue > red
});
it('puts its brightest blob where the KEY LIGHT actually is', () => {
// The whole point of passing the light directions in: a reflection that disagrees
// with the shading reads as a bug, not as lighting.
const k = key.clone().normalize();
let bestI = -1, bestJ = -1, best = -1;
for (let j = 0; j < ENV_H; j++) {
for (let i = 0; i < ENV_W; i++) {
const l = at(i, j);
if (l > best) { best = l; bestI = i; bestJ = j; }
}
}
const d = dirAt((bestI + 0.5) / ENV_W, (bestJ + 0.5) / ENV_H, new THREE.Vector3());
expect(d.dot(k)).toBeGreaterThan(0.97); // within ~14 degrees of the key direction
});
it('follows three.js\'s own equirect convention, so nothing lands mirrored', () => {
// equirect_uv.glsl: u = atan2(z, x) / 2PI + 0.5, v = asin(y) / PI + 0.5
const v = new THREE.Vector3();
expect(dirAt(0.5, 1, v).y).toBeCloseTo(1, 6); // v=1 -> straight up
expect(dirAt(0.5, 0, v).y).toBeCloseTo(-1, 6); // v=0 -> straight down
dirAt(0.5, 0.5, v);
expect(v.x).toBeCloseTo(1, 6); // u=0.5 -> +x on the horizon
dirAt(0.75, 0.5, v);
expect(v.z).toBeCloseTo(1, 6); // u=0.75 -> +z
});
});

View File

@ -15,11 +15,13 @@
*/
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import type { GameData, MachineDef } from '../contracts';
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
import { DEFAULT_ERA, applyEraSkin, eraIndex, type Era } from './era';
const MODEL_DIR = 'assets/models';
const POLL_MS = 10_000;
@ -76,15 +78,17 @@ function accentMat(color: number): THREE.MeshStandardMaterial {
* idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat
* a GLB has no such split, so there heat tints everything.
*/
function makePlaceholder(def: MachineDef, accent: number, body_: number): THREE.Object3D {
function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Era): THREE.Object3D {
const g = new THREE.Group();
const fw = Math.max(0.4, def.footprint.x * 0.92);
const fd = Math.max(0.4, def.footprint.y * 0.92);
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
const chassis = bodyMat(body_);
applyEraSkin(chassis, era); // chassis only — the accent is the resource, not the decade
const body = new THREE.Mesh(
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
bodyMat(body_),
chassis,
);
body.position.y = h / 2;
body.castShadow = body.receiveShadow = true;
@ -160,6 +164,29 @@ function makePlaceholder(def: MachineDef, accent: number, body_: number): THREE.
}) satisfies IdleFn;
break;
}
case 'lab': {
// A research bench: low slab, and the "bottled artifact triptych" (§2) standing on
// it. Three bottles is the whole silhouette — nothing else in the hall is a row of
// little glowing cylinders, which is the point of a placeholder.
const bottles: THREE.Mesh[] = [];
for (let i = 0; i < 3; i++) {
const b = new THREE.Mesh(
new THREE.CylinderGeometry(fw * 0.07, fw * 0.07, h * 0.3, 8),
i === 0 ? am : am.clone(),
);
b.position.set((i - 1) * fw * 0.2, h + h * 0.15, 0);
g.add(b);
bottles.push(b);
}
g.userData.fktryIdle = ((t: number) => {
// They pulse out of phase — a bench mid-assay, not a machine mid-cycle.
bottles.forEach((b, i) => {
(b.material as THREE.MeshStandardMaterial).emissiveIntensity =
1.0 + Math.sin(t * 2.4 + i * 2.1) * 0.45;
});
}) satisfies IdleFn;
break;
}
case 'splitter': {
// Output-cycling tell: a lamp that sweeps across the outputs (codex: round-robin).
const bar = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.66, 0.05, fd * 0.16), am);
@ -196,10 +223,140 @@ export function beltPlateGeometry(): THREE.BufferGeometry {
return geo;
}
// ---------------------------------------------------------------- GLB legibility
/**
* GLB legibility treatment. Round 6 shipped half of this; round 7 added the half that
* was actually load-bearing and cut the other half back to size.
*
* MODELBEAST bakes albedo into textures and ships NO emissive, so at game zoom on a
* near-black ground a real asset read as a dark hole. Round 6 answered that with a
* texel-following emissive floor at 0.42 which worked, but ROUND 7 FOUND THE ROOT
* CAUSE it was masking: 13 of the 27 shipped GLBs carry `metallicFactor >= 0.8` (16 at
* >= 0.5) and 21 have no metallic-roughness texture, and a metal's diffuse term is
* `albedo * (1 - metalness)` so those materials had almost no diffuse AND, with
* `scene.environment === null`, nothing to reflect. They were near-black by arithmetic.
* The emissive floor was cosmetics over a lighting hole; env.ts fills the hole.
*
* So the treatment is now three things, all keyed off data the placeholders already use:
*
* 1. ENVIRONMENT: the asset's own, stronger share of the procedural IBL. `envMap` is
* assigned per material rather than inherited from `scene.environment` on purpose
* three.js overrides `material.envMapIntensity` with `scene.environmentIntensity`
* whenever a material falls back to the scene (WebGLRenderer: `material.envMap ===
* null && scene.environment !== null`), so assigning it explicitly is the ONLY way
* the assets can be dosed differently from six rounds of hand-tuned placeholder art.
* 2. EMISSIVE FLOOR: still here, at less than half the round-6 value (see below).
* 3. ACCENT BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of
* what the machine makes the same accent derivation the placeholder uses.
*/
/**
* The assets' share of the environment. High because the assets are unrealistically
* metallic: `metallicFactor: 1` with no MR texture is what the mesh generators emit by
* default, not an art decision the codex describes grimy chassis, not chrome. Until
* that is fixed upstream, indirect specular is nearly the whole material response for
* half the catalog, so this is where their light has to come from. **If MODELBEAST ever
* ships sane metalness, drop this to ~1.0** (measured alternative: clamping metalness to
* 0.45 at load for the 21 materials with no MR texture reaches the same luminance at
* `envMapIntensity: 1` and is visually near-identical not shipped, because rewriting
* an asset's authored PBR values is a bigger claim than lighting the room it stands in).
*/
const GLB_ENV_INTENSITY = 2.6;
/**
* Round 6 shipped 0.42, and with a real environment that is now overexposure: it flattens
* the shading the IBL just bought back and pushes every chassis toward a self-lit sticker.
* Measured across the 20 machines visible at the showroom close-up frame (mean sRGB
* luminance of each body, plain crust ~24 for scale):
*
* env + floor 0.00 -> darkest machine 38 (already clear of the floor no holes)
* env + floor 0.18 -> darkest 54, demosaicer 77, mv-extractor 71, neural-keyer 81
* no env, floor 0.42 (round 6) -> darkest 57, demosaicer 75, mv-extractor 70, keyer 85
*
* So 0.18 lands the catalog within a few percent of round 6's overall brightness while
* the IBL, not a flat self-glow, does the shaping. NOT retired: a genuinely black texel
* still needs the "never a hole" guarantee, and it's what keeps a machine legible when a
* brownout drops tone-mapping exposure to 0.7.
*/
const GLB_EMISSIVE_FLOOR = 0.18;
function treatGLBMaterials(root: THREE.Object3D, env: THREE.Texture | null): void {
root.traverse((o) => {
const m = o as THREE.Mesh;
if (!m.isMesh) return;
const mats = Array.isArray(m.material) ? m.material : [m.material];
for (const raw of mats) {
const mat = raw as THREE.MeshStandardMaterial;
if (!mat || !(mat as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue;
if (env) {
mat.envMap = env;
mat.envMapIntensity = GLB_ENV_INTENSITY;
}
const e = mat.emissive;
const alreadyLit = !!e && e.r + e.g + e.b > 0.03 && (mat.emissiveIntensity ?? 0) > 0.02;
if (!alreadyLit) {
// MODELBEAST authored no glow here, so give it the floor. (If it DID author one,
// it still gets the environment above — only the emissive is left alone.)
if (mat.map) {
mat.emissiveMap = mat.map; // white * map = the texture, self-lit
mat.emissive = new THREE.Color(0xffffff);
} else {
mat.emissive = mat.color.clone(); // no texture: self-colour floor
}
mat.emissiveIntensity = GLB_EMISSIVE_FLOOR;
}
mat.needsUpdate = true;
}
});
}
/** Unit beacon; instances scale it by footprint and never dispose it (module-shared). */
const BEACON_GEO = new THREE.IcosahedronGeometry(1, 1);
/**
* Perch the machine's ONE emissive accent on top of a baked GLB and give the asset a
* gentle idle pulse resource colour, identity, and a heartbeat, all from the same
* accent the placeholder derives. The idle reads `beacon.material` at call time (not a
* captured reference) because EntityLayer clones every GLB material per instance; a
* closure over the pre-clone material would drive a mesh nothing renders.
*/
function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): void {
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
const fp = Math.min(def.footprint.x, def.footprint.y);
const r = THREE.MathUtils.clamp(fp * 0.1, 0.08, 0.2);
const beacon = new THREE.Mesh(
BEACON_GEO,
new THREE.MeshStandardMaterial({
color: accent, emissive: accent, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.1,
}),
);
beacon.scale.setScalar(r);
beacon.position.set(
(box.min.x + box.max.x) / 2,
box.max.y + r * 1.3,
(box.min.z + box.max.z) / 2,
);
beacon.userData.fktryBeacon = true;
root.add(beacon);
root.userData.fktryIdle = ((t: number) => {
(beacon.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.8 + Math.sin(t * 2.3) * 0.45;
}) satisfies IdleFn;
}
// ---------------------------------------------------------------- GLB handling
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D {
/**
* Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane.
* Exported for `registry.test.ts` the fit maths is the thing a bad asset export
* silently breaks, and it needs no GPU to check.
*/
export function normaliseGLB(
src: THREE.Object3D,
def: MachineDef,
clips: THREE.AnimationClip[],
accent?: number,
): THREE.Object3D {
// SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound
// per instance or every copy animates off the first one's bones.
const obj = cloneSkinned(src);
@ -224,14 +381,20 @@ function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.Animati
});
// A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs
// get wrapped: the mixer drives the inner node, the wrapper does the normalising.
let root: THREE.Object3D;
if (clips.length) {
const wrap = new THREE.Group();
wrap.add(obj);
wrap.userData.fktryClips = clips;
return wrap;
root = wrap;
} else {
obj.userData.fktryClips = clips;
root = obj;
}
obj.userData.fktryClips = clips;
return obj;
// Machine create path passes an accent -> perch the beacon. The instanceable path
// (belts) passes none: an instanced belt has no room for a beacon and no accent to show.
if (accent !== undefined) addAccentBeacon(root, def, accent);
return root;
}
/**
@ -285,15 +448,53 @@ export class AssetRegistry {
private entries = new Map<string, AssetEntry>();
private defs = new Map<string, MachineDef>();
private loader = new GLTFLoader();
/** The derived emissive accent per asset key shared by placeholders and hot-swapped
* GLBs so a real asset gets the same one impossible element the placeholder had. */
private accents = new Map<string, number>();
private eras = new Map<string, Era>();
/** Orchestrator integration fix (round-7 pilot): the farm's PBR meshes are shipped
* through `gltf-transform optimize --compress meshopt --texture-compress webp`, which
* cuts a Hunyuan3D asset from 7.6 MB to ~420 KB smaller than the albedo-only sf3d
* assets it replaces, WITH metallic-roughness maps. That needs three extensions:
* EXT_texture_webp and KHR_mesh_quantization are native to GLTFLoader; meshopt needs
* its decoder handed over. Uncompressed GLBs keep loading unchanged. */
private loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
private timer: number | null = null;
/** The prefiltered environment (env.ts), handed over by index.ts before init. */
private env: THREE.Texture | null = null;
async init(data: GameData): Promise<void> {
/** Give hot-swapped GLBs their own share of the IBL. Call before `init`. */
setEnvironment(env: THREE.Texture | null): void {
this.env = env;
}
/** Which era a machine is skinned as (derived from tech unlocks; stream by default). */
eraOf(machineId: string): Era {
return this.eras.get(machineId) ?? DEFAULT_ERA;
}
/**
* Build every placeholder synchronously, then kick the GLB probe off WITHOUT awaiting
* it (round 7).
*
* This used to `await this.probeAll()`, and main.ts awaits `renderer.init()` before it
* starts the frame loop so the first frame of the game was gated on 27 HEAD requests
* plus ~28 MB of GLB fetch/parse. Placeholders are honorable (MASTERPLAN) and they are
* already fully built one line above: there is nothing to wait for. Assets now land
* through exactly the same hot-swap path a mid-session MODELBEAST drop uses, which is
* a path this lane has shipped and verified since round 1 the first second of a boot
* is just the shortest possible version of it.
*/
init(data: GameData): void {
this.eras = eraIndex(data);
for (const def of data.machines) {
this.defs.set(def.asset, def);
this.entries.set(def.asset, this.placeholderEntry(def, accentFor(def, data), bodyFor(def)));
const accent = accentFor(def, data);
this.accents.set(def.asset, accent);
const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id));
this.entries.set(def.asset, entry);
}
await this.probeAll();
void this.probeAll(); // deliberately not awaited — see above
if (import.meta.env.DEV) {
this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number;
}
@ -307,13 +508,13 @@ export class AssetRegistry {
return this.entries.get(key) ?? null;
}
private placeholderEntry(def: MachineDef, accent: number, body: number): AssetEntry {
private placeholderEntry(def: MachineDef, accent: number, body: number, era: Era): AssetEntry {
const prev = this.entries.get(def.asset);
return {
key: def.asset,
version: prev ? prev.version : 0,
isGLB: false,
create: () => makePlaceholder(def, accent, body),
create: () => makePlaceholder(def, accent, body, era),
// Belts are instanced by belts.ts, which supplies the chevron material itself —
// this material is only a fallback and is normally discarded.
instanceable: () =>
@ -337,12 +538,17 @@ export class AssetRegistry {
const prev = this.entries.get(def.asset);
const version = (prev?.version ?? 0) + 1;
const template = gltf.scene;
// Legibility treatment, run ONCE on the shared template: environment + emissive
// floor. EntityLayer clones these materials per instance, so per-entity heat/scram
// still works on top (and `clone()` carries envMap/envMapIntensity across).
treatGLBMaterials(template, this.env);
const clips = gltf.animations ?? [];
const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
this.entries.set(def.asset, {
key: def.asset,
version,
isGLB: true,
create: () => normaliseGLB(template, def, clips),
create: () => normaliseGLB(template, def, clips, accent),
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
});
this.version++;
@ -358,20 +564,23 @@ export class AssetRegistry {
}
/**
* The machine's ONE emissive accent = the colour of what it makes (recipes -> outputs
* -> ItemDef.color), falling back to a per-kind accent.
* The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed,
* otherwise the colour of what it makes (recipes -> outputs -> ItemDef.color), otherwise
* a per-kind accent.
*
* NOTE this deliberately does NOT read `MachineDef.color`, against the letter of the
* round-2 orders. See `bodyFor` below and the round-2 NOTES: LANE-DATA authored that
* field as chassis colour, and wiring it to the accent makes every machine glow a
* muddy neutral, which is the opposite of the style guide's "one impossible element".
* `MachineDef.color` is NOT read here v3 ruled it the chassis; see `bodyFor` below.
*
* The derived path has a guard: raw products are near-black by design (MDAT ORE is
* #1a1a22), and an accent that dark isn't an accent — the seam extractor's CRT head
* would be a black panel on a black chassis. Anything below the legibility floor falls
* through to the kind accent (for the extractor, CRT blue, as the codex describes it).
*/
function accentFor(def: MachineDef, data: GameData): number {
export function accentFor(def: MachineDef, data: GameData): number {
// v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a
// dark accent is a decision; the floor below exists for DERIVED colours, which are
// incidental (item colours are authored for chips, not for machine accents). Same
// reasoning as `bodyFor` and `def.color`.
if (def.accent) return new THREE.Color(def.accent).getHex();
for (const rid of def.recipes) {
const recipe = data.recipes.find((r) => r.id === rid);
const outId = recipe && Object.keys(recipe.outputs)[0];
@ -395,7 +604,7 @@ function accentFor(def: MachineDef, data: GameData): number {
* deletes that element. Read as bodies they are exactly right, and they art-direct 21
* machines that were previously 7 per-kind greys. Flagged for the orchestrator.
*/
function bodyFor(def: MachineDef): number {
export function bodyFor(def: MachineDef): number {
if (def.color) return new THREE.Color(def.color).getHex();
return BODY_BY_KIND[def.kind] ?? 0x3b3944;
}

150
fktry/src/render/relics.ts Normal file
View File

@ -0,0 +1,150 @@
/**
* LANE-RENDER the relic. `snapshot.relics` (v6): optical fossils buried in the crust.
*
* The brief is a Mario hidden pipe: findable by a player who wanders and looks, invisible
* to one who doesn't. So an UNFOUND relic is only a faint static-patch decal that breathes
* no marker, no minimap ping, no arrow. Its discoverability budget is curiosity. The
* restraint is the feature: if you can see it from across the map it isn't hidden.
*
* FOUND (excavated) the patch becomes a small monument: a fossil slab on a plinth,
* lifting out of the ground, lit. §1: the optical soundtrack fossils *sing* when
* excavated the singing is LANE-SCREEN's audio band; this is the thing it sings from.
*/
import * as THREE from 'three';
import type { RelicState } from '../contracts';
import { disposeObject } from './coords';
import type { Juice } from './juice';
const SHIMMER_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const SHIMMER_FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
varying vec2 vUv;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
void main() {
vec2 p = vUv * 2.0 - 1.0;
float rr = dot(p, p);
if (rr > 1.0) discard;
// static crawl: a hidden VHS dropout, quantised so it flickers rather than smears.
vec2 g = floor(vUv * 22.0) + floor(vec2(uTime * 9.0));
float s = hash(g);
// breathe the whole patch so a still eye catches motion, but faintly.
float breathe = 0.5 + 0.5 * sin(uTime * 1.3);
float edge = 1.0 - smoothstep(0.4, 1.0, rr);
float a = s * edge * (0.05 + 0.06 * breathe); // deliberately tiny
gl_FragColor = vec4(vec3(0.7, 0.75, 0.85) * s, a);
}
`;
interface Rec {
found: boolean;
obj: THREE.Object3D;
}
const PLANE = new THREE.PlaneGeometry(1, 1);
export class RelicLayer {
readonly group = new THREE.Group();
private live = new Map<number, Rec>();
private seen = new Set<number>();
private shimmerUniforms = { uTime: { value: 0 } };
private shimmerMat: THREE.ShaderMaterial;
constructor(private juice?: Juice) {
this.group.name = 'relics';
this.shimmerMat = new THREE.ShaderMaterial({
uniforms: this.shimmerUniforms,
vertexShader: SHIMMER_VERT,
fragmentShader: SHIMMER_FRAG,
transparent: true,
depthWrite: false,
});
}
/**
* @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);
if (rec && rec.found !== r.found) {
this.drop(r.id, rec); // excavated: swap shimmer → monument
rec = undefined;
}
if (!rec) {
const obj = r.found ? this.buildMonument() : this.buildShimmer();
obj.position.set(r.pos.x + 0.5, 0, r.pos.y + 0.5);
this.group.add(obj);
rec = { found: r.found, obj };
this.live.set(r.id, rec);
}
// 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;
rec.obj.position.y = 0.05 + Math.sin(timeSec * 1.5) * 0.03;
}
}
for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec);
}
private buildShimmer(): THREE.Object3D {
const m = new THREE.Mesh(PLANE, this.shimmerMat);
m.rotation.x = -Math.PI / 2;
m.scale.set(2.4, 2.4, 1);
m.position.y = 0.02;
m.renderOrder = 2;
return m;
}
private buildMonument(): THREE.Object3D {
const g = new THREE.Group();
const plinth = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.6, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x14121d, roughness: 0.9, emissive: 0x1a2740, emissiveIntensity: 0.4 }),
);
plinth.position.y = 0.2;
plinth.castShadow = plinth.receiveShadow = true;
// The fossil: a wavy photographic strip that "sings" — an emissive slab, gamut-lit.
const strip = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.8, 0.5),
new THREE.MeshStandardMaterial({
color: 0xffe0b0, emissive: 0xffcaa0, emissiveIntensity: 1.3,
roughness: 0.3, metalness: 0.4,
}),
);
strip.position.y = 0.8;
strip.castShadow = true;
g.add(plinth, strip);
g.userData.owns = true;
return g;
}
private drop(id: number, rec: Rec): void {
this.group.remove(rec.obj);
if (rec.obj.userData.owns) disposeObject(rec.obj); // monuments own their geo/mats
// shimmers share PLANE + shimmerMat — nothing to dispose
this.live.delete(id);
}
}

107
fktry/src/render/remove.ts Normal file
View File

@ -0,0 +1,107 @@
/**
* LANE-RENDER remove-mode hover.
*
* Two reads at once: a hazard-striped decal on the machine's footprint (this is the
* ground you are about to clear) and a red wash over its volume (this is the thing that
* is going away). Stripes because the codex dresses everything horrifying as routine
* industrial equipment demolition should look like a permit, not an explosion.
*
* Driven by the `__remove` ghost sentinel; see ghost.ts.
*/
import * as THREE from 'three';
const DEMOLITION = 0xff3b30;
const DECAL_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const DECAL_FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uScale; // footprint in tiles, so stripes don't stretch on big machines
uniform vec3 uColor;
varying vec2 vUv;
void main() {
// Border: constant thickness regardless of footprint.
vec2 d = min(vUv, 1.0 - vUv) * uScale;
float edge = min(d.x, d.y);
float border = 1.0 - smoothstep(0.04, 0.10, edge);
// Hazard stripes, crawling.
vec2 p = vUv * uScale;
float s = fract((p.x + p.y) * 1.6 - uTime * 0.5);
float stripe = step(0.5, s);
float a = max(border, stripe * 0.28);
if (a < 0.01) discard;
gl_FragColor = vec4(uColor, a * 0.85);
}
`;
export class RemoveHover {
readonly group = new THREE.Group();
private decal: THREE.Mesh;
private wash: THREE.Mesh;
private uniforms = {
uTime: { value: 0 },
uScale: { value: new THREE.Vector2(1, 1) },
uColor: { value: new THREE.Color(DEMOLITION) },
};
constructor() {
this.group.name = 'removeHover';
this.group.visible = false;
this.group.renderOrder = 11;
this.decal = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: DECAL_VERT,
fragmentShader: DECAL_FRAG,
transparent: true,
depthWrite: false,
}),
);
this.decal.rotation.x = -Math.PI / 2;
this.decal.position.y = 0.02; // just clear of the ground plane
this.group.add(this.decal);
this.wash = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({
color: DEMOLITION,
transparent: true,
opacity: 0.3,
depthWrite: false,
}),
);
this.group.add(this.wash);
}
hide(): void {
this.group.visible = false;
}
/**
* Frame the machine we're about to demolish. `w`/`d` are its ROTATED footprint, so a
* 2x3 turned east reads as 3x2 the decal has to match what's actually occupied.
*/
show(cx: number, cz: number, w: number, d: number, height: number, timeSec: number): void {
this.group.visible = true;
this.uniforms.uTime.value = timeSec;
this.uniforms.uScale.value.set(w, d);
this.decal.scale.set(w, d, 1);
this.decal.position.set(cx, 0.02, cz);
this.wash.scale.set(w * 0.94, height, d * 0.94);
this.wash.position.set(cx, height / 2, cz);
}
}

188
fktry/src/render/seams.ts Normal file
View File

@ -0,0 +1,188 @@
/**
* LANE-RENDER mineral seams: the glinting patches that tell you where to mine.
*
* Data is `data/seams.json` (LANE-DATA). Its doc sets the division of labour:
* SIM an extractor extracts only while its footprint overlaps a seam rect.
* RENDER `era` skins the ground; `resource` is the stratum's codex payload.
* So this layer does two jobs at once: it skins each region with its era (§1 strata,
* with a wide soft falloff so it reads as a whisper across the crust, not a decal), and
* inside the rect it lays the mineral itself obsidian darkening with the resource's
* colour flickering through fracture veins (§2: "thin glowing rainbow circuit veins").
*
* The tell is deliberately quiet. There is NO label and no icon: a seam is a patch of
* ground that glints. A player who looks discovers that extractors want to sit on them;
* a player who doesn't just sees texture. `richness` drives how strongly it glints, so
* the rich remote seams are the ones that reward wandering.
*/
import * as THREE from 'three';
import type { GameData } from '../contracts';
import seamsFile from '../../data/seams.json';
export interface SeamRect { x: number; y: number; w: number; h: number }
export interface SeamDef {
id: string;
era: string;
resource: string;
rect: SeamRect;
richness: number;
}
/** §1 strata palettes — the era whisper laid over the crust around each seam. */
const ERA_TINT: Record<string, number> = {
reel: 0x3a2a16, // celluloid shale, silver-nitrate brown
broadcast: 0x14301c, // phosphor dunes
disc: 0x241634, // rainbow-on-black gloss
stream: 0x1e2430, // flat plastic crust
fortress: 0x2c3038,
};
/** How far past the rect the era whisper bleeds, in tiles. */
const ERA_BLEED = 7;
const VERT = /* glsl */ `
attribute vec3 aEra;
attribute vec3 aOre;
attribute vec2 aHalf; // rect half-extent in tiles (for the inside/outside test)
attribute float aRich;
varying vec3 vEra;
varying vec3 vOre;
varying vec2 vHalf;
varying float vRich;
varying vec2 vLocal; // tiles from the rect centre
varying vec2 vWorld;
void main() {
vEra = aEra; vOre = aOre; vHalf = aHalf; vRich = aRich;
vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = wp.xz;
// instanceMatrix scales a unit quad to (rect + bleed); recover tiles-from-centre.
vLocal = position.xz * (aHalf + vec2(${ERA_BLEED}.0)) * 2.0;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
varying vec3 vEra;
varying vec3 vOre;
varying vec2 vHalf;
varying float vRich;
varying vec2 vLocal;
varying vec2 vWorld;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
return mix(mix(hash(i), hash(i+vec2(1,0)), f.x),
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y);
}
void main(){
vec2 d = abs(vLocal) - vHalf; // >0 outside the rect, in tiles
// --- era whisper: strongest at the rect, faded out over ERA_BLEED tiles ---
float outside = max(max(d.x, d.y), 0.0);
float era = 1.0 - smoothstep(0.0, ${ERA_BLEED}.0, outside);
// fbm-break the edge so a rect never reads as a rectangle
era *= 0.55 + 0.45 * vnoise(vWorld * 0.35);
// --- the mineral itself: only inside the rect ---
// Soft-edged so a seam never reads as a rectangle you could have drawn in CSS, but
// the fade is SHORT: SIM extracts on any footprint/rect overlap, so a glint that died
// two tiles inside the boundary would under-report where mining actually works.
float body = (1.0 - smoothstep(-0.8, 0.6, max(d.x, d.y)))
* (0.6 + 0.4 * vnoise(vWorld * 0.55));
// GLINT, not veins. The first pass drew continuous sinusoidal lines and they read as
// green worms crawling over the floor — organic, loud, competing with the belts.
// Mineral glitter is sparse and it TWINKLES: quantise to a fleck grid, keep the top
// few percent, and let each fleck sparkle on its own clock.
vec2 g = vWorld * 7.0;
vec2 cell = floor(g);
float pick = hash(cell);
// Shape a round fleck inside its cell instead of filling the cell — a full cell reads
// as confetti at this zoom, a point reads as a mineral fleck.
vec2 sub = fract(g) - 0.5 - (vec2(hash(cell + 3.7), hash(cell + 9.1)) - 0.5) * 0.5;
float dot_ = 1.0 - smoothstep(0.10, 0.30, length(sub));
float fleck = smoothstep(0.90 - vRich * 0.10, 1.0, pick) * dot_;
float twinkle = 0.35 + 0.65 * pow(max(0.0, sin(uTime * 2.2 + pick * 43.0)), 3.0);
float glint = fleck * twinkle * body;
vec3 col = vEra * era * 0.75; // stratum tint on the crust
col = mix(col, vOre, min(1.0, glint * 1.6)); // ore colour only in the flecks
float a = era * 0.22 + body * 0.09 + glint * (0.35 + vRich * 0.4);
if (a < 0.004) discard;
gl_FragColor = vec4(col, a);
// sRGB out — a raw ShaderMaterial gets none of the output includes (see ground.ts).
#include <colorspace_fragment>
}
`;
export class SeamLayer {
readonly mesh: THREE.InstancedMesh;
private uniforms = { uTime: { value: 0 } };
private dummy = new THREE.Object3D();
constructor(seams: SeamDef[], data: GameData) {
const geo = new THREE.PlaneGeometry(1, 1);
geo.rotateX(-Math.PI / 2); // lie flat on the ground
const n = Math.max(1, seams.length);
const era = new Float32Array(n * 3);
const ore = new Float32Array(n * 3);
const half = new Float32Array(n * 2);
const rich = new Float32Array(n);
const c = new THREE.Color();
const mat = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
});
this.mesh = new THREE.InstancedMesh(geo, mat, n);
this.mesh.name = 'seams';
this.mesh.renderOrder = -1; // above the ground plane, below everything else
this.mesh.frustumCulled = false;
seams.forEach((s, i) => {
const { x, y, w, h } = s.rect;
// Quad covers the rect plus the era bleed on every side.
this.dummy.position.set(x + w / 2, 0.012, y + h / 2);
this.dummy.scale.set(w + ERA_BLEED * 2, 1, h + ERA_BLEED * 2);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
c.setHex(ERA_TINT[s.era] ?? ERA_TINT.stream);
era.set([c.r, c.g, c.b], i * 3);
const item = data.items.find((it) => it.id === s.resource);
c.set(item?.color ?? '#8899aa');
ore.set([c.r, c.g, c.b], i * 3);
half.set([w / 2, h / 2], i * 2);
rich[i] = s.richness ?? 0.5;
});
this.mesh.count = seams.length;
this.mesh.instanceMatrix.needsUpdate = true;
geo.setAttribute('aEra', new THREE.InstancedBufferAttribute(era, 3));
geo.setAttribute('aOre', new THREE.InstancedBufferAttribute(ore, 3));
geo.setAttribute('aHalf', new THREE.InstancedBufferAttribute(half, 2));
geo.setAttribute('aRich', new THREE.InstancedBufferAttribute(rich, 1));
}
frame(timeSec: number): void {
this.uniforms.uTime.value = timeSec;
}
}
/** The seams DATA authored. Statically imported so it works in a production build too. */
export function loadSeams(): SeamDef[] {
return ((seamsFile as { seams?: SeamDef[] }).seams ?? []) as SeamDef[];
}
/** The SE corner DATA keeps seam-free so the relic always has somewhere to land. */
export function relicReserve(): SeamRect | null {
return (seamsFile as { relicReserve?: SeamRect }).relicReserve ?? null;
}

View File

@ -0,0 +1,324 @@
/**
* LANE-RENDER `?showroom`: the MODELBEAST asset QA page.
*
* Every machine and every item, on labelled plinths, under a fixed camera. Drop a GLB
* into `public/assets/models/<asset>.glb` and it appears here within ~10s with no
* reload this is the eyeball step for an asset drop: right scale? sits on the ground?
* silhouette reads at iso distance? clip loops?
*
* The machines are fabricated as a normal SimSnapshot rather than hand-built meshes, so
* they go through the SAME EntityLayer path as the real game hot-swap, GLB
* normalisation, AnimationMixer, era skin and all. A bespoke display path would be a
* QA page that tests itself instead of the renderer.
*
* Items aren't entities (they have no `asset` key and never hot-swap), so those are
* built directly but from beltitems.ts's own geometry/material factories, so what you
* see here is literally what rides the belts.
*
* Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag.
*/
import * as THREE from 'three';
import type {
CorrectionState, CorrectionUnit, Dir, EntityState, GameData, MachineDef, SimSnapshot,
} from '../contracts';
import { AssetRegistry } from './registry';
import { itemGeometry, itemMaterial } from './beltitems';
export const showroomEnabled = (): boolean =>
import.meta.env.DEV && new URLSearchParams(location.search).has('showroom');
/** Machines face the camera (dir 2 = S = +z), which is where the iso rig looks from. */
const FACING: Dir = 2;
/**
* Machine hall to the north, item hall to the south, both inside the ±32 world.
* Both catalogs grow every round (9 items -> 60; 7 machines -> 29), so nothing here is
* a fixed row/column count: the halls are packed into the world box at build time.
*/
const M_COLS = 8;
const M_SPACING = 6.5;
const M_Z0 = -24;
const I_SPACING = 3.5;
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;
sub: string;
x: number;
z: number;
/** Cached label size, measured once — re-reading offsetWidth every frame thrashes layout. */
w: number;
h: number;
}
export class Showroom {
readonly decor = new THREE.Group();
private entities: EntityState[] = [];
private slots: Slot[] = [];
private labelHost: HTMLDivElement | null = null;
private labelEls: HTMLDivElement[] = [];
private v = new THREE.Vector3();
/** Camera preset framing the whole hall. */
readonly view = { x: 0, z: 2, zoom: 21 };
constructor(private data: GameData, private registry: AssetRegistry) {
this.decor.name = '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) => {
const cx = -mCentre + (i % M_COLS) * M_SPACING;
const cz = M_Z0 + Math.floor(i / M_COLS) * M_SPACING;
// Centre each footprint on its slot: pos is the min corner.
const x = Math.round(cx - def.footprint.x / 2);
const z = Math.round(cz - def.footprint.y / 2);
this.entities.push({
id: id++, def: def.id, pos: { x, y: z }, dir: FACING, recipe: null,
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, scrammed: false,
});
this.slots.push({
label: def.name,
sub: `${def.asset}.glb · ${this.registry.eraOf(def.id)}`,
x: cx, z: cz, w: 0, h: 0,
});
this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, cz));
});
const itemZ0 = M_Z0 + Math.ceil(this.data.machines.length / M_COLS) * M_SPACING + I_GAP;
// Fit the item hall to the world box — BOTH axes. Budgeting only depth is what put
// all 60 items on one 206-tile-wide row the moment the machine hall grew to 5 rows
// and ate the remaining depth: rows collapsed to 1, so columns took the overflow and
// walked straight out of the world.
const iRows = Math.max(1, Math.floor((I_Z_LIMIT - itemZ0) / I_SPACING) + 1);
const maxCols = Math.max(1, Math.floor((X_LIMIT * 2) / I_SPACING) + 1);
const iCols = Math.min(maxCols, Math.max(1, Math.ceil(this.data.items.length / iRows)));
const iCentre = ((iCols - 1) * I_SPACING) / 2;
// If both axes are full the catalog has outgrown the hall — say so rather than
// quietly stacking items on top of each other off-world.
const capacity = iRows * iCols;
if (this.data.items.length > capacity) {
console.warn(
`[render] showroom: ${this.data.items.length} items exceed the hall's ${capacity} ` +
`slots (${iCols}x${iRows}); the overflow will sit past the world edge. Time to page.`,
);
}
// --- items: built from the belt layer's own factories ----------------------------
this.data.items.forEach((def, i) => {
const cx = -iCentre + (i % iCols) * I_SPACING;
const cz = itemZ0 + Math.floor(i / iCols) * I_SPACING;
const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
mesh.position.set(cx, PLINTH_H + 0.35, cz);
mesh.castShadow = true;
mesh.userData.spin = true;
this.decor.add(mesh);
this.decor.add(plinth(1.4, 1.4, cx, cz));
this.slots.push({
label: def.name, sub: `tier ${def.tier} · ${def.color}`, x: cx, z: cz, w: 0, h: 0,
});
});
// Preset frames the MACHINE hall: assets are what this page exists to inspect, and a
// view wide enough to include every item renders each machine too small to judge.
// The item hall is one pan south (WASD / drag).
const mRows = Math.ceil(this.data.machines.length / M_COLS);
this.view.z = M_Z0 + ((mRows - 1) * M_SPACING) / 2;
this.view.zoom = 22;
}
/** All machines, standing still. Items live in `decor`, not the snapshot. */
snapshot(tick: number): SimSnapshot {
return {
tick,
paused: false,
entities: this.entities,
beltItems: [],
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
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;
}
}
// ---------------------------------------------------------------- labels
/** DOM labels: crisp at any zoom, and no font atlas to maintain for a QA page. */
mountLabels(container: HTMLElement): void {
const host = document.createElement('div');
host.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden';
this.labelEls = this.slots.map((s) => {
const el = document.createElement('div');
el.style.cssText = [
'position:absolute',
'transform:translate(-50%,0)',
'font:10px/1.35 "Courier New",ui-monospace,monospace',
'color:#cfe6ff',
'text-align:center',
'text-shadow:0 1px 3px #000,0 0 8px #000',
'white-space:nowrap',
].join(';');
el.innerHTML = `<div>${esc(s.label)}</div><div style="color:#7d8aa8">${esc(s.sub)}</div>`;
host.appendChild(el);
return el;
});
container.appendChild(host);
this.labelHost = host;
// Measure once: sizes never change, and reading offsetWidth per frame would force
// a layout for every one of ~74 labels.
this.slots.forEach((s, i) => {
s.w = this.labelEls[i].offsetWidth;
s.h = this.labelEls[i].offsetHeight;
});
}
/**
* Project, then drop any label that would collide with one already placed. There are
* ~74 of these and at overview zoom they cannot all fit; a readable subset that
* resolves as you zoom in beats an unreadable pile. Front-to-back so the labels you
* lose are the ones furthest away.
*/
updateLabels(camera: THREE.Camera, container: HTMLElement): void {
if (!this.labelHost) return;
const w = container.clientWidth;
const h = container.clientHeight;
this.placed.length = 0;
const order = this.slots
.map((s, i) => ({ s, i, d: s.z })) // nearer the camera = larger z in this rig
.sort((a, b) => b.d - a.d);
for (const { s, i } of order) {
const el = this.labelEls[i];
this.v.set(s.x, 0, s.z + 1.5).project(camera);
if (this.v.z > 1 || Math.abs(this.v.x) > 1.15 || Math.abs(this.v.y) > 1.15) {
el.style.display = 'none';
continue;
}
const px = (this.v.x * 0.5 + 0.5) * w;
const py = (-this.v.y * 0.5 + 0.5) * h;
const l = px - s.w / 2;
const r = px + s.w / 2;
const t = py;
const b = py + s.h;
let hit = false;
for (const p of this.placed) {
if (l < p[2] && r > p[0] && t < p[3] && b > p[1]) { hit = true; break; }
}
if (hit) {
el.style.display = 'none';
continue;
}
this.placed.push([l, t, r, b]);
el.style.display = '';
el.style.left = `${px}px`;
el.style.top = `${py}px`;
}
}
private placed: Array<[number, number, number, number]> = [];
}
function plinth(w: number, d: number, cx: number, cz: number): THREE.Mesh {
const m = new THREE.Mesh(
new THREE.BoxGeometry(w, PLINTH_H, d),
new THREE.MeshStandardMaterial({
color: 0x14121d,
emissive: 0x2a3550,
emissiveIntensity: 0.35,
roughness: 0.9,
}),
);
m.position.set(cx, PLINTH_H / 2, cz);
m.receiveShadow = true;
return m;
}
const esc = (s: string): string =>
s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

View File

@ -0,0 +1,238 @@
/**
* LANE-RENDER wildlife: dust piles, mosquito swarms, and parity mites (§2/§5, the
* living hazards of M2 and The Correction).
*
* Piles are HF-dust glitter heaps (codex: quantizer sweepings) they grow with `size`.
* Swarms are pixel-gnat clouds that cluster over the machine they're degrading (`target`);
* 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).
*
* 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';
const GNATS_PER_SWARM = 24;
/** HF dust: pale, faintly iridescent glitter. Emissive so heaps sparkle at iso distance. */
function pileMaterial(): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: 0xe8e0ff, emissive: 0xb0a0ff, emissiveIntensity: 0.5,
roughness: 0.3, metalness: 0.6, flatShading: true,
});
}
/** A crisp green ✓ on transparent ground — the mite's single, approving eye (§5). */
function checkmarkTexture(): THREE.CanvasTexture {
const s = 64;
const cv = document.createElement('canvas');
cv.width = cv.height = s;
const ctx = cv.getContext('2d')!;
ctx.strokeStyle = '#39ff88';
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(s * 0.2, s * 0.52);
ctx.lineTo(s * 0.42, s * 0.74);
ctx.lineTo(s * 0.82, s * 0.26);
ctx.stroke();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
export class WildlifeLayer {
readonly group = new THREE.Group();
private piles: THREE.InstancedMesh;
private gnats: THREE.InstancedMesh;
private mites: THREE.InstancedMesh;
private miteEyes: THREE.InstancedMesh;
private pileCap = 64;
private gnatCap = 64 * GNATS_PER_SWARM;
private miteCap = 96;
private dummy = new THREE.Object3D();
private eyeCol = new THREE.Color();
/** 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() {
this.group.name = 'wildlife';
// A low, faceted heap; instance scale grows it with `size`.
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
pileGeo.scale(1, 0.5, 1);
this.piles = new THREE.InstancedMesh(pileGeo, pileMaterial(), this.pileCap);
this.piles.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.piles.castShadow = true;
this.piles.frustumCulled = false;
this.piles.count = 0;
const gnatGeo = new THREE.PlaneGeometry(0.08, 0.08);
const gnatMat = new THREE.MeshBasicMaterial({
color: 0x101014, transparent: true, opacity: 0.85, side: THREE.DoubleSide,
});
this.gnats = new THREE.InstancedMesh(gnatGeo, gnatMat, this.gnatCap);
this.gnats.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.gnats.frustumCulled = false;
this.gnats.count = 0;
// Parity mite: a hospital-white carapace (faceted ellipsoid). Faintly self-lit so it
// reads clinical-white on the dark ground rather than going grey.
const miteGeo = new THREE.IcosahedronGeometry(0.18, 0);
miteGeo.scale(1, 0.62, 1.4); // longer than wide: a beetle, not a bead
const miteMat = new THREE.MeshStandardMaterial({
color: 0xf4f6ff, emissive: 0xd6ddf2, emissiveIntensity: 0.32,
roughness: 0.5, metalness: 0.1, flatShading: true,
});
this.mites = new THREE.InstancedMesh(miteGeo, miteMat, this.miteCap);
this.mites.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mites.castShadow = true;
this.mites.frustumCulled = false;
this.mites.count = 0;
const eyeGeo = new THREE.PlaneGeometry(0.16, 0.16);
const eyeMat = new THREE.MeshBasicMaterial({
map: checkmarkTexture(), transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
this.miteEyes = new THREE.InstancedMesh(eyeGeo, eyeMat, this.miteCap);
this.miteEyes.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.miteEyes.frustumCulled = false;
this.miteEyes.count = 0;
this.group.add(this.piles, this.gnats, this.mites, this.miteEyes);
}
sync(wildlife: ReadonlyArray<WildlifeState> | undefined, timeSec: number): void {
this.harassed.clear();
const list = wildlife ?? [];
let pileN = 0;
let gnatN = 0;
let miteN = 0;
const seenMites = new Set<number>();
for (const w of list) {
if (w.kind === 'dust-pile') {
if (pileN >= this.pileCap) continue;
const s = 0.4 + THREE.MathUtils.clamp(w.size, 0, 1) * 1.3;
this.dummy.position.set(w.pos.x + 0.5, 0.05 * s, w.pos.y + 0.5);
this.dummy.rotation.set(0, w.id * 1.7, 0); // desync facets between heaps
this.dummy.scale.set(s, s, s);
this.dummy.updateMatrix();
this.piles.setMatrixAt(pileN++, this.dummy.matrix);
} else if (w.kind === 'parity-mite') {
if (miteN >= this.miteCap) continue;
seenMites.add(w.id);
miteN = this.placeMite(w, timeSec, miteN);
} else {
if (w.target !== undefined) this.harassed.add(w.target);
const cx = w.pos.x + 0.5;
const cz = w.pos.y + 0.5;
const density = Math.round(GNATS_PER_SWARM * (0.4 + w.size * 0.6));
for (let i = 0; i < density && gnatN < this.gnatCap; i++) {
// Each gnat orbits the swarm centre on its own little chaotic path.
const ph = w.id * 3.1 + i * 0.7;
const rad = 0.5 + 0.5 * Math.sin(timeSec * 3 + ph * 1.3);
const a = timeSec * (1.5 + (i % 3) * 0.4) + ph;
this.dummy.position.set(
cx + Math.cos(a) * rad,
0.5 + 0.4 * Math.sin(timeSec * 4 + ph) + w.size * 0.3,
cz + Math.sin(a * 1.1) * rad,
);
this.dummy.rotation.set(0, -a, 0); // face roughly along travel
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.gnats.setMatrixAt(gnatN++, this.dummy.matrix);
}
}
}
// 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;
this.gnats.count = gnatN;
this.gnats.instanceMatrix.needsUpdate = true;
this.mites.count = miteN;
this.mites.instanceMatrix.needsUpdate = true;
this.miteEyes.count = miteN;
this.miteEyes.instanceMatrix.needsUpdate = true;
if (this.miteEyes.instanceColor) this.miteEyes.instanceColor.needsUpdate = true;
}
/**
* One mite. `size` is the sim's state channel (§5): 0 = hunting, 0<size<1 = on station
* 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. 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;
const cz = w.pos.y + 0.5;
const size = w.size;
const onStation = size > 0 && size < 1;
const ph = w.id * 2.3999; // golden-angle desync
// On station it dwells (a tiny tremor); otherwise it patrols a slow, patient circle.
const amp = onStation ? 0.05 : 0.26;
const rate = onStation ? 0.9 : 0.55;
const gait = timeSec * rate + ph;
const px = cx + Math.cos(gait) * amp;
const pz = cz + Math.sin(gait) * amp;
const yaw = -gait; // face along the patrol tangent
const bob = Math.abs(Math.sin(gait * 3)) * (onStation ? 0.012 : 0.03);
this.dummy.position.set(px, 0.14 + bob, pz);
this.dummy.rotation.set(0, yaw, 0);
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.mites.setMatrixAt(n, this.dummy.matrix);
// Eye: forward of the carapace (local -Z, so world = (-sinYaw,-cosYaw)) and up, laid
// back ~45° so the checkmark meets the iso camera instead of lying flat on the floor.
const fwd = 0.14;
this.dummy.position.set(px - Math.sin(yaw) * fwd, 0.27 + bob, pz - Math.cos(yaw) * fwd);
this.dummy.rotation.set(-Math.PI / 4, yaw, 0);
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.miteEyes.setMatrixAt(n, this.dummy.matrix);
// The approval glows in as the correction completes: dim while hunting, ramping on
// station, full when sated. Per-instance via instanceColor (multiplies the map).
const glow = size <= 0 ? 0.5 : size < 1 ? 0.5 + 0.5 * size : 1;
this.miteEyes.setColorAt(n, this.eyeCol.setRGB(glow, glow, glow));
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). */
export function harassedIn(snap: SimSnapshot): Set<number> {
const out = new Set<number>();
for (const w of snap.wildlife ?? []) {
if (w.kind === 'mosquito-swarm' && w.target !== undefined) out.add(w.target);
}
return out;
}

View File

@ -1,25 +1,41 @@
/**
* THE SCREEN's base content: procedural "clean programming".
* Sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype's
* makeBaseTexture and widened to 16:9. This is what the factory's output is painted
* ONTO it must be bland enough that any corruption reads instantly.
*
* Cost control: the pattern is static, so we only repaint when a visible glyph changes
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow
* drift, not from re-uploading 640x360 pixels every frame.
* A sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype and widened
* to 16:9. This is what the factory's output is painted ONTO bland enough that any corruption
* reads instantly. Round 5: the SUBSTRATE ages with the highest ratified era (eras.ts); the
* corruption on top stays era-agnostic.
*
* Cost control: the pattern is static, so we only repaint when a visible input changes (timecode
* ~1/sec, era, chyron text). Motion comes from the shader's slow drift, not from re-uploading
* 640x360 pixels every frame.
*/
import { eraSkin, type Era, type EraSkin } from './eras';
export const BASE_W = 640;
export const BASE_H = 360;
export interface BaseState {
timeMs: number;
era: Era;
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
chyron: string;
/**
* v9 stasis: a hash auditor is holding a sector still while it reads it. The transport legend
* flips PLAY HOLD and the timecode simply stops (the caller freezes `timeMs`). This is
* the camcorder's OWN vocabulary for a deliberate pause nothing here is damage, and none of
* the brownout's freeze/dropout/judder language is involved.
*/
held?: boolean;
}
export interface BaseFeed {
canvas: HTMLCanvasElement;
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
update(timeMs: number, idle: boolean): boolean;
update(state: BaseState): boolean;
}
function timecode(timeMs: number): string {
// Tape-style running timecode, starting at a suspiciously specific point.
const total = Math.floor(timeMs / 1000) + 754; // 0:12:34
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
@ -27,29 +43,77 @@ function timecode(timeMs: number): string {
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function applyTreatment(g: CanvasRenderingContext2D, skin: EraSkin) {
const t = skin.treatment;
if (t.kind === 'none') return;
g.save();
if (t.kind === 'sepia') {
// warm the whole frame + gate-weave sprocket holes down both edges
g.globalCompositeOperation = 'overlay';
g.fillStyle = 'rgba(150,110,60,0.28)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = '#0d0904';
g.fillRect(0, 0, 22, BASE_H);
g.fillRect(BASE_W - 22, 0, 22, BASE_H);
g.fillStyle = '#efe2c0';
for (let y = 6; y < BASE_H; y += 34) {
g.fillRect(5, y, 12, 18);
g.fillRect(BASE_W - 17, y, 12, 18);
}
} else if (t.kind === 'phosphor') {
// green cathode wash + heavy interlace lines
g.globalCompositeOperation = 'multiply';
g.fillStyle = 'rgba(120,255,150,0.35)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = 'rgba(0,0,0,0.28)';
for (let y = 0; y < BASE_H; y += 2) g.fillRect(0, y, BASE_W, 1);
} else if (t.kind === 'gloss') {
// rainbow pit-and-land sheen sweeping across, plus a specular band
const grad = g.createLinearGradient(0, 0, BASE_W, BASE_H);
grad.addColorStop(0.0, 'rgba(255,0,180,0.10)');
grad.addColorStop(0.35, 'rgba(0,220,255,0.06)');
grad.addColorStop(0.7, 'rgba(120,255,120,0.10)');
grad.addColorStop(1.0, 'rgba(255,180,0,0.08)');
g.globalCompositeOperation = 'screen';
g.fillStyle = grad;
g.fillRect(0, 0, BASE_W, BASE_H);
g.fillStyle = 'rgba(255,255,255,0.10)';
g.fillRect(0, BASE_H * 0.2, BASE_W, 26);
g.globalCompositeOperation = 'source-over';
} else if (t.kind === 'bleach') {
// wash toward blinding white, kill contrast
g.fillStyle = 'rgba(250,250,250,0.6)';
g.fillRect(0, 0, BASE_W, BASE_H);
}
g.restore();
}
export function createBaseFeed(): BaseFeed {
const canvas = document.createElement('canvas');
canvas.width = BASE_W;
canvas.height = BASE_H;
const g = canvas.getContext('2d')!;
let lastTC = '';
let lastIdle: boolean | null = null;
let last = '';
function paint(tc: string, idle: boolean) {
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const bw = BASE_W / bars.length;
bars.forEach((col, i) => {
function paint(state: BaseState) {
const skin = eraSkin(state.era);
g.fillStyle = skin.bg;
g.fillRect(0, 0, BASE_W, BASE_H);
const bw = BASE_W / skin.bars.length;
skin.bars.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
});
// castellations strip
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
casts.forEach((col, i) => {
skin.casts.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
});
// pluge / ramp
g.fillStyle = '#101010';
g.fillStyle = skin.bg;
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
ramp.addColorStop(0, '#000');
@ -58,36 +122,59 @@ export function createBaseFeed(): BaseFeed {
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
// camcorder overlay
g.fillStyle = '#eee';
g.fillStyle = skin.ink;
g.font = "bold 22px 'Courier New', monospace";
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
g.fillText(tc, 20, BASE_H * 0.95);
g.fillText(state.held ? 'HOLD ‖' : 'PLAY ▶', 20, BASE_H * 0.86);
g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
g.font = "bold 18px 'Courier New', monospace";
g.fillStyle = '#f4f4f4';
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
g.fillStyle = '#ff4444';
g.fillStyle = skin.accent;
g.beginPath();
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
g.fill();
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships.
if (idle) {
applyTreatment(g, skin);
// Chyron band. Carries 'NOTHING IS WRONG' while idle, or a radio subtitle when tuned.
// Painted AFTER the era treatment so captions stay legible on any substrate.
// Round-5 review fix (orchestrator): a single un-wrapped fillText showed ~51 chars
// and 29 of THE STANDARD's 30 lines clipped mid-sentence. "THE SCREEN tells you
// what it says" has to mean ALL of what it says: word-wrap to two lines, stepping
// the font down until it fits.
if (state.chyron) {
const maxW = BASE_W - 36;
let lines: string[] = [];
let px = 20;
for (const size of [20, 16, 13]) {
px = size;
g.font = `bold ${px}px 'Courier New', monospace`;
lines = [];
let cur = '';
for (const word of state.chyron.split(' ')) {
const next = cur ? cur + ' ' + word : word;
if (g.measureText(next).width <= maxW) cur = next;
else { if (cur) lines.push(cur); cur = word; }
}
if (cur) lines.push(cur);
if (lines.length <= 2) break;
}
const lineH = px + 6;
const bandH = 10 + lines.length * lineH;
g.fillStyle = 'rgba(8,8,12,0.82)';
g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
g.fillRect(0, BASE_H * 0.5, BASE_W, bandH);
g.fillStyle = '#d8ffd8';
g.font = "bold 20px 'Courier New', monospace";
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24);
lines.forEach((line, i) => g.fillText(line, 18, BASE_H * 0.5 + px + 4 + i * lineH));
}
}
return {
canvas,
update(timeMs: number, idle: boolean) {
const tc = timecode(timeMs);
if (tc === lastTC && idle === lastIdle) return false;
lastTC = tc;
lastIdle = idle;
paint(tc, idle);
update(state: BaseState) {
// Cache key of every visible input; repaint only when one changes.
const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}|${state.held ? 'H' : 'P'}`;
if (key === last) return false;
last = key;
paint(state);
return true;
},
};

View File

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { BOREDOM_GRACE_SECONDS, BOREDOM_HORIZON_SECONDS, freshness } from './boredom';
describe('boredom decays the display, slowly', () => {
it('shows everything you earned while production is running', () => {
expect(freshness(0)).toBe(1);
expect(freshness(5)).toBe(1);
});
it('does not punish a normal jam', () => {
// The whole grace window plus a beat: a stall long enough to notice, diagnose and fix
// must leave THE SCREEN exactly as the player left it.
expect(freshness(BOREDOM_GRACE_SECONDS)).toBe(1);
expect(freshness(BOREDOM_GRACE_SECONDS - 1)).toBe(1);
});
it('takes minutes, not seconds, to forget', () => {
// A minute of silence has barely dented it; the sky is patient.
expect(freshness(BOREDOM_GRACE_SECONDS + 30)).toBeGreaterThan(0.8);
// Fully clean only after the player has genuinely walked away.
expect(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS).toBeGreaterThanOrEqual(180);
});
it('reaches clean at the horizon and stays there', () => {
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS)).toBe(0);
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS + 9999)).toBe(0);
});
it('decays monotonically', () => {
let prev = 2;
for (let s = 0; s <= BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS + 60; s += 5) {
const f = freshness(s);
expect(f).toBeLessThanOrEqual(prev);
expect(f).toBeGreaterThanOrEqual(0);
prev = f;
}
});
it('sits at half way through the decay', () => {
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS / 2)).toBeCloseTo(0.5, 5);
});
it('treats a nonsense idle time as fresh rather than wiping the sky', () => {
expect(freshness(NaN)).toBe(1);
expect(freshness(-10)).toBe(1);
});
});
describe('jammed-rot: stuck work spoils faster than resting work', () => {
const IDLE = BOREDOM_GRACE_SECONDS + 60;
it('leaves a clean idle exactly as gentle as before', () => {
// rot defaults to 0, so round 4 behaviour is untouched for a player who walked away.
expect(freshness(IDLE, 0)).toBe(freshness(IDLE));
});
it('forgets faster the more of the factory is jammed', () => {
const clean = freshness(IDLE, 0);
const half = freshness(IDLE, 0.5);
const solid = freshness(IDLE, 1);
expect(half).toBeLessThan(clean);
expect(solid).toBeLessThan(half);
});
it('still gives a fully jammed factory a grace window', () => {
// Even total gridlock should not punish instantly — a jam you fix in a few seconds is free.
expect(freshness(10, 1)).toBe(1);
});
it('makes a wholly-stuck line sag in tens of seconds, not minutes', () => {
expect(freshness(90, 1)).toBeLessThan(0.5);
expect(freshness(90, 0)).toBeGreaterThan(0.8); // ...while a clean idle has barely moved
});
it('clamps nonsense rot values instead of inverting the curve', () => {
expect(freshness(IDLE, -5)).toBe(freshness(IDLE, 0));
expect(freshness(IDLE, 99)).toBe(freshness(IDLE, 1));
});
});

View File

@ -0,0 +1,37 @@
/**
* Boredom the factory's work evaporating off THE SCREEN.
*
* If nothing ships for a long stretch, the sky slowly forgets. The corruption LEDGER is
* untouched: history is not erased, only the display decays. So when production resumes the
* targets are instantly back where they were and the picture snaps home in about a second.
*
* The horizon is deliberately generous. A jam, a rebuild, a rethink of the bus none of
* those are supposed to read as punishment. The player should have to genuinely walk away
* before the sky starts cleaning itself.
*/
/** No shipments for this long before the sky begins to forget at all. */
export const BOREDOM_GRACE_SECONDS = 60;
/** ...and this long again to fade from full corruption to clean. */
export const BOREDOM_HORIZON_SECONDS = 180;
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/**
* How much of the ledger's corruption is still on display, 0..1.
* 1 = everything you earned is showing; 0 = the sky has forgotten (the tallies have not).
*
* `rot` (round 5 jammed-rot) accelerates forgetting. A CLEAN idle the player walked away
* decays at the base rate. A factory JAMMED SOLID rots faster: the work isn't resting, it's
* spoiling, and the sky should say so. rot 0 = clean idle; rot 1 = everything jammed.
*/
export function freshness(idleSeconds: number, rot = 0): number {
if (!(idleSeconds > BOREDOM_GRACE_SECONDS)) return 1; // NaN-safe: unknown idle reads as fresh
// A jammed factory shortens the grace and steepens the decay. At full rot the horizon is
// roughly a third, so a wholly-stuck line visibly sags in tens of seconds, not minutes.
const r = clamp01(rot);
const grace = BOREDOM_GRACE_SECONDS * (1 - 0.5 * r);
const horizon = BOREDOM_HORIZON_SECONDS * (1 - 0.66 * r);
if (idleSeconds <= grace) return 1;
return 1 - clamp01((idleSeconds - grace) / horizon);
}

View File

@ -145,6 +145,49 @@ describe('anchor slabs sanitize', () => {
});
});
describe('repairs ease the ledger by one unit of the item (The Correction, v6)', () => {
// A `repaired` event decrements the tally by one unit in index.ts; this pins the CONTRACT
// that decrement is built on — "eases total corruption by its item's contribution".
it("removing one unit eases net units by exactly that item's per-unit contribution", () => {
const before = resolveLedger(tally({ melt: 20 }));
const after = resolveLedger(tally({ melt: 19 }));
expect(before.units - after.units).toBeCloseTo(corruptionFor('melt').potency, 5);
expect(after.total).toBeLessThan(before.total);
});
it('correcting a hard-hitting item eases more than correcting a weak one', () => {
const heavy =
resolveLedger(tally({ 'explosive-hits': 20 })).units -
resolveLedger(tally({ 'explosive-hits': 19 })).units;
const light =
resolveLedger(tally({ 'gamut-paint-rec709': 20 })).units -
resolveLedger(tally({ 'gamut-paint-rec709': 19 })).units;
expect(heavy).toBeGreaterThan(light);
});
it('a gunship mirror costs EXACTLY what a mite repair costs (one decrement path, v9)', () => {
// `repaired` and `mirrored` both route through correct() in index.ts, so the arithmetic is
// the same subtraction by construction. This pins the thing that would break if anyone ever
// forked them: a sector theft and a unit theft must move the ledger identically, because
// the ledger only knows units. The gunship's difference is its PLACARD, not its price.
const before = resolveLedger(tally({ 'chroma-slurry': 12 }));
const afterOne = resolveLedger(tally({ 'chroma-slurry': 11 }));
const afterTwo = resolveLedger(tally({ 'chroma-slurry': 10 }));
const first = before.units - afterOne.units;
const second = afterOne.units - afterTwo.units;
expect(first).toBeCloseTo(second, 10);
expect(first).toBeCloseTo(corruptionFor('chroma-slurry').potency, 10);
});
it('a repair never drives corruption below sterile (theft floors at clean)', () => {
// ship one, correct one → back to the NOTHING IS WRONG floor, never past it.
const { units, total, targets } = resolveLedger(tally({ melt: 0 }));
expect(units).toBe(0);
expect(total).toBe(0);
for (const p of PARAMS) expect(targets[p]).toBe(0);
});
});
describe('param hygiene', () => {
it('never emits a param outside 0..1, even shipping everything at once', () => {
const everything = tally({

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { ERA_ORDER, eraSkin, highestEra, type Era } from './eras';
const eraOf = (t: string): Era | undefined =>
({ a: 'reel', b: 'broadcast', c: 'disc', d: 'stream', e: 'fortress' } as Record<string, Era>)[t];
describe('era skins cover the codex', () => {
it('has a distinct skin for every era', () => {
const bars = new Set<string>();
for (const era of ERA_ORDER) {
const skin = eraSkin(era);
expect(skin.bars).toHaveLength(7); // the substrate keeps SMPTE structure; only palette ages
expect(skin.casts).toHaveLength(7);
bars.add(skin.bars.join(','));
}
expect(bars.size).toBe(ERA_ORDER.length); // no two eras look alike
});
it('is deterministic — the same era always paints the same substrate', () => {
expect(eraSkin('disc')).toEqual(eraSkin('disc'));
});
it('runs oldest to newest, reel first and fortress last', () => {
expect(ERA_ORDER[0]).toBe('reel');
expect(ERA_ORDER[ERA_ORDER.length - 1]).toBe('fortress');
});
});
describe('highest ratified era', () => {
it('starts in the deepest stratum when nothing is ratified', () => {
expect(highestEra([], eraOf)).toBe('reel');
});
it('takes the newest era present, not the last listed', () => {
expect(highestEra(['c', 'a', 'b'], eraOf)).toBe('disc');
expect(highestEra(['e', 'a'], eraOf)).toBe('fortress');
});
it('ignores tech it cannot place rather than falling over', () => {
// LANE-DATA can add tech ahead of me; unknown ids must not crash or reset the era.
expect(highestEra(['b', 'who-knows'], eraOf)).toBe('broadcast');
});
it('never goes backwards as more tech is ratified', () => {
let prev = -1;
const acc: string[] = [];
for (const t of ['a', 'b', 'c', 'd', 'e']) {
acc.push(t);
const idx = ERA_ORDER.indexOf(highestEra(acc, eraOf));
expect(idx).toBeGreaterThanOrEqual(prev);
prev = idx;
}
});
});

104
fktry/src/screen/eras.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* Era skins for the clean programming (round 5 Part A).
*
* THE SCREEN's SUBSTRATE ages with the highest ratified era; the corruption on top stays
* era-agnostic. Media history is geology (codex §1), so the test pattern itself is a fossil
* of whatever format the player has climbed to. Codex §8 era skins drive the palette.
*
* Pure and data-only so it is trivially unit-testable and carries no DOM the base feed
* consumes an EraSkin, it does not decide one.
*/
export type Era = 'reel' | 'broadcast' | 'disc' | 'stream' | 'fortress';
/** Deepest → shallowest, i.e. oldest → newest. "Highest ratified" = last of these unlocked. */
export const ERA_ORDER: readonly Era[] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
export interface EraSkin {
/** the seven SMPTE bars, era-recoloured */
bars: string[];
/** the castellation strip under the bars */
casts: string[];
/** backdrop behind the pattern (matters where bars don't cover) */
bg: string;
/** overlay text + timecode colour */
ink: string;
/** rec-dot / accent colour */
accent: string;
/**
* A full-canvas post-treatment applied after the pattern is drawn. Kept as a tagged union
* (not a callback) so it stays pure data the base feed interprets it.
*/
treatment:
| { kind: 'none' }
| { kind: 'sepia'; sprockets: true } // reel: warm film, sprocket holes
| { kind: 'phosphor' } // broadcast: green cast + interlace
| { kind: 'gloss' } // disc: rainbow-on-black sheen
| { kind: 'bleach' }; // fortress: blinding matte white
}
const STREAM_BARS = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const STREAM_CASTS = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
const SKINS: Record<Era, EraSkin> = {
reel: {
bars: ['#b9a67c', '#c7b06a', '#9c9a72', '#8f9a63', '#a98c6e', '#a06a4e', '#6e5c46'],
casts: ['#5a4a34', '#241d14', '#6e5240', '#241d14', '#5c5a44', '#241d14', '#b9a67c'],
bg: '#181008',
ink: '#f0e0c0',
accent: '#d8a24a',
treatment: { kind: 'sepia', sprockets: true },
},
broadcast: {
// NTSC bars, but everything sunk into a phosphor-green cathode glow.
bars: ['#8fce8f', '#b6c664', '#4fb7b7', '#4fb44f', '#8a5aa8', '#9a4a4a', '#3a4fb0'],
casts: ['#12300f', '#0a140a', '#2a3a20', '#0a140a', '#1a3a30', '#0a140a', '#8fce8f'],
bg: '#03120a',
ink: '#c8ffce',
accent: '#8fffa0',
treatment: { kind: 'phosphor' },
},
disc: {
// Rigid, glossy, rainbow-on-black. DVD menu energy.
bars: ['#d0d0d0', '#d8d800', '#00d8d8', '#00d800', '#d800d8', '#e00000', '#2020ff'],
casts: ['#2020ff', '#000000', '#d800d8', '#000000', '#00d8d8', '#000000', '#d0d0d0'],
bg: '#000000',
ink: '#ffffff',
accent: '#ff3fd4',
treatment: { kind: 'gloss' },
},
stream: {
// The surface world / the original prototype look — the game's "now".
bars: STREAM_BARS,
casts: STREAM_CASTS,
bg: '#101010',
ink: '#f4f4f4',
accent: '#ff4444',
treatment: { kind: 'none' },
},
fortress: {
// The DCP citadel: mathematically perfect, blinding matte white, near-invisible bars.
bars: ['#f7f7f7', '#f2f2e8', '#e8f2f2', '#e8f2e8', '#f2e8f2', '#f2e8e8', '#e8e8f4'],
casts: ['#e6e6ee', '#f4f4f4', '#eee6ee', '#f4f4f4', '#e6eeee', '#f4f4f4', '#f7f7f7'],
bg: '#fbfbfb',
ink: '#20242a',
accent: '#2f7d3f',
treatment: { kind: 'bleach' },
},
};
/** The highest ratified era from a set of unlocked tech, given a tech→era lookup. */
export function highestEra(unlocked: readonly string[], eraOf: (tech: string) => Era | undefined): Era {
let best = 0; // default: reel (the world starts in the deepest stratum you can reach)
for (const t of unlocked) {
const e = eraOf(t);
if (e === undefined) continue;
const idx = ERA_ORDER.indexOf(e);
if (idx > best) best = idx;
}
return ERA_ORDER[best];
}
export function eraSkin(era: Era): EraSkin {
return SKINS[era];
}

View File

@ -13,6 +13,7 @@
* would cost more than the whole effect is allowed to cost.
*/
import { BASE_H, BASE_W, type BaseFeed } from './baseTexture';
import type { Era } from './eras';
import type { Overlays } from './overlays';
import { PARAMS, type Param, type ParamSet } from './params';
@ -29,10 +30,15 @@ uniform float t;
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
uniform float u_block, u_ring, u_moire;
uniform float u_drift, u_brownout, u_flash;
uniform float u_strain, u_fever;
uniform float u_strain, u_fever, u_pallor, u_sterile;
uniform vec2 u_moireSeed;
uniform sampler2D u_stampTex, u_scramTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
uniform float u_wildlife;
uniform vec2 u_wildlifeSeed;
uniform float u_mite;
uniform vec2 u_miteSeed;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_placardTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title;
uniform float u_placard, u_placardScale;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
@ -177,6 +183,17 @@ void main(){
float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0);
col = mix(col, vec3(sn), u_noise * 0.7);
// ---- wildlife shimmer: a mosquito swarm degrading a REGION of the sky. Same growth-mask
// template as moire, seeded from the swarm's world position — high-frequency crawling
// luminance noise (literal mosquito noise) rather than the flat static above.
if(u_wildlife > 0.001){
float wm = growthMask(vUv, u_wildlifeSeed, u_wildlife*0.7) * clamp(u_wildlife*1.6, 0.0, 1.0);
if(wm > 0.001){
float sk = hash(floor(vUv*vec2(320.0,180.0)) + floor(tt*20.0)); // crawls fast, per-frame
col = mix(col, col*0.6 + vec3(sk)*0.5, wm*0.7);
}
}
// freeze dropout: grey flash lines
float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0))));
col = mix(col, vec3(0.45), dropo * u_freeze);
@ -201,6 +218,43 @@ void main(){
// fever runs warm: the picture itself develops a temperature
col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55);
// ---- pallor: the second dread. Not urgency — malaise. An empty reserve drains the colour
// out of the sky and closes the corners in. Codex §8: colour IS the resource, so a factory
// with no cushion literally has less of it. Chronic, and it sits there in surplus too.
if(u_pallor > 0.001){
col = mix(col, vec3(lu(col)), u_pallor*0.5);
vec2 pd = vUv - 0.5; pd.x *= 1.78;
float vig = smoothstep(0.34, 0.95, length(pd));
col *= 1.0 - vig*u_pallor*0.75;
}
// ---- debt band: PARITY DEBT at SEALED (band 3) or worse. Literally the pallor machinery
// above with its sign flipped — same radii, same falloff, white instead of grey. Pallor is
// the corners closing into darkness because you are poor; this is the corners washing out
// under institutional light because you are being LOOKED AT. Faintest reading on the sky by
// design: it is a standing condition, not an event, and it must never out-shout the artifacts.
if(u_sterile > 0.001){
vec2 sd = vUv - 0.5; sd.x *= 1.78;
float svig = smoothstep(0.34, 0.95, length(sd));
col = mix(col, vec3(0.95,0.97,0.96), svig*u_sterile*0.42);
}
// ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy
// parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of
// corruption — it desaturates and washes the region toward clinical white with a faint
// manufactured tile grid. It sits on TOP of the corruption stack because it is erasure: your
// mess is yours, this is your work being quietly deleted. Meant to feel WORSE than rot.
if(u_mite > 0.001){
float mmask = growthMask(vUv, u_miteSeed, u_mite*0.7) * clamp(u_mite*1.7, 0.0, 1.0);
if(mmask > 0.001){
vec3 sterile = mix(vec3(lu(col)), vec3(0.93,0.96,0.95), 0.72); // desaturate → cool white
sterile = mix(sterile, vec3(0.98), 0.35); // lift toward clinical bright
float tile = max(step(0.93, fract(vUv.x*64.0)), step(0.93, fract(vUv.y*36.0)));
sterile = mix(sterile, sterile*0.9, tile*0.6); // faint manufactured grid
col = mix(col, sterile, mmask*0.9);
}
}
// faint scanlines for vibe (constant, not scored)
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
@ -214,6 +268,19 @@ void main(){
}
}
// ---- the Correction's placard: it signs one unit off the ledger (a mite's repair) or takes
// a whole sector (a gunship's mirror). ONE slot, two painters — same faction, same voice, so a
// burst of Corrections queues instead of overlapping. Small, perfect, screen space
// (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
if(u_placard > 0.001){
vec2 halfSize = vec2(0.150, 0.082)*u_placardScale;
vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5;
if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
vec4 rp = texture2D(u_placardTex, ruv);
col = mix(col, rp.rgb, rp.a*u_placard);
}
}
// ---- scram: the tape loses tracking for exactly one frame
if(u_scram > 0.5){
float halfH = 0.085;
@ -227,12 +294,26 @@ void main(){
// the awakening: one frame of full white on the first shipment ever
col = mix(col, vec3(1.0), u_flash);
// ---- ratification: The Correction takes the sky. Composited dead last and in screen space,
// so not one pixel of it is corrupted — that is the entire point of the joke.
if(u_card > 0.001){
col = mix(col, texture2D(u_cardTex, vUv).rgb, u_card);
}
// ---- boot title: WIMVEE FKTRY burn-in. Premultiplied-ish over black so it can dissolve.
if(u_title > 0.001){
vec4 ti = texture2D(u_titleTex, vUv);
col = mix(col, ti.rgb, ti.a * u_title);
}
gl_FragColor = vec4(col, 1.0);
}`;
type UniformKey =
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'sterile' | 'moireSeed'
| 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'placardTex'
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'placard' | 'placardScale';
export interface DrawState {
params: ParamSet;
@ -241,16 +322,41 @@ export interface DrawState {
drift: number;
/** 0..1 brownout severity */
brownout: number;
/** 0..1 one-frame white awakening */
/** 0..1 awakening flash (round 5: ramped over ~0.5s, no longer a single frame) */
flash: number;
/** nothing shipped yet — the clean feed still carries its chyron */
idle: boolean;
/** current substrate era for the base feed */
era: Era;
/** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
chyron: string;
/** v9: a hash auditor is holding the feed. Flips the transport legend to HOLD; the CALLER
* freezes `timeSec`, which is what actually stops the picture. */
held: boolean;
/** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */
strain: number;
/** 0..1 feverish shimmer from aggregate machine heat */
fever: number;
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
pallor: number;
/** 0..1 sterile white vignette from the Correction's debt band (>=3 SEALED) */
sterile: number;
/** where the moire crystal grows from, in screen uv */
moireSeed: [number, number];
/** 0..1 regional mosquito shimmer amount (wildlife swarms) */
wildlife: number;
/** where the swarm shimmer grows from, in screen uv */
wildlifeSeed: [number, number];
/** 0..1 hospital-white sterile creep around heavy parity-mite activity (the inverse of rot) */
mite: number;
/** where the sterile patch grows from, in screen uv */
miteSeed: [number, number];
/** 0..1 opacity of the ratification card */
card: number;
/** set when the card canvas has been repainted and needs re-upload */
cardDirty: boolean;
/** 0..1 opacity of the boot title card */
title: number;
/** set when the title canvas has been repainted and needs re-upload */
titleDirty: boolean;
/** 0..1 opacity of the shipment stamp */
stampAmt: number;
/** stamp size multiplier — bigger shipments stamp harder */
@ -261,6 +367,12 @@ export interface DrawState {
scramY: number;
/** set when the stamp canvas has been repainted and needs re-upload */
stampDirty: boolean;
/** 0..1 opacity of the Correction's placard (mite repair OR gunship mirror — one slot) */
placard: number;
/** placard size multiplier (slight overshoot on impact, like the stamp) */
placardScale: number;
/** set when the placard canvas has been repainted and needs re-upload */
placardDirty: boolean;
}
export interface GlitchStack {
@ -320,11 +432,17 @@ export function createGlitchStack(
return t;
}
base.update(0, true);
base.update({ timeMs: 0, era: 'stream', chyron: 'NOTHING IS WRONG' });
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
overlays.paintCard('reel', '');
overlays.paintTitle();
overlays.paintRepair(''); // allocate the Correction's placard texture up front
const baseTex = makeTex(0, base.canvas);
const stampTex = makeTex(1, overlays.stampCanvas);
makeTex(2, overlays.scramCanvas);
const cardTex = makeTex(3, overlays.cardCanvas);
const titleTex = makeTex(4, overlays.titleCanvas);
const placardTex = makeTex(5, overlays.placardCanvas);
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
uni.t = gl.getUniformLocation(prog, 't');
@ -333,25 +451,41 @@ export function createGlitchStack(
uni.flash = gl.getUniformLocation(prog, 'u_flash');
uni.strain = gl.getUniformLocation(prog, 'u_strain');
uni.fever = gl.getUniformLocation(prog, 'u_fever');
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
uni.sterile = gl.getUniformLocation(prog, 'u_sterile');
uni.card = gl.getUniformLocation(prog, 'u_card');
uni.title = gl.getUniformLocation(prog, 'u_title');
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife');
uni.wildlifeSeed = gl.getUniformLocation(prog, 'u_wildlifeSeed');
uni.mite = gl.getUniformLocation(prog, 'u_mite');
uni.miteSeed = gl.getUniformLocation(prog, 'u_miteSeed');
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
uni.scram = gl.getUniformLocation(prog, 'u_scram');
uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
uni.placard = gl.getUniformLocation(prog, 'u_placard');
uni.placardScale = gl.getUniformLocation(prog, 'u_placardScale');
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1);
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
gl.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4);
gl.uniform1i(gl.getUniformLocation(prog, 'u_placardTex'), 5);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uni.drift, 1);
return {
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed,
stampAmt, stampScale, scram, scramY, stampDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
if (base.update(timeSec * 1000, idle)) {
draw({ params, timeSec, drift, brownout, flash, era, chyron, held, strain, fever, pallor, sterile,
moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram, scramY, stampDirty,
placard, placardScale, placardDirty,
card, cardDirty, title, titleDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / era / chyron).
if (base.update({ timeMs: timeSec * 1000, era, chyron, held })) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, baseTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
@ -361,17 +495,42 @@ export function createGlitchStack(
gl.bindTexture(gl.TEXTURE_2D, stampTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas);
}
if (cardDirty) {
gl.activeTexture(gl.TEXTURE0 + 3);
gl.bindTexture(gl.TEXTURE_2D, cardTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.cardCanvas);
}
if (titleDirty) {
gl.activeTexture(gl.TEXTURE0 + 4);
gl.bindTexture(gl.TEXTURE_2D, titleTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
}
if (placardDirty) {
gl.activeTexture(gl.TEXTURE0 + 5);
gl.bindTexture(gl.TEXTURE_2D, placardTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.placardCanvas);
}
gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift);
gl.uniform1f(uni.brownout, brownout);
gl.uniform1f(uni.flash, flash);
gl.uniform1f(uni.strain, strain);
gl.uniform1f(uni.fever, fever);
gl.uniform1f(uni.pallor, pallor);
gl.uniform1f(uni.sterile, sterile);
gl.uniform1f(uni.card, card);
gl.uniform1f(uni.title, title);
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
gl.uniform1f(uni.wildlife, wildlife);
gl.uniform2f(uni.wildlifeSeed, wildlifeSeed[0], wildlifeSeed[1]);
gl.uniform1f(uni.mite, mite);
gl.uniform2f(uni.miteSeed, miteSeed[0], miteSeed[1]);
gl.uniform1f(uni.stampAmt, stampAmt);
gl.uniform1f(uni.stampScale, stampScale);
gl.uniform1f(uni.scram, scram ? 1 : 0);
gl.uniform1f(uni.scramY, scramY);
gl.uniform1f(uni.placard, placard);
gl.uniform1f(uni.placardScale, placardScale);
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},

View File

@ -15,7 +15,10 @@ import { createGlitchStack, type GlitchStack } from './glitchStack';
import { createOverlays } from './overlays';
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
import { computeStrain } from './strain';
import { computeStrain, sterileForBand } from './strain';
import { freshness } from './boredom';
import { ERA_ORDER, highestEra, type Era } from './eras';
import { getSubtitle } from '../audio/subtitleBus';
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
const EASE_TAU = 1.2;
@ -28,6 +31,8 @@ const WOBBLE_DEPTH = 0.12;
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05];
/** Seconds a shipment stamp stays on the feed. */
const STAMP_LIFE = 0.4;
/** The Correction's placard lingers a touch longer than a shipment stamp — read at leisure. */
const PLACARD_LIFE = 0.55;
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
const STAMP_QUEUE_MAX = 2;
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
@ -35,8 +40,28 @@ const SCRAM_FRAMES = 3;
/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */
const STRAIN_TAU = 0.35;
const FEVER_TAU = 1.5;
/** Pallor is chronic, so it should drift rather than react. */
const PALLOR_TAU = 2.0;
/**
* The ratification card is timed in SECONDS, not frames: it's a broadcast interruption, so it
* must last one second whatever the refresh rate. (The scram wipe is frame-counted on purpose
* that one is a tape artifact, and tape artifacts are quantized to frames.)
*/
const CARD_SECONDS = 1;
/** Boot title: hold the burn-in, then dissolve to the NOTHING IS WRONG feed. */
const TITLE_HOLD = 1.5;
const TITLE_FADE = 0.6;
/** Round 5: the awakening is no longer one frame but a ~0.5s full-canvas moment. */
const FLASH_SECONDS = 0.5;
interface Stamp { name: string; count: number; color: string; age: number }
/**
* One entry in The Correction's placard queue. `kind` picks the painter, NOT the arithmetic:
* a mite's repair and a gunship's mirror take exactly the same unit off exactly the same
* ledger, and they share one overlay slot so the faction speaks with one voice.
*/
interface Placard { name: string; kind: 'repair' | 'mirror'; age: number }
interface Swarm { pos: { x: number; y: number }; size: number }
/**
* Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given
@ -51,12 +76,38 @@ function seedFromTick(tick: number): [number, number] {
return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5];
}
/** Swarms live on the factory floor; hash their world position to a stable spot on the sky. */
function seedFromPos(pos: { x: number; y: number }): [number, number] {
const h = (n: number) => {
const x = Math.sin(n) * 43758.5453;
return x - Math.floor(x);
};
return [0.2 + h(pos.x * 12.9898 + pos.y * 78.233) * 0.6, 0.2 + h(pos.x * 39.3468 + pos.y * 11.135) * 0.6];
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
function smoothstep(e0: number, e1: number, x: number): number {
const t = clamp01((x - e0) / (e1 - e0));
return t * t * (3 - 2 * t);
}
/** Boot title: hold at full, then dissolve. Returns 0 once the sequence is over. */
function titleOpacity(age: number): number {
if (age <= TITLE_HOLD) return 1;
return 1 - smoothstep(TITLE_HOLD, TITLE_HOLD + TITLE_FADE, age);
}
/**
* The awakening. A blown-out hold then a fast decay the sky is stunned for a moment rather
* than blinking once. Returns 0 when not flashing (age < 0).
*/
function flashEnvelope(age: number): number {
if (age < 0) return 0;
if (age >= FLASH_SECONDS) return 0;
if (age < 0.12) return 1;
return 1 - smoothstep(0.12, FLASH_SECONDS, age);
}
export function createScreenFX(): ScreenFX {
let stack: GlitchStack | null = null;
const overlays = createOverlays();
@ -72,20 +123,64 @@ export function createScreenFX(): ScreenFX {
let brownout = 0;
let brownoutTarget = 0;
let pendingFlash = false;
let hasShipped = false;
const stampQueue: Stamp[] = [];
let stamp: Stamp | null = null;
let stampDirty = false;
const placardQueue: Placard[] = [];
let placard: Placard | null = null;
let placardDirty = false;
let scramFrames = 0;
let scramY = 0.5;
let strain = 0;
let fever = 0;
let pallor = 0;
let sterile = 0;
/** Debt band from `debtBand` events — the fallback when no snapshot.correction is present. */
let eventBand = 0;
/**
* Live hash-auditor holds, keyed by the event's `lockHash`. A set rather than a flag so
* overlapping audits nest correctly: two auditors both holding means one release cannot
* restart the clock under the other one's feet.
*/
const stasisHolds = new Set<string>();
/**
* THE SKY'S OWN CLOCK. Everything the picture animates on reads this, not wall time: the
* timecode, the drift, the shader's `t`, the mature-corruption wobble. A hash auditor's
* stasis simply stops it accumulating the feed is not damaged, it is administered and
* releasing resumes from the stopped value, so the sky comes back exactly where it left
* rather than jumping forward by however long the audit took.
*/
let feedSec = 0;
/** Last resolved values of the two Correction readings, for the DEV stats readout only. */
let heldNow = false;
let bandNow = 0;
let moireSeed: [number, number] = [0.5, 0.44];
let moireSeeded = false;
let lastShipMs = 0;
let markShip = false;
let fresh = 1;
let cardAge = -1; // <0 = no card on screen
let cardDirty = false;
const eras = new Map<string, Era>();
// Part A round 5:
let era: Era = ERA_ORDER[0]; // the world starts in the deepest stratum (reel beds)
let forcedEra: Era | null = null; // debug hook override
let wildlife = 0; // eased regional-shimmer amount
let wildlifeSeed: [number, number] = [0.5, 0.5];
const swarms = new Map<number, Swarm>(); // event-tracked fallback when no snapshot
let mite = 0; // eased hospital-white sterile creep amount
let miteSeed: [number, number] = [0.5, 0.5];
const mites = new Map<number, Swarm>(); // event-tracked parity-mite fallback
let titleAge = 0; // boot title sequence clock
let titleDirty = true; // painted once at stack creation; upload on the first frame
let flashAge = -1; // <0 = not flashing
let lastMs = 0;
let frameCost = 0; // rolling avg ms of this module's CPU work
@ -101,9 +196,10 @@ export function createScreenFX(): ScreenFX {
if (count <= 0) return;
tally.set(item, (tally.get(item) ?? 0) + count);
ledgerDirty = true;
markShip = true; // the sky stops forgetting the moment anything lands
if (!hasShipped) {
hasShipped = true;
pendingFlash = true; // the awakening
flashAge = 0; // the awakening — a ~0.5s moment, ramped in frame()
}
// The crystal takes root where and when it first arrived, and grows from there.
if (!moireSeeded && (corruptionFor(item).weights.moire ?? 0) > 0) {
@ -121,11 +217,36 @@ export function createScreenFX(): ScreenFX {
while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
}
return {
/**
* THE CORRECTION TAKES ONE UNIT. The single decrement path a parity mite's `repaired` and a
* redundancy gunship's `mirrored` both come through here, because they are the same theft at
* two scales and forking them would be how the two slowly stopped agreeing.
*
* This is THEFT, not decay: the LEDGER drops by exactly that item's per-unit contribution, a
* real permanent removal unlike boredom, which only fades the display and leaves the tally
* intact to snap back. We deliberately do NOT refresh the boredom clock either way: a factory
* being quietly eaten is not a factory that is producing.
*/
function correct(item: string, kind: 'repair' | 'mirror') {
const cur = tally.get(item) ?? 0;
if (cur > 0) {
tally.set(item, cur - 1);
ledgerDirty = true;
}
const def = items.get(item);
placardQueue.push({ name: def?.name ?? item.toUpperCase(), kind, age: 0 });
while (placardQueue.length > STAMP_QUEUE_MAX) placardQueue.shift();
}
let liveCanvas: HTMLCanvasElement | null = null;
const api: ScreenFX = {
init(canvas: HTMLCanvasElement, data: GameData) {
liveCanvas = canvas;
const base = createBaseFeed();
stack = createGlitchStack(canvas, base, overlays);
for (const it of data.items) items.set(it.id, it);
for (const t of data.tech) eras.set(t.id, t.era);
const unmapped = unmappedItems(data);
if (unmapped.length) {
@ -141,6 +262,29 @@ export function createScreenFX(): ScreenFX {
ship,
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; },
setEra: (e: Era) => { forcedEra = e; },
skipTitle: () => { titleAge = TITLE_HOLD + TITLE_FADE + 1; },
swarm: (on: boolean, id = 1, pos = { x: 3, y: -2 }) => {
if (on) swarms.set(id, { pos, size: 0.7 });
else swarms.delete(id);
},
mite: (on: boolean, id = 1, pos = { x: -4, y: 5 }, size = 0.7) => {
if (on) mites.set(id, { pos, size });
else mites.delete(id);
},
// Both drive the ONE decrement path, so the debug hook can never disagree with the
// event handler about what a Correction costs.
repair: (item: string, count = 1) => {
for (let i = 0; i < count; i++) correct(item, 'repair');
},
mirror: (item: string, count = 1) => {
for (let i = 0; i < count; i++) correct(item, 'mirror');
},
stasis: (on: boolean, lockHash = 'dev') => {
if (on) stasisHolds.add(lockHash);
else stasisHolds.delete(lockHash);
},
band: (n: number) => { eventBand = n; },
reset: () => {
tally.clear();
ledgerDirty = true;
@ -151,14 +295,52 @@ export function createScreenFX(): ScreenFX {
moireSeed = [0.5, 0.44];
strain = 0;
fever = 0;
pallor = 0;
sterile = 0;
eventBand = 0;
bandNow = 0;
heldNow = false;
stasisHolds.clear();
feedSec = 0;
cardAge = -1;
flashAge = -1;
lastShipMs = 0;
fresh = 1;
forcedEra = null;
swarms.clear();
wildlife = 0;
mites.clear();
mite = 0;
placard = null;
placardQueue.length = 0;
for (const p of PARAMS) eased[p] = 0;
},
research: (tech: string) => {
overlays.paintCard(eras.get(tech) ?? 'reel', tech);
cardDirty = true;
cardAge = 0;
},
stats: () => ({
units: +units.toFixed(2),
total: +total.toFixed(3),
displayTotal: +(total * fresh).toFixed(3),
freshness: +fresh.toFixed(3),
idle: !hasShipped,
era,
strain: +strain.toFixed(3),
fever: +fever.toFixed(3),
pallor: +pallor.toFixed(3),
sterile: +sterile.toFixed(3),
band: bandNow,
held: heldNow,
holds: stasisHolds.size,
feedSec: +feedSec.toFixed(4),
wildlife: +wildlife.toFixed(3),
mite: +mite.toFixed(3),
placard: placard ? placard.kind : null,
card: cardAge >= 0 ? 1 : 0,
title: +titleOpacity(titleAge).toFixed(3),
flash: +flashEnvelope(flashAge).toFixed(3),
moireSeed: moireSeed.map((n) => +n.toFixed(3)),
brownout: +brownout.toFixed(2),
msPerFrame: +frameCost.toFixed(3),
@ -166,6 +348,11 @@ export function createScreenFX(): ScreenFX {
params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])),
}),
escalationItems: ESCALATION_ITEMS,
// Verification driver: a hidden harness tab throttles rAF, so pump the REAL module with
// a synthetic timebase and read pixels in the same JS task (prior-round contact-sheet
// trick — a hidden tab clears the WebGL buffer after compositing otherwise).
frame: (ms: number, snap?: SimSnapshot) => api.frame(ms, snap),
canvas: () => liveCanvas,
};
},
@ -178,6 +365,36 @@ export function createScreenFX(): ScreenFX {
// event rather than random, so the same run always wipes the same way.
scramFrames = SCRAM_FRAMES;
scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
} else if (e.kind === 'researched') {
// The Correction takes the sky for a second, every time the player does science.
overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech);
cardDirty = true;
cardAge = 0;
} else if (e.kind === 'wildlife' && e.wildlife === 'mosquito-swarm') {
// Event-tracked swarms are the fallback when no snapshot.wildlife is available
// (debug hook). When a snapshot IS present, frame() reads it as the source of truth.
if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 });
else swarms.delete(e.id);
} else if (e.kind === 'wildlife' && e.wildlife === 'parity-mite') {
// Parity-mites drive the sterile creep. Same fallback contract as the swarms above:
// snapshot.wildlife is truth when present, these events cover the no-snapshot case.
if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 });
else mites.delete(e.id);
} else if (e.kind === 'repaired') {
// A parity mite corrected one unit of your work, and signs it with the green tick.
correct(e.item, 'repair');
} else if (e.kind === 'mirrored') {
// A redundancy gunship re-encoded a whole sector. Same theft, same ledger, same
// decrement — a different placard, because a sector is not a unit.
correct(e.item, 'mirror');
} else if (e.kind === 'stasis') {
// A hash auditor is holding a rectangle of the factory still while it reads it.
// Keyed by lockHash so nested holds release independently. Stasis auto-releases by
// contract, so the sky can trust it will be handed its clock back.
if (e.on) stasisHolds.add(e.lockHash);
else stasisHolds.delete(e.lockHash);
} else if (e.kind === 'debtBand') {
eventBand = e.band;
}
}
},
@ -192,6 +409,20 @@ export function createScreenFX(): ScreenFX {
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000));
lastMs = timeMs;
// STASIS. Events are the truth here — this is an edge, and main.ts drains every frame —
// while the snapshot corroborates for the case an edge can never cover: a save loaded
// mid-audit, where the hold is a standing fact and no event will ever fire for it.
let held = stasisHolds.size > 0;
if (!held && snap?.correction) {
for (const u of snap.correction.units) {
if (u.phase === 'stasis') { held = true; break; }
}
}
// The sky's clock stops. Nothing else does: the ledger still books shipments, stamps
// still land, the strain still reads. The picture is held, the paperwork is not.
if (!held) feedSec += dt;
heldNow = held;
if (ledgerDirty) recompute();
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
@ -200,14 +431,76 @@ export function createScreenFX(): ScreenFX {
const target = computeStrain(snap);
strain = ease(strain, target.tremor, dt, STRAIN_TAU);
fever = ease(fever, target.fever, dt, FEVER_TAU);
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
const timeSec = timeMs / 1000;
// The debt band. Snapshot is truth when The Correction exists at all; the `debtBand` edge
// covers the no-snapshot path. Eased on the PALLOR tau because it is the same kind of
// reading: chronic, a standing condition, never an event.
const band = snap?.correction ? snap.correction.band : eventBand;
bandNow = band;
sterile = ease(sterile, sterileForBand(band), dt, PALLOR_TAU);
// The substrate ages with the highest ratified era. Corruption stays era-agnostic —
// only what it is painted ONTO changes.
era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era);
// Jammed-rot: a factory stuck solid rots faster than one resting. Ratio of jammed
// machines, so a single stuck belt in a big base barely registers.
let rot = 0;
if (snap && snap.entities.length) {
let jammed = 0;
for (const e of snap.entities) if (e.jammed) jammed++;
rot = jammed / snap.entities.length;
}
// Boredom: the DISPLAY decays, the ledger does not. `fresh` scales what's shown; the
// tally underneath is untouched, so resuming production snaps the targets straight back
// and the easing walks the picture home in about a second.
if (markShip) {
lastShipMs = timeMs;
markShip = false;
}
fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000, rot) : 1;
// Wildlife shimmer: drive the region from the WORST swarm — one legible infestation
// beats an averaged smear. Snapshot is truth when present; events are the fallback.
let worst: Swarm | null = null;
const live = snap?.wildlife;
if (live) {
for (const w of live) {
if (w.kind !== 'mosquito-swarm') continue;
if (!worst || w.size > worst.size) worst = { pos: w.pos, size: w.size };
}
} else {
for (const w of swarms.values()) if (!worst || w.size > worst.size) worst = w;
}
if (worst) wildlifeSeed = seedFromPos(worst.pos);
wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0);
// Mite sterility: same worst-of pattern, but for parity-mites. The sterile creep grows
// around the heaviest infestation; snapshot is truth when present, events are the fallback.
let worstMite: Swarm | null = null;
if (live) {
for (const w of live) {
if (w.kind !== 'parity-mite') continue;
if (!worstMite || w.size > worstMite.size) worstMite = { pos: w.pos, size: w.size };
}
} else {
for (const m of mites.values()) if (!worstMite || m.size > worstMite.size) worstMite = m;
}
if (worstMite) miteSeed = seedFromPos(worstMite.pos);
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
const timeSec = feedSec;
const displayTotal = total * fresh;
const wobble =
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
displayTotal > WOBBLE_THRESHOLD
? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
: 0;
for (let i = 0; i < PARAMS.length; i++) {
const p = PARAMS[i];
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
eased[p] = ease(eased[p], targets[p] * fresh, dt, EASE_TAU);
// Wobble rides on top of the eased value; it never feeds back into the ledger.
const w =
wobble > 0
@ -234,17 +527,64 @@ export function createScreenFX(): ScreenFX {
if (stamp.age >= STAMP_LIFE) stamp = null;
}
const flash = pendingFlash ? 1 : 0;
// The Correction's placard lifecycle — its own slot so it never fights the shipment stamp,
// and ONE slot for both painters so repairs and mirrors queue rather than overlap. Same
// punch-in-hold-fade shape, held a touch longer because it is a signature to be read.
if (!placard && placardQueue.length) {
placard = placardQueue.shift()!;
if (placard.kind === 'mirror') overlays.paintMirror(placard.name);
else overlays.paintRepair(placard.name);
placardDirty = true;
}
let placardAmt = 0;
let placardScale = 1;
if (placard) {
const a = placard.age / PLACARD_LIFE;
placardAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
placardScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
placard.age += dt;
if (placard.age >= PLACARD_LIFE) placard = null;
}
// The card holds at full opacity and then cuts — no fade. The corruption crashes back in
// rather than seeping back; a dissolve would make it look negotiable.
let card = 0;
if (cardAge >= 0) {
card = 1;
cardAge += dt;
if (cardAge >= CARD_SECONDS) cardAge = -1;
}
// Boot title, then the awakening — both are timed moments, not single frames.
const title = titleOpacity(titleAge);
if (title > 0) titleAge += dt;
const flash = flashEnvelope(flashAge);
if (flashAge >= 0) {
flashAge += dt;
if (flashAge >= FLASH_SECONDS) flashAge = -1;
}
// The chyron: a radio caption outranks the idle placard — when THE SPEAKER is talking,
// the sky subtitles it. Suppressed under the boot title (which covers the feed anyway).
const subtitle = getSubtitle();
const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG');
stack.draw({
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
strain, fever, moireSeed,
params: out, timeSec, drift: 1, brownout, flash, era, chyron, held,
strain, fever, pallor, sterile, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
placard: placardAmt, placardScale, placardDirty,
card, cardDirty, title, titleDirty,
});
pendingFlash = false;
if (scramFrames > 0) scramFrames--;
stampDirty = false;
placardDirty = false;
cardDirty = false;
titleDirty = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
},
};
return api;
}

View File

@ -10,12 +10,60 @@ export const STAMP_W = 256;
export const STAMP_H = 128;
export const SCRAM_W = 512;
export const SCRAM_H = 64;
export const CARD_W = 640;
export const CARD_H = 360;
/**
* THE CORRECTION'S PLACARD SLOT. One canvas, one shader unit, two painters: `paintRepair` for a
* parity mite's correction and `paintMirror` for a redundancy gunship's sector theft. They share
* the slot deliberately both are the same faction signing the same ledger, and the sharing is
* what keeps a burst of Corrections reading as one voice instead of two competing overlays.
* Small on purpose: a signature, not a broadcast.
*/
export const PLACARD_W = 224;
export const PLACARD_H = 112;
/**
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id
* The Correction does not care what you unlocked, only which format it just blessed.
*/
const ERA_STANDARD: Record<string, string> = {
reel: 'OPTICAL FILM',
broadcast: 'NTSC / PAL ANALOG',
disc: 'MPEG-2 / DVD',
stream: 'H.264 / HEVC / AV1',
fortress: 'DCP',
};
export function eraStandard(era: string): string {
return ERA_STANDARD[era] ?? era.toUpperCase();
}
export interface Overlays {
stampCanvas: HTMLCanvasElement;
scramCanvas: HTMLCanvasElement;
cardCanvas: HTMLCanvasElement;
titleCanvas: HTMLCanvasElement;
/** The Correction's placard slot — repaint it with paintRepair OR paintMirror. */
placardCanvas: HTMLCanvasElement;
/** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */
paintStamp(name: string, count: number, color: string): void;
/** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */
paintCard(era: string, tech: string): void;
/** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */
paintTitle(): void;
/**
* Repaint the placard as a MITE REPAIR The Correction signing one unit of your work off the
* ledger. Small, perfect (no crooked stamp), hospital white with the faction green tick:
* deliberately the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft.
*/
paintRepair(name: string): void;
/**
* Repaint the placard as a GUNSHIP MIRROR a whole sector re-encoded out from under you.
* Same hospital white, but ARCHITECTURAL where the mite's is clerical: no green tick, a
* drafted sector outline with registration marks instead. A mite corrects a unit; a gunship
* redraws the map, and the placard should read as a plan rather than a signature.
*/
paintMirror(name: string): void;
}
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
@ -61,9 +109,107 @@ export function createOverlays(): Overlays {
scramCanvas.height = SCRAM_H;
paintScramBar(scramCanvas.getContext('2d')!);
const cardCanvas = document.createElement('canvas');
cardCanvas.width = CARD_W;
cardCanvas.height = CARD_H;
const cg = cardCanvas.getContext('2d')!;
const titleCanvas = document.createElement('canvas');
titleCanvas.width = CARD_W;
titleCanvas.height = CARD_H;
const tg = titleCanvas.getContext('2d')!;
const placardCanvas = document.createElement('canvas');
placardCanvas.width = PLACARD_W;
placardCanvas.height = PLACARD_H;
const rg = placardCanvas.getContext('2d')!;
return {
stampCanvas,
scramCanvas,
cardCanvas,
titleCanvas,
placardCanvas,
paintTitle() {
// The game finally says its own name. A CRT burn-in: chunky phosphor letters with a
// hot-magenta / cyan chromatic ghost, over transparent black so it can dissolve to the
// NOTHING IS WRONG feed. Alpha is what the shader mixes on.
tg.clearRect(0, 0, CARD_W, CARD_H);
tg.fillStyle = 'rgba(3,2,8,0.92)';
tg.fillRect(0, 0, CARD_W, CARD_H);
const cx = CARD_W / 2;
tg.textAlign = 'center';
tg.textBaseline = 'middle';
tg.font = "bold 58px 'Courier New', monospace";
// chromatic aberration ghosts (the prototype's signature look)
tg.fillStyle = '#3fffe0';
tg.fillText('WIMVEE', cx - 3, 150);
tg.fillStyle = '#ff2222';
tg.fillText('WIMVEE', cx + 3, 150);
tg.fillStyle = '#f0ffe8';
tg.fillText('WIMVEE', cx, 150);
tg.fillStyle = '#3fffe0';
tg.fillText('FKTRY', cx - 3, 214);
tg.fillStyle = '#ff2222';
tg.fillText('FKTRY', cx + 3, 214);
tg.fillStyle = '#ff3fd4';
tg.fillText('FKTRY', cx, 214);
tg.fillStyle = '#4a6b4a';
tg.font = "14px 'Courier New', monospace";
tg.fillText('A DECODER DAEMON PRODUCTION', cx, 268);
tg.font = "11px 'Courier New', monospace";
tg.fillText('PLEASE STAND BY', cx, 300);
// faint scanlines baked in for the burn-in feel
tg.fillStyle = 'rgba(0,0,0,0.22)';
for (let y = 0; y < CARD_H; y += 3) tg.fillRect(0, y, CARD_W, 1);
tg.textAlign = 'left'; // restore default for the other painters
},
paintCard(era: string, tech: string) {
// The Correction wins the sky for one second: hospital white, green checkmark, and the
// absolute confidence of a compliance notice. Nothing here is corrupted — that's the joke.
cg.fillStyle = '#f2f5f2';
cg.fillRect(0, 0, CARD_W, CARD_H);
cg.strokeStyle = '#cfd8cf';
cg.lineWidth = 2;
cg.strokeRect(24, 24, CARD_W - 48, CARD_H - 48);
cg.textBaseline = 'middle';
cg.fillStyle = '#12160f';
cg.font = "bold 40px 'Courier New', monospace";
const title = 'STANDARD RATIFIED';
cg.fillText(title, (CARD_W - cg.measureText(title).width) / 2, 132);
const standard = eraStandard(era);
cg.fillStyle = '#2f7d3f';
cg.font = "bold 28px 'Courier New', monospace";
cg.fillText(standard, (CARD_W - cg.measureText(standard).width) / 2, 186);
// green tick — The Correction's faction uniform (codex §8)
cg.strokeStyle = '#2f7d3f';
cg.lineWidth = 7;
cg.lineCap = 'round';
cg.lineJoin = 'round';
cg.beginPath();
cg.moveTo(CARD_W / 2 - 26, 236);
cg.lineTo(CARD_W / 2 - 8, 254);
cg.lineTo(CARD_W / 2 + 28, 218);
cg.stroke();
cg.fillStyle = '#5c6b5c';
cg.font = "13px 'Courier New', monospace";
const sub = 'CONFORMANCE VERIFIED · NO ACTION REQUIRED';
cg.fillText(sub, (CARD_W - cg.measureText(sub).width) / 2, 292);
cg.font = "11px 'Courier New', monospace";
cg.fillText(tech.toUpperCase().replace(/-/g, ' '), 40, 322);
const auth = 'THE CORRECTION';
cg.fillText(auth, CARD_W - 40 - cg.measureText(auth).width, 322);
},
paintStamp(name: string, count: number, color: string) {
sg.clearRect(0, 0, STAMP_W, STAMP_H);
sg.save();
@ -104,5 +250,126 @@ export function createOverlays(): Overlays {
sg.fillText('SHIPPED / NO QUESTIONS', 22, 34);
sg.restore();
},
paintRepair(name: string) {
// The Correction's uniform (codex §8): hospital white, green tick, and the placid
// confidence of a compliance stamp. NOT rotated — where the shipment stamp is thumped
// on by a tired hand, this is machined on square. That contrast is the whole read.
rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
// clean white placard with a thin green rule
rg.fillStyle = '#f2f5f2';
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.fill();
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 3;
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.stroke();
// the green tick — the faction signature
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 5;
rg.lineCap = 'round';
rg.lineJoin = 'round';
rg.beginPath();
rg.moveTo(26, 40);
rg.lineTo(38, 52);
rg.lineTo(60, 26);
rg.stroke();
rg.textBaseline = 'middle';
rg.fillStyle = '#12160f';
rg.font = "bold 20px 'Courier New', monospace";
rg.fillText('CORRECTED', 74, 40);
// the item that was quietly reverted — shrink long codex names to fit the placard
rg.fillStyle = '#5c6b5c';
let size = 15;
rg.font = `${size}px 'Courier New', monospace`;
while (rg.measureText(name).width > PLACARD_W - 40 && size > 8) {
size -= 1;
rg.font = `${size}px 'Courier New', monospace`;
}
rg.fillText(name, 26, 74);
rg.fillStyle = '#8a998a';
rg.font = "9px 'Courier New', monospace";
rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94);
},
paintMirror(name: string) {
// A REDUNDANCY GUNSHIP has re-encoded a sector. Same faction, same hospital white — but
// the mite's placard is CLERICAL (a tick, a signature) and this one is ARCHITECTURAL: a
// drafted sector rectangle with registration marks, the way a plan shows you the block of
// your city that has been compulsorily purchased. No tick anywhere; a tick means a person
// checked something, and nobody checked this.
rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
rg.fillStyle = '#f2f5f2';
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.fill();
// graphite double rule — drawing-office, not clinic
rg.strokeStyle = '#5c6b5c';
rg.lineWidth = 2;
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.stroke();
rg.lineWidth = 1;
rg.strokeStyle = '#b9c4b9';
roundRect(rg, 12, 12, PLACARD_W - 24, PLACARD_H - 24, 3);
rg.stroke();
// the sector diagram: an outlined rect with corner registration ticks and a centre cross.
const bx = 24;
const by = 30;
const bw = 44;
const bh = 44;
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 2;
rg.strokeRect(bx, by, bw, bh);
rg.strokeStyle = '#12160f';
rg.lineWidth = 1;
const tick = 6;
for (const [cx, cy, sx, sy] of [
[bx, by, -1, -1], [bx + bw, by, 1, -1], [bx, by + bh, -1, 1], [bx + bw, by + bh, 1, 1],
] as const) {
rg.beginPath();
rg.moveTo(cx, cy);
rg.lineTo(cx + sx * tick, cy);
rg.moveTo(cx, cy);
rg.lineTo(cx, cy + sy * tick);
rg.stroke();
}
rg.strokeStyle = '#8a998a';
rg.beginPath();
rg.moveTo(bx + bw / 2, by + 8);
rg.lineTo(bx + bw / 2, by + bh - 8);
rg.moveTo(bx + 8, by + bh / 2);
rg.lineTo(bx + bw - 8, by + bh / 2);
rg.stroke();
// the mirror itself: the sector's own diagonal, doubled
rg.strokeStyle = '#2f7d3f';
rg.beginPath();
rg.moveTo(bx + 4, by + bh - 4);
rg.lineTo(bx + bw - 4, by + 4);
rg.stroke();
rg.textBaseline = 'middle';
rg.fillStyle = '#12160f';
rg.font = "bold 19px 'Courier New', monospace";
rg.fillText('MIRRORED', 82, 42);
// the item taken out of the sector — shrink long codex names to fit beside the diagram
rg.fillStyle = '#5c6b5c';
let msize = 14;
rg.font = `${msize}px 'Courier New', monospace`;
while (rg.measureText(name).width > PLACARD_W - 96 && msize > 8) {
msize -= 1;
rg.font = `${msize}px 'Courier New', monospace`;
}
rg.fillText(name, 82, 66);
rg.fillStyle = '#8a998a';
rg.font = "9px 'Courier New', monospace";
rg.fillText('THE CORRECTION · SECTOR RE-ENCODED', 24, 94);
},
};
}

View File

@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { EntityState, SimSnapshot } from '../contracts';
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain';
import { computeStrain, sterileForBand, STERILE_BAND, STRAIN_HORIZON_SECONDS } from './strain';
// v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated
// here now rather than left to break the day the contract tightens.
function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): SimSnapshot {
return {
tick: 0,
@ -14,22 +16,32 @@ function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<En
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth },
shippedTotal: {},
activeCommission: null,
commissionQueue: [],
commissionProgress: {},
research: { active: null, progress: {}, unlocked: [] },
};
}
describe('the NOTHING IS WRONG era stays sterile', () => {
it('is inert with no snapshot at all', () => {
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 0 });
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 0, pallor: 0 });
});
it('does not leak strain into a fresh factory (gen=draw=stored=0)', () => {
// The boot state. Nothing built, nothing drawing: dread here would be a lie.
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0 });
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0, pallor: 0 });
});
it('stays sterile with no tanks built, even though stored is 0', () => {
// The trap in the second dread axis: stored/capacity with no capacity must not read as
// "empty". You cannot be low on a reserve you never built.
expect(computeStrain(snap({ capacity: 0 })).pallor).toBe(0);
expect(computeStrain(snap({ gen: 50, draw: 20, capacity: undefined })).pallor).toBe(0);
});
it('stays calm for a healthy factory running a surplus', () => {
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 });
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500, capacity: 500 })))
.toEqual({ tremor: 0, fever: 0, pallor: 0 });
});
it('stays calm in surplus even with an empty reserve', () => {
@ -82,6 +94,39 @@ describe('tremor tracks seconds-of-reserve, not raw stored', () => {
});
});
describe('the two dreads are different questions', () => {
it('full tank draining fast: tremor without pallor', () => {
// 4 seconds from dark, but the tanks are brimming. Acute, not chronic.
const s = computeStrain(snap({ gen: 0, draw: 10, stored: 40, capacity: 40 }));
expect(s.tremor).toBe(0.5);
expect(s.pallor).toBe(0);
});
it('near-empty tank coasting: pallor without tremor', () => {
// Nothing is draining — draw <= gen — so there is no cliff. But there is also no cushion.
const s = computeStrain(snap({ gen: 100, draw: 100, stored: 5, capacity: 500 }));
expect(s.tremor).toBe(0);
expect(s.pallor).toBeCloseTo(0.99, 5);
});
it('empty tank AND draining: both, which is the real emergency', () => {
const s = computeStrain(snap({ gen: 0, draw: 10, stored: 0, capacity: 500 }));
expect(s.tremor).toBe(1);
expect(s.pallor).toBe(1);
});
it('pallor tracks the fraction, not the amount', () => {
// Same 100 banked reads as comfortable in a small tank and dire in a huge one.
expect(computeStrain(snap({ stored: 100, capacity: 100 })).pallor).toBe(0);
expect(computeStrain(snap({ stored: 100, capacity: 1000 })).pallor).toBeCloseTo(0.9, 5);
});
it('never leaves 0..1 when the sim overfills or overdraws a tank', () => {
expect(computeStrain(snap({ stored: 900, capacity: 500 })).pallor).toBe(0);
expect(computeStrain(snap({ stored: -20, capacity: 500 })).pallor).toBe(1);
});
});
describe('fever reads the machines that actually run hot', () => {
it('is not diluted by a sea of cold belts', () => {
// One decoder at 0.9 surrounded by 50 cold belts is a fever, not a rounding error.
@ -99,3 +144,33 @@ describe('fever reads the machines that actually run hot', () => {
expect(f).toBe(1);
});
});
describe('the debt band reads as light (v9)', () => {
it('shows NOTHING below SEALED — a factory in good standing wears no mark', () => {
// The whole reading is worthless if it is ever faintly on. Bands 0-2 must be exactly zero,
// not "nearly zero": this is the same guarantee as the NOTHING IS WRONG era above.
for (const band of [0, 1, 2]) expect(sterileForBand(band)).toBe(0);
});
it('is absent when The Correction has never been heard from at all', () => {
expect(sterileForBand(undefined)).toBe(0);
expect(sterileForBand(null)).toBe(0);
});
it('appears at SEALED and gets worse at REFUSED', () => {
expect(sterileForBand(STERILE_BAND)).toBeGreaterThan(0);
expect(sterileForBand(4)).toBeGreaterThan(sterileForBand(3));
expect(sterileForBand(4)).toBe(1);
});
it('never exceeds the 0..1 the shader uniform expects', () => {
for (const band of [-1, 0, 1, 2, 3, 4, 99]) {
expect(sterileForBand(band)).toBeGreaterThanOrEqual(0);
expect(sterileForBand(band)).toBeLessThanOrEqual(1);
}
});
it('is a step, not a ramp — SEALED and REFUSED are two rooms', () => {
expect(sterileForBand(4) - sterileForBand(3)).toBeGreaterThan(0.2);
});
});

View File

@ -10,14 +10,28 @@
*/
import type { SimSnapshot } from '../contracts';
/**
* The two dreads (v4). They are deliberately different questions:
*
* tremor "how many seconds until dark?" ACUTE. The cliff edge. Only exists while
* something is actually draining, and it spikes as the runway runs out.
* pallor "how much cushion do I have at all?" CHRONIC. Sits there even in surplus.
* A full tank draining fast is a crisis; a near-empty tank coasting is a way of
* life, and it should feel like malaise rather than alarm.
*
* A factory with 4 seconds left and full tanks reads as a jolt; one permanently scraping the
* bottom of an empty tank reads as washed-out and grim. Same bandwidth system, two feelings.
*/
export interface Strain {
/** 0..1 tremor/desync judder as the bandwidth reserve runs out */
tremor: number;
/** 0..1 slow feverish shimmer from aggregate machine heat */
fever: number;
/** 0..1 pallor: how empty the tanks are, regardless of whether anything is draining */
pallor: number;
}
export const NO_STRAIN: Strain = { tremor: 0, fever: 0 };
export const NO_STRAIN: Strain = { tremor: 0, fever: 0, pallor: 0 };
/**
* How much runway counts as calm. Reserve deeper than this reads as healthy; dread ramps up
@ -40,7 +54,7 @@ export function computeStrain(snap?: SimSnapshot): Strain {
// --- tremor: seconds of reserve remaining at the current deficit.
// Per the v3 units ruling, `stored` is bandwidth-SECONDS while gen/draw are bandwidth per
// second — so stored/deficit is literally "how long until the lights go out".
const { gen, draw, stored } = snap.bandwidth;
const { gen, draw, stored, capacity } = snap.bandwidth;
const deficit = draw - gen;
let tremor = 0;
if (deficit > 0) {
@ -50,6 +64,12 @@ export function computeStrain(snap?: SimSnapshot): Strain {
// A factory in surplus is calm no matter how little it has banked — that is what keeps
// strain out of the NOTHING IS WRONG era, where gen and draw are both zero.
// --- pallor: how empty the tanks are. Unlike tremor this does NOT care about draining;
// it is the standing cost of living hand-to-mouth.
// No tanks built = no cushion to miss = no pallor. That is not a special case for the boot
// state, it is the honest reading: you cannot be low on a reserve you never built.
const pallor = capacity && capacity > 0 ? 1 - clamp01(Math.max(0, stored) / capacity) : 0;
// --- fever: how hot are the machines that actually run hot.
// Averaging across every entity would drown a glowing decoder in a sea of cold belts.
let sum = 0;
@ -62,5 +82,28 @@ export function computeStrain(snap?: SimSnapshot): Strain {
}
const fever = hot > 0 ? clamp01(sum / hot) : 0;
return { tremor, fever };
return { tremor, fever, pallor };
}
/**
* The rung at which The Correction's paperwork reaches the sky.
* Contracts v9 ladder: 0 NOMINAL, 1 OBSERVED, 2 AUDITED, 3 SEALED, 4 REFUSED.
*/
export const STERILE_BAND = 3;
/**
* The debt band, read as light (v9). Below SEALED the sky wears nothing at all: a factory in
* good standing must look exactly like a factory nobody has ever heard of, or the reading means
* nothing when it does appear.
*
* Deliberately NOT folded into computeStrain: the band arrives both as a `debtBand` edge event
* and in `snapshot.correction`, so the compositor resolves the source of truth first and then
* asks this for the amount. Keeping it a one-line pure function is what makes "gone below band
* 3" a unit test instead of a promise.
*/
export function sterileForBand(band: number | undefined | null): number {
if (band == null || band < STERILE_BAND) return 0;
// Two steps, not a ramp: the last rung of the ladder should feel like a different room, and
// there are only two rungs above the threshold to distinguish.
return band >= 4 ? 1 : 0.65;
}

View File

@ -19,6 +19,7 @@
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantAllResearch } from './testkit';
import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -33,6 +34,7 @@ const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
function newSim(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the bloom duplicator is gated behind stream-bloom-loops
return sim;
}
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {

300
fktry/src/sim/constants.ts Normal file
View File

@ -0,0 +1,300 @@
/**
* LANE-SIM's tuning constants the single source of truth.
*
* These were private to index.ts until round 4. They're exported because other lanes'
* tests were hand-copying the numbers and going stale in silence: a copy of 0.7 doesn't
* fail when the sim moves to 0.65, it just quietly asserts the wrong thing forever.
* Import these rather than retyping them.
*
* Anything DATA can already set per-machine (heatPerTick, coolPerTick, bufferCap,
* beltSpeed, coolRadius) lives in data/machines.json, not here. These are the sim-wide
* rules that no machine overrides.
*/
// ------------------------------------------------------------------ belts
/** Minimum gap between two items, in belt-tiles. Enforced across the tile seam too. */
export const BELT_SPACING = 0.45;
/** Items a single belt tile may hold. */
export const BELT_CAPACITY = 2;
/** Items a splitter holds while it waits for an output to open. */
export const SPLITTER_CAPACITY = 2;
// -------------------------------------------------------------- machines
/** A machine stalls ('output full') once any output piles to this multiple of its yield. */
export const OUTPUT_STALL_FACTOR = 4;
/** A machine accepts an input up to this multiple of the recipe requirement. */
export const INPUT_BUFFER_FACTOR = 2;
// ------------------------------------------------------------------ heat
//
// heatPerTick is GROSS (contracts v4): net climb = heatPerTick - coolPerTick, so a
// machine whose heatPerTick never clears its cooling can never overheat.
/** Below this heat, throttling is purely cosmetic. */
export const HEAT_THROTTLE_START = 0.7;
/** Speed multiplier at heat 1.0 — the codex's "tick rate visibly halves". */
export const HEAT_THROTTLE_FLOOR = 0.5;
/** Heat at which a machine scrams. */
export const HEAT_SCRAM = 1;
/** Cool back below this and a scrammed machine restarts. */
export const HEAT_RESTART = 0.5;
/** Per-tick cooling when a machine's def doesn't specify `coolPerTick`. */
export const HEAT_COOL_DEFAULT = 0.004;
/**
* Ceiling on a machine's total cooling once auras stack (own rate + every cooler in
* range). At 0.05 a machine sheds a full 1.0 of heat in 20 ticks (~0.67s), which is
* already effectively instant past this, extra coolers would buy nothing but would
* still cost bandwidth and floor space, and stacking them would look broken rather than
* generous. See NOTES round 4.
*/
export const HEAT_COOL_MAX = 0.05;
// ---------------------------------------------------------------- research
/**
* Ticks a lab spends turning ONE delivered science pack into one unit of progress.
* Research is an investment, not a purchase: at 30 tps this is ~2s per pack, so a
* 10-pack tech is ~20s of lab time on one lab, or ~10s on two (labs parallelize).
* Stepped, not fractional a pack is consumed and progress ticks up by a whole 1 only
* when the timer completes, which keeps `research.progress` integer and the determinism
* trivial to reason about.
*/
export const RESEARCH_TICKS_PER_PACK = 60;
// ---------------------------------------------------------------- wildlife
//
// The dust→swarm loop, M2's living hazard. These item ids are the coupling to DATA: the
// pile grows from the dust a machine is HOLDING (so a factory that belts its dust away
// never hatches), and the trap is any machine whose recipe cans the swarm.
/** The item whose accumulation breeds swarms (codex: HF DUST "attracts and hatches"). */
export const DUST_ITEM = 'hf-dust';
/** The canned live-swarm item — a trap's output, and what commissions ask for. */
export const SWARM_ITEM = 'mosquito-swarm';
/**
* Pile growth per tick per unit of dust BACKED UP in a machine's output buffer where
* "backed up" means beyond one craft's worth (see DUST_SPILL_GRACE). A line that keeps its
* dust moving never crosses the grace threshold and never breeds; a line that ignores it
* climbs to the stall cap and hatches in roughly 400 ticks (~14s). That gap is the mechanic.
*/
export const DUST_GROWTH_PER_HELD = 0.00025;
/**
* A machine may hold this multiple of its own dust yield without shedding any on the floor.
* Normal buffering between belt pushes is not neglect, and punishing it would make the
* hazard feel like weather rather than a consequence.
*/
export const DUST_SPILL_GRACE = 1;
/** A pile at or above this hatches a swarm and is consumed. */
export const DUST_PILE_MAX = 1;
/** Tiles from the emitter a new pile appears (seeded RNG picks the exact offset). */
export const DUST_PILE_SPREAD = 2;
/** Swarm drift toward its target, tiles/sec. */
export const SWARM_DRIFT_TILES_PER_SEC = 1.5;
/** Within this chebyshev range of its target's footprint, a swarm is attached and biting. */
export const SWARM_ATTACH_RANGE = 1;
/** Speed multiplier a fully-severe attached swarm inflicts (0.5 = half speed). */
export const SWARM_SLOW_FLOOR = 0.5;
/** Severity gained per tick while attached, up to 1.0 — the longer it sits, the worse. */
export const SWARM_SEVERITY_GROWTH = 0.004;
/** A trap catches swarms within this chebyshev range of its footprint. */
export const TRAP_RADIUS = 4;
/**
* Live swarms allowed at once. A machine that is permanently jammed on dust would
* otherwise breed without limit measured 29 of them in a 20k demo run, which is a
* plague rather than a hazard. At the cap, full piles simply sit there waiting for room.
*/
export const SWARM_MAX = 8;
// -------------------------------------------------------- parity mites (M3)
//
// THE CORRECTION's first unit (codex §5): hospital-white beetle drones that patrol your
// belts and quietly *repair* tier-2 glitch products back into clean footage. They correct
// CORRUPTION, never raw material — that's both the fiction and the balance, so they only
// ever touch tier-2 cargo. Pressure scales with how much corruption you've shipped: the
// more product goes to THE SCREEN, the more the immune system notices you.
/** The canned-mite item a FIREWALL produces — forward-canon in data; the counter's cargo. */
export const MITE_ITEM = 'parity-mite';
/** Lowest item tier a mite will correct. Below this it's raw material, which they ignore. */
export const MITE_TIER_MIN = 2;
/**
* Tier-2 products shipped per patrolling mite. desiredMites = floor(corruption / this),
* capped at MITE_MAX so the wave escalates with progress and never appears before you've
* shipped real product. At 40 the first mite arrives well after the melt line is flowing.
*/
export const MITE_SPAWN_PER = 40;
/**
* Mites patrolling at once. Caught mites are replaced from the rim while corruption stays
* above threshold the firewall's job is to hold the line, not to win a finite fight.
*/
export const MITE_MAX = 6;
/** A mite drifts toward its chosen belt item at this speed, tiles/sec (it patrols briskly). */
export const MITE_DRIFT_TILES_PER_SEC = 2;
/** Within this chebyshev range of its target item, a mite reaches it and starts repairing. */
export const MITE_REACH_RANGE = 0.75;
/**
* Ticks a mite dwells on station, solder-iron working, before the correction completes. The
* dwell is the window a FIREWALL needs to can it mid-repair grab the mite here and the
* product survives. At 30 (~1s) it reads as "quiet, unhurried work" and gives an armed
* firewall time to snap it before the repair lands.
*/
export const MITE_REPAIR_TICKS = 30;
/** A firewall catches mites within this chebyshev range of its footprint (mirrors the trap). */
export const FIREWALL_RADIUS = 4;
// ------------------------------------------- THE CORRECTION: PARITY DEBT (M3)
//
// One scalar drives the whole escalation ladder. Debt is not anger — The Correction
// cannot perceive you (codex §5), only deviation. Ship corruption and the deviation
// figure climbs; ship boring tier-0/1 material and it is amortised away faster. Five
// bands gate which rungs are dispatched, and the band is the only thing the rest of
// the sim asks about.
/** Lowest item tier that counts as DEVIATION. Below this you are shipping stock footage. */
export const DEBT_TIER_MIN = 2;
/**
* Debt added per tier>=2 item shipped. At 0.0025, 80 corrupt shipments walk a quiet
* factory from NOMINAL to REFUSED but nothing is quiet, so decay and clean shipping
* both pull the other way and the real curve is a negotiation, not a countdown.
*/
export const DEBT_PER_CORRUPT_SHIP = 0.0025;
/**
* Baseline amortisation per tick, applied ALWAYS. Over a 20,000-tick session this is
* 0.4 of debt shed for free enough that an idle factory always drifts back to
* NOMINAL, never enough to outrun a working one.
*/
export const DEBT_DECAY_PER_TICK = 0.00002;
/**
* Debt a single tier-0/1 shipment buys back deliberately about a TWELFTH of what one
* corrupt shipment costs. Measured at parity-ish (0.0006): the reference factory's own
* byproduct stream (1,282 hf dust + 144 slurry over 20k, all of it stuff you are dumping
* rather than choosing) bought 0.89 of credit and pinned the whole ladder at band 2
* forever. Any factory with a fat byproduct lane would have been permanently immune to
* escalation without the player ever deciding anything, which is not a valve, it's a
* loophole. Passive dumping still helps a little; the real lever is the quota below.
*/
export const DEBT_CLEAN_CREDIT_PER_ITEM = 0.0002;
/** Credit cannot bank forever — hoarding clean shipments to pre-pay an audit is not a plan. */
export const DEBT_CLEAN_CREDIT_MAX = 0.25;
/**
* Ceiling on how fast banked clean credit is spent. Without it, one enormous clean
* shipment would vent the whole ladder in a single tick and the bands would strobe.
*/
export const DEBT_CLEAN_DECAY_MAX_PER_TICK = 0.0004;
/**
* Completing a `compliance:true` commission vents this outright. It is deliberately
* worth more than the material: the paperwork is the point, not the luma.
*/
export const DEBT_VENT_COMPLIANCE = 0.2;
/** Prying a compliance seal off adds this. You defaced a notice; that is itself deviation. */
export const DEBT_PER_PRY = 0.05;
/**
* Band floors: index i is the debt at which band i+1 begins.
* 0 NOMINAL | 1 OBSERVED | 2 AUDITED (wardens) | 3 SEALED (gunships) | 4 REFUSED (auditors)
*/
export const DEBT_BANDS = [0.2, 0.4, 0.6, 0.8] as const;
/**
* Bands drop late by this margin the same hysteresis idea as heat's scram/restart.
* Debt hovering exactly on a threshold would otherwise emit `debtBand` every few ticks
* and strobe the UI gauge for the rest of the session.
*/
export const DEBT_BAND_HYSTERESIS = 0.03;
// ----------------------------------------------- THE CORRECTION: units (rungs 2-4)
//
// Arrival is NOT random. Every unit's entry point is a pure function of the target it
// was dispatched to (nearest rim for a warden, the sector itself for the airborne two),
// so the whole subsystem is reproducible without touching the RNG stream — which also
// means wildlife and mites keep the exact spawn positions they had before rung 2 existed.
/** Ticks between a rung leaving (or being canned) and the same rung being dispatched again. */
export const CORRECTION_RESPAWN_TICKS = 900;
/**
* The fax lead time. `notice` is emitted the tick a unit is dispatched and nothing
* happens for 20 seconds fax-first is contractual, and this is the window the player
* has to seal up, abstain, or arm a firewall.
*/
export const WARDEN_NOTICE_TICKS = 600;
/** Warden walking speed, tiles/sec. Heavier and more patient than a mite. */
export const WARDEN_WALK_TILES_PER_SEC = 1.2;
/** Chebyshev range at which a warden is close enough to begin the audit. */
export const WARDEN_REACH_RANGE = 1;
/** Ticks the audit takes, phase progress running 0..1. Visible, interruptible work. */
export const WARDEN_AUDIT_TICKS = 120;
/** Outputs rolled back when the audit lands — "the last N outputs" of codex §5. */
export const WARDEN_ROLLBACK_N = 4;
/** Size of the per-machine output ring buffer the rollback reads. */
export const WARDEN_OUT_HISTORY = 8;
/**
* Ticks of prying to get a compliance seal off. At 90 (~3s) it is a deliberate act with
* a cost attached (DEBT_PER_PRY), not a click the cheap answer is a firewall.
*/
export const PRY_TICKS = 90;
/** Sector edge, in tiles. The world is 64 wide, so the grid is a clean 8x8 of these. */
export const GUNSHIP_SECTOR = 8;
/** Gunship fax lead time. */
export const GUNSHIP_NOTICE_TICKS = 450;
/**
* Mirror progress per unit of DEVIATION crafted inside the tethered sector. At 0.1 a busy
* sector loses a mirrored unit roughly every ten corrupt crafts the parallel copy keeps
* pace with you, which is the horror. A sector busily making ore is never mirrored at all:
* the gunship corrects corruption, not productivity. (Measured at 0.04 the tether was
* routinely broken before the first mirror ever landed, which made the rung invisible.)
*/
export const GUNSHIP_PROGRESS_PER_CRAFT = 0.1;
/**
* Ticks the PARITY ANCHOR may go without crafting before the tether breaks. Starving,
* sealing, idling or removing the anchor all reduce to this one measurement.
*/
export const GUNSHIP_ANCHOR_IDLE_TICKS = 300;
/** Hash auditor fax lead time. */
export const AUDITOR_NOTICE_TICKS = 600;
/** The visible PRESCAN — 5 seconds of being read. Craft during it and it finds you. */
export const AUDITOR_PRESCAN_TICKS = 150;
/**
* Stasis duration. THIS IS A SAFETY, NOT A TUNING KNOB: the lock ALWAYS releases after
* this many ticks whether or not the player does anything at all. Contracts v9 makes
* auto-release contractual, and `correction.test.ts` pins it a change that makes
* stasis escapable only by player action is a bug, however clever.
*/
export const AUDITOR_STASIS_TICKS = 450;
/**
* The material counter (DATA authors the recipe: two inputs on the hex-splicer). Holding
* one of these in any machine inside the prescan rect voids the audit outright the
* auditor reads a signature that checks out and files nothing. Absent from the data, the
* counter simply doesn't exist yet and abstention is the only answer.
*/
export const FORGED_HASH_ITEM = 'forged-hash';
// ------------------------------------------------------------------ relic
/** Nearest a buried relic sits to origin (tiles, euclidean) — well past the starter area. */
export const RELIC_MIN_DIST = 18;
/** Farthest a buried relic sits from origin. */
export const RELIC_MAX_DIST = 28;
/**
* Keep-outs for relic placement, sized to enclose the reference factory's two works the
* western melt line and the eastern research annex so the demo can always dig its relic
* up. A real world has no factory here yet; this just keeps the secret clear of the ground
* everyone builds on. relic.test.ts checks, against the layout's real occupied tiles, that
* a spread of seeds never buries it. When DATA supplies a `relicReserve` the sim prefers
* that corner instead and these become belt-and-braces.
*/
export const RELIC_KEEPOUT = [
{ x0: -33, y0: -16, x1: 2, y1: 16 }, // the melt line
{ x0: 6, y0: -9, x1: 29, y1: 12 }, // the research annex
];
/** Chebyshev range within which an `excavate` uncovers the relic. */
export const RELIC_DIG_RANGE = 1;
// ---------------------------------------------------------------- tracing
/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */
export const MAX_TRACE = 512;

View File

@ -0,0 +1,883 @@
/**
* THE CORRECTION, rungs 2-4 (contracts v9) PARITY DEBT, checksum wardens, redundancy
* gunships, hash auditors.
*
* The design points these tests exist to protect:
* - DEBT is a ledger, not a timer. It is earned by shipping DEVIATION (tier>=2), amortised
* always, and vented deliberately by filing the enemy's own paperwork. Bands gate the
* ladder and nothing else in the sim needs to know more than the band.
* - Fax first, always. Every rung emits `notice` before it does anything, for every unit,
* with no exceptions the warning is the contract.
* - A sealed machine is DEAD until pried, and prying costs debt. Both halves matter: a seal
* you can ignore is set dressing, and a pry that is free makes the seal a click.
* - THE AUDITOR'S AUTO-RELEASE IS A SAFETY. It lifts after AUDITOR_STASIS_TICKS whether or
* not the player does anything at all. There must be no way to make a lock permanent,
* however clever, and the test at the bottom of this file is the thing standing in the way.
*
* Fixture-based, like mite.test.ts: the sim keys off item TIER and recipe shape rather than
* ids, so a small honest factory proves the mechanics and the real data drops straight in.
* The reference-factory tests at the end are the same mechanics against DATA's real tree.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory, referenceFactoryTech, firewallInData } from './reference';
import { grantResearch, setParityDebt } from './testkit';
import {
AUDITOR_NOTICE_TICKS,
AUDITOR_PRESCAN_TICKS,
AUDITOR_STASIS_TICKS,
DEBT_BANDS,
DEBT_PER_CORRUPT_SHIP,
DEBT_PER_PRY,
DEBT_VENT_COMPLIANCE,
FORGED_HASH_ITEM,
GUNSHIP_NOTICE_TICKS,
PRY_TICKS,
WARDEN_NOTICE_TICKS,
WARDEN_ROLLBACK_N,
} from './constants';
import type {
Command, CorrectionUnit, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
} from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import tech from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
const item = (id: string, tier: number): ItemDef =>
({ id, name: id, codex: 'fixture', tier, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
/**
* raw = tier-0 (stock footage buys debt credit). product = tier-2 (DEVIATION earns debt).
* The splicer makes a FORGED HASH from two inputs, exactly the shape DATA authored on the
* hex-splicer, and exactly the shape the empty-inputs catcher path cannot see.
*/
const FIXTURE: GameData = {
items: [item('raw', 0), item('product', 2), item(FORGED_HASH_ITEM, 3), item('parity-mite', 2)],
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 3 }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 100 }),
mach({ id: 'forge', kind: 'crafter', recipes: ['make'] }),
mach({ id: 'split', kind: 'splitter' }),
mach({ id: 'splicer', kind: 'crafter', recipes: ['splice'] }),
mach({ id: 'firewall', kind: 'crafter', recipes: ['burn'], powerDraw: 1 }),
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { raw: 1 }, ticks: 4 }),
rec({ id: 'make', machine: 'forge', inputs: { raw: 1 }, outputs: { product: 1 }, ticks: 4 }),
rec({
id: 'splice', machine: 'splicer', inputs: { raw: 1, product: 1 },
outputs: { [FORGED_HASH_ITEM]: 1 }, ticks: 8,
}),
rec({ id: 'burn', machine: 'firewall', inputs: {}, outputs: { 'parity-mite': 1 }, ticks: 20 }),
],
tech: [],
commissions: [{ id: 'open', flavor: 'anything', wants: { product: 999999 }, rewardBandwidth: 1 }],
};
function newSim(seed = 1, data: GameData = FIXTURE): Sim {
const sim = createSim();
sim.init(data, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
/** Runs until `done()` or the budget expires; returns the tick it stopped on. */
function runUntil(sim: Sim, budget: number, done: () => boolean, sink?: SimEvent[]): number {
for (let i = 0; i < budget; i++) {
if (done()) return sim.snapshot().tick;
run(sim, 1, sink);
}
return done() ? sim.snapshot().tick : -1;
}
const units = (sim: Sim): ReadonlyArray<CorrectionUnit> => sim.snapshot().correction!.units;
const unitOf = (sim: Sim, kind: CorrectionUnit['kind']): CorrectionUnit | undefined =>
units(sim).find((u) => u.kind === kind);
const debtOf = (sim: Sim): number => sim.snapshot().correction!.debt;
const entOf = (sim: Sim, def: string) => sim.snapshot().entities.find((e) => e.def === def)!;
/**
* A small works that ships tier-2 product, entirely inside ONE 8x8 sector (x 0..7, y 0..7
* the sector grid is world-aligned from -32). Keeping it in one sector is what lets the
* gunship and auditor tests reason about "inside the rect" without guessing.
*/
function corruptionWorks(
sim: Sim, opts: { firewall?: boolean; splicer?: boolean; catchSink?: boolean } = {},
): void {
sim.enqueue(place('gen', 0, 20)); // power lives outside the sector
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('forge', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('belt', 4, 0, E));
if (opts.splicer) {
// A splitter taps product off the main line and a second mine supplies raw, so the
// splicer's TWO-input recipe actually runs. That shape matters: DATA's real forged hash
// is 1 stco-map + 50 hf-dust, and a recipe with inputs is invisible to the empty-inputs
// catcher path — the forged hash is found by ITEM, in a machine's buffers, not by shape.
sim.enqueue(place('split', 5, 0));
sim.enqueue(place('belt', 5, 1, S));
sim.enqueue(place('belt', 5, 2, S));
sim.enqueue(place('belt', 5, 3, W));
sim.enqueue(place('mine', 0, 3));
sim.enqueue(place('belt', 1, 3, E));
sim.enqueue(place('belt', 2, 3, E));
sim.enqueue(place('belt', 3, 3, E));
// No belt leaves the splicer, so the forged hash stays in its output buffer, held
// inside the sector — which is exactly the state that voids an audit.
sim.enqueue(place('splicer', 4, 3));
} else {
sim.enqueue(place('belt', 5, 0, E));
}
sim.enqueue(place('belt', 6, 0, E));
sim.enqueue(place('ship', 7, 0));
if (opts.firewall) {
sim.enqueue(place('firewall', 3, 2));
// The catch has to GO somewhere. A firewall with nowhere to put its cargo fills to the
// stall cap after four catches and quietly stops catching anything ever again — which
// is how the first cut of this fixture "proved" that a guard doesn't stop a warden.
// (catchSink:false deliberately reproduces that, for the one test that needs a firewall
// which catches but ships nothing.)
if (opts.catchSink !== false) {
sim.enqueue(place('belt', 2, 2, W));
sim.enqueue(place('ship', 1, 2));
}
}
}
// ============================================================== PARITY DEBT
describe('PARITY DEBT — the ledger', () => {
it('rises on tier>=2 shipments and not on tier-0/1 ones', () => {
const sim = newSim();
corruptionWorks(sim);
run(sim, 400);
const shipped = sim.snapshot().shippedTotal['product'] ?? 0;
expect(shipped, 'nothing shipped, so this proves nothing').toBeGreaterThan(0);
expect(debtOf(sim)).toBeGreaterThan(0);
// A works that ships ONLY raw ore is never noticed, however long it runs.
const clean = newSim();
clean.enqueue(place('gen', 0, 20));
clean.enqueue(place('mine', 0, 0));
clean.enqueue(place('belt', 1, 0, E));
clean.enqueue(place('ship', 2, 0));
run(clean, 4000);
expect(clean.snapshot().shippedTotal['raw'] ?? 0).toBeGreaterThan(20);
expect(debtOf(clean)).toBe(0);
});
it('decays always — an idle factory drifts back to NOMINAL on its own', () => {
const sim = newSim();
corruptionWorks(sim);
run(sim, 600);
const peak = debtOf(sim);
expect(peak).toBeGreaterThan(0);
// Tear the line out: nothing ships from here on, so only decay is left.
sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } });
run(sim, 3000);
expect(debtOf(sim)).toBeLessThan(peak);
});
it('decays FASTER while tier-0/1 material is shipping', () => {
// Two identical debts, two identical 4,000-tick windows. One factory sits still; the
// other ships stock footage the whole time. Same clock, different ledger.
const idle = newSim();
idle.enqueue(place('gen', 0, 20));
setParityDebt(idle, 0.5);
run(idle, 4000);
const dumping = newSim();
dumping.enqueue(place('gen', 0, 20));
dumping.enqueue(place('mine', 0, 0));
dumping.enqueue(place('belt', 1, 0, E));
dumping.enqueue(place('ship', 2, 0));
setParityDebt(dumping, 0.5);
run(dumping, 4000);
expect(dumping.snapshot().shippedTotal['raw'] ?? 0).toBeGreaterThan(50);
expect(dumping.snapshot().correction!.debt).toBeLessThan(idle.snapshot().correction!.debt);
});
it('a compliance commission vents a chunk outright', () => {
const data: GameData = {
...FIXTURE,
commissions: [
{ id: 'quota', flavor: 'stock footage please', wants: { raw: 5 }, rewardBandwidth: 1, compliance: true },
],
};
const sim = newSim(1, data);
sim.enqueue(place('gen', 0, 20));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('ship', 2, 0));
setParityDebt(sim, 0.7);
const evs: SimEvent[] = [];
const before = debtOf(sim);
const at = runUntil(sim, 4000, () => evs.some((e) => e.kind === 'commissionDone'), evs);
expect(at, 'the quota never completed').toBeGreaterThan(0);
// The vent is worth far more than the five bits of ore it asked for.
expect(before - debtOf(sim)).toBeGreaterThan(DEBT_VENT_COMPLIANCE * 0.9);
});
it('emits debtBand on band EDGES only, never per tick', () => {
const sim = newSim();
corruptionWorks(sim);
const evs: SimEvent[] = [];
setParityDebt(sim, DEBT_BANDS[0] + 0.001); // stood just over the OBSERVED floor
run(sim, 2000, evs);
const edges = evs.filter((e) => e.kind === 'debtBand');
// It crossed into band 1 once. Two thousand ticks of hovering there is not two thousand
// events — hysteresis is what keeps the UI gauge from strobing for the rest of the run.
expect(edges.length).toBeGreaterThanOrEqual(1);
expect(edges.length).toBeLessThan(5);
expect(edges[0].kind === 'debtBand' && edges[0].band).toBe(1);
});
it('every band floor is reachable and reports the right band', () => {
for (let i = 0; i < DEBT_BANDS.length; i++) {
const sim = newSim();
sim.enqueue(place('gen', 0, 20));
setParityDebt(sim, DEBT_BANDS[i] + 0.005);
run(sim, 1);
expect(sim.snapshot().correction!.band, `debt just over floor ${i}`).toBe(i + 1);
}
});
});
// ========================================================== CHECKSUM WARDEN
/** Stands a sim at the foot of a band with a real, already-deviating factory under it. */
function atBand(
band: number,
opts: { firewall?: boolean; splicer?: boolean; catchSink?: boolean } = {},
seed = 3,
): Sim {
const sim = newSim(seed);
corruptionWorks(sim, opts);
run(sim, 400); // earn some real corruptOut first
setParityDebt(sim, DEBT_BANDS[band - 1] + 0.02);
return sim;
}
describe('CHECKSUM WARDEN — the audit', () => {
it('faxes FIRST: notice precedes the walk by the full notice window', () => {
const sim = atBand(2);
const evs: SimEvent[] = [];
const noticeAt = runUntil(sim, 2000, () => evs.some((e) => e.kind === 'notice'), evs);
expect(noticeAt, 'no notice was ever faxed').toBeGreaterThan(0);
const n = evs.find((e) => e.kind === 'notice')!;
expect(n.kind === 'notice' && n.unit).toBe('checksum-warden');
// Through the whole notice window it is present but doing nothing to you.
expect(unitOf(sim, 'checksum-warden')!.phase).toBe('notice');
run(sim, WARDEN_NOTICE_TICKS - 5);
expect(unitOf(sim, 'checksum-warden')!.phase).toBe('notice');
run(sim, 10);
expect(unitOf(sim, 'checksum-warden')!.phase).not.toBe('notice');
});
it('walks to the most DEVIATING machine, audits it, and seals it', () => {
const sim = atBand(2);
const evs: SimEvent[] = [];
const sealedAt = runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs);
expect(sealedAt, 'no machine was ever sealed').toBeGreaterThan(0);
// The phases really were visible on the way — this is what RENDER animates.
const seen = new Set(evs.filter((e) => e.kind === 'notice').map(() => 'notice'));
expect(seen.has('notice')).toBe(true);
const sealEv = evs.find((e) => e.kind === 'sealed')!;
expect(sealEv.kind === 'sealed' && sealEv.on).toBe(true);
// It audited the forge — the only thing in the world producing tier-2 deviation.
const forge = entOf(sim, 'forge');
expect(sealEv.kind === 'sealed' && sealEv.entity).toBe(forge.id);
expect(forge.sealed).toBe(true);
});
it('rolls back the last N outputs — and they are really gone from the world', () => {
const sim = atBand(2);
const evs: SimEvent[] = [];
runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs);
// The rollback lands as `repaired` — the same class of theft as a mite's, which is why
// it reuses the event SCREEN already subtracts from the sky ledger.
const sealTick = evs.find((e) => e.kind === 'sealed')!.tick;
const rolled = evs.filter((e) => e.kind === 'repaired' && e.tick === sealTick);
expect(rolled.length, 'the audit rolled nothing back').toBeGreaterThan(0);
expect(rolled.length).toBeLessThanOrEqual(WARDEN_ROLLBACK_N);
for (const r of rolled) expect(r.kind === 'repaired' && r.item).toBe('product');
});
it('A SEALED MACHINE IS DEAD — provably, until it is pried', () => {
const sim = atBand(2);
const evs: SimEvent[] = [];
runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs);
const forge = entOf(sim, 'forge');
expect(forge.sealed).toBe(true);
expect(forge.jammed).toBe('sealed'); // and it says so, for the inspector
// Two thousand ticks with a full input line and a live grid: not one craft.
const after: SimEvent[] = [];
run(sim, 2000, after);
const crafts = after.filter((e) => e.kind === 'crafted' && e.entity === forge.id);
expect(crafts, 'a sealed machine ran anyway').toHaveLength(0);
// Not vacuous: the extractor upstream is still working the whole time.
const mine = entOf(sim, 'mine');
expect(after.some((e) => e.kind === 'crafted' && e.entity === mine.id)).toBe(true);
});
it('pry clears the seal after PRY_TICKS, and the machine runs again', () => {
const sim = atBand(2);
const evs: SimEvent[] = [];
runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs);
const forgeId = entOf(sim, 'forge').id;
const pos = entOf(sim, 'forge').pos;
sim.enqueue({ kind: 'pry', pos: { x: pos.x, y: pos.y } });
run(sim, 1);
// The sim owns the clock: one command, then a progress bar UI/RENDER can read.
expect(entOf(sim, 'forge').progress).toBeGreaterThan(0);
expect(entOf(sim, 'forge').sealed, 'the seal came off instantly').toBe(true);
const during: SimEvent[] = [];
run(sim, PRY_TICKS, during);
const off = during.find((e) => e.kind === 'sealed' && !e.on);
expect(off, 'the seal never came off').toBeDefined();
expect(off!.kind === 'sealed' && off!.entity).toBe(forgeId);
expect(entOf(sim, 'forge').sealed).toBeFalsy();
// And it is a working machine again, not a cleared flag.
const after: SimEvent[] = [];
run(sim, 400, after);
expect(after.some((e) => e.kind === 'crafted' && e.entity === forgeId)).toBe(true);
});
it('prying ADDS debt — you defaced a compliance notice', () => {
const sim = atBand(2);
const evs: SimEvent[] = [];
runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs);
const pos = entOf(sim, 'forge').pos;
// Stand somewhere with headroom first: by the time a warden has walked over, this works
// has shipped enough product to saturate the ledger at 1.0, and you cannot measure a
// rise against a clamp.
setParityDebt(sim, 0.3);
const before = debtOf(sim);
sim.enqueue({ kind: 'pry', pos: { x: pos.x, y: pos.y } });
const during: SimEvent[] = [];
run(sim, PRY_TICKS + 4, during);
expect(during.some((e) => e.kind === 'sealed' && !e.on), 'the pry never finished').toBe(true);
// Debt is only decaying otherwise (the line is sealed and shipping nothing), so the whole
// rise is the pry. It is charged once, on completion — not per tick of prying.
expect(debtOf(sim) - before).toBeGreaterThan(DEBT_PER_PRY * 0.8);
expect(debtOf(sim) - before).toBeLessThan(DEBT_PER_PRY * 1.2);
});
it('a firewall cans the warden on approach — the other counter', () => {
// Same seed, same works; one has a firewall covering the forge.
const open = atBand(2, {}, 11);
const openEvs: SimEvent[] = [];
run(open, 6000, openEvs);
const guarded = atBand(2, { firewall: true }, 11);
const guardedEvs: SimEvent[] = [];
run(guarded, 6000, guardedEvs);
expect(openEvs.some((e) => e.kind === 'sealed'), 'the undefended works was never sealed')
.toBe(true);
expect(guardedEvs.some((e) => e.kind === 'sealed'), 'the guard let a warden through')
.toBe(false);
// And the guard did it by canning units, not by the wardens never arriving.
expect(guardedEvs.filter((e) => e.kind === 'notice').length).toBeGreaterThan(0);
});
it('canning a unit adds NO debt — an immune system does not hold a grudge', () => {
// `catchSink: false` is load-bearing: with a shipper on the catch lane the firewall
// SELLS its canned mites, and a canned mite is a tier-2 item, so the ledger climbs —
// from the shipment, not from the kill. Holding the cargo isolates the ruling.
const sim = atBand(2, { firewall: true, catchSink: false }, 11);
// Sever the product line too, so nothing at all ships and only decay is left.
sim.enqueue({ kind: 'remove', pos: { x: 6, y: 0 } });
run(sim, 200);
let prev = debtOf(sim);
let rose = false;
for (let i = 0; i < 6000; i++) {
run(sim, 1);
const d = debtOf(sim);
if (d > prev + 1e-9) rose = true;
prev = d;
}
expect(rose, 'debt rose while the only events were catches').toBe(false);
// Not vacuous: the firewall really was canning things the whole time.
const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf['parity-mite'] ?? 0, 'nothing was ever canned').toBeGreaterThan(0);
expect(sim.snapshot().shippedTotal['parity-mite'] ?? 0).toBe(0);
});
});
// ======================================================== REDUNDANCY GUNSHIP
describe('REDUNDANCY GUNSHIP — the mirror', () => {
it('tethers to a sector and mirrors what it produces', () => {
const sim = atBand(3);
const evs: SimEvent[] = [];
const at = runUntil(sim, 12000, () => evs.some((e) => e.kind === 'mirrored'), evs);
expect(at, 'nothing was ever mirrored').toBeGreaterThan(0);
const notice = evs.find((e) => e.kind === 'notice' && e.unit === 'redundancy-gunship');
expect(notice, 'the gunship never faxed').toBeDefined();
const m = evs.find((e) => e.kind === 'mirrored')!;
// It mirrors DEVIATION, and the theft is reported where it happened.
expect(m.kind === 'mirrored' && m.item).toBe('product');
expect(m.kind === 'mirrored' && m.pos).toBeDefined();
// Fax first: the notice really did come before the theft.
expect(notice!.tick).toBeLessThan(m.tick);
});
it('MIRRORED ARITHMETIC: one event per threshold, each worth exactly one unit', () => {
const sim = atBand(3);
const evs: SimEvent[] = [];
runUntil(sim, 12000, () => evs.filter((e) => e.kind === 'mirrored').length >= 3, evs);
const mirrors = evs.filter((e) => e.kind === 'mirrored');
expect(mirrors.length).toBeGreaterThanOrEqual(3);
// SCREEN subtracts exactly one from the sky ledger per event, so a bigger theft has to
// be N events and never a scaled one. Two mirrors never land on the same tick from one
// gunship, and every one of them names an item and a place.
const byTick = new Set(mirrors.map((e) => e.tick));
expect(byTick.size).toBe(mirrors.length);
for (const m of mirrors) {
expect(m.kind === 'mirrored' && typeof m.item).toBe('string');
}
});
it('targets DEVIATION, not throughput — a busy tier-0 sector is of no interest', () => {
// Two works: one hammering out raw ore at 15 tiles away, one making a trickle of
// product. The gunship must tether to the corrupt one.
const sim = newSim(5);
corruptionWorks(sim); // product, sector x0..7 y0..7
sim.enqueue(place('gen', 0, 20));
for (let i = 0; i < 4; i++) sim.enqueue(place('mine', 16 + i, 16)); // pure tier-0 volume
for (let i = 0; i < 4; i++) sim.enqueue(place('belt', 16 + i, 17, E));
sim.enqueue(place('ship', 20, 17));
run(sim, 600);
setParityDebt(sim, DEBT_BANDS[2] + 0.02);
const evs: SimEvent[] = [];
runUntil(sim, 12000, () => {
const g = unitOf(sim, 'redundancy-gunship');
return !!g && g.phase === 'tether';
}, evs);
const g = unitOf(sim, 'redundancy-gunship');
expect(g, 'no gunship ever tethered').toBeDefined();
// The ore works ships far more items; the gunship is looking at the product line.
expect(sim.snapshot().shippedTotal['raw'] ?? 0)
.toBeGreaterThan(sim.snapshot().shippedTotal['product'] ?? 0);
expect(g!.rect).toBeDefined();
expect(g!.rect!.x).toBeLessThanOrEqual(0);
expect(g!.rect!.y).toBeLessThanOrEqual(0);
// and the anchor is the machine that actually deviates
expect(g!.target).toBe(entOf(sim, 'forge').id);
});
it('the tether breaks when the anchor is sealed, idled or torn out', () => {
const sim = atBand(3);
runUntil(sim, 12000, () => {
const g = unitOf(sim, 'redundancy-gunship');
return !!g && g.phase === 'tether';
});
expect(unitOf(sim, 'redundancy-gunship')!.phase).toBe('tether');
// Kill the parity link: tear the anchor out from under it.
const anchor = sim.snapshot().entities.find((e) => e.id === unitOf(sim, 'redundancy-gunship')!.target)!;
sim.enqueue({ kind: 'remove', pos: { x: anchor.pos.x, y: anchor.pos.y } });
run(sim, 2);
const g = unitOf(sim, 'redundancy-gunship');
expect(g === undefined || g.phase === 'leaving').toBe(true);
});
});
// ============================================================= HASH AUDITOR
describe('HASH AUDITOR — the refusal', () => {
it('faxes, prescans, then locks a rect with a deterministic hash', () => {
const sim = atBand(4);
const evs: SimEvent[] = [];
const at = runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs);
expect(at, 'nothing was ever locked').toBeGreaterThan(0);
const notice = evs.find((e) => e.kind === 'notice' && e.unit === 'hash-auditor')!;
const lock = evs.find((e) => e.kind === 'stasis' && e.on)!;
expect(notice.tick).toBeLessThan(lock.tick);
// The visible 5-second prescan sits between the fax and the lock, every time.
expect(lock.tick - notice.tick)
.toBeGreaterThanOrEqual(AUDITOR_NOTICE_TICKS + AUDITOR_PRESCAN_TICKS - 2);
expect(lock.kind === 'stasis' && lock.lockHash).toMatch(/^[0-9A-F]{8}$/);
});
it('the lock hash survives a save/load — the same lock, the same number', () => {
const sim = atBand(4);
const evs: SimEvent[] = [];
runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs);
const hash = (evs.find((e) => e.kind === 'stasis' && e.on)! as { lockHash: string }).lockHash;
const b = newSim(3);
b.load(sim.save());
const released: SimEvent[] = [];
run(b, AUDITOR_STASIS_TICKS + 10, released);
const off = released.find((e) => e.kind === 'stasis' && !e.on);
expect(off, 'the restored lock never released').toBeDefined();
expect(off!.kind === 'stasis' && off!.lockHash).toBe(hash);
});
it('freezes entities and belt items inside the rect, and resumes exactly where it stopped', () => {
const sim = atBand(4);
const evs: SimEvent[] = [];
runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs);
const rect = (evs.find((e) => e.kind === 'stasis' && e.on)! as {
rect: { x: number; y: number; w: number; h: number };
}).rect;
const inside = (x: number, y: number): boolean =>
x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h;
const frozenItems = () => sim.snapshot().beltItems
.filter((b) => {
const belt = sim.snapshot().entities.find((e) => e.id === b.entity)!;
return inside(belt.pos.x, belt.pos.y);
})
.map((b) => `${b.id}@${b.t}`)
.sort();
const before = frozenItems();
expect(before.length, 'no cargo was inside the lock, so this proves nothing')
.toBeGreaterThan(0);
const forgeBefore = entOf(sim, 'forge').progress;
const during: SimEvent[] = [];
run(sim, 100, during);
// 0:00. Not slow — stopped. Same items, same sub-tile positions, to the bit.
expect(frozenItems()).toEqual(before);
expect(entOf(sim, 'forge').progress).toBe(forgeBefore);
expect(during.filter((e) => e.kind === 'crafted' && e.entity === entOf(sim, 'forge').id))
.toHaveLength(0);
// Release, and the line picks up mid-tile rather than restarting.
run(sim, AUDITOR_STASIS_TICKS);
const after: SimEvent[] = [];
run(sim, 300, after);
expect(after.some((e) => e.kind === 'crafted')).toBe(true);
});
it('THE SAFETY: stasis ALWAYS auto-releases, with the player doing nothing at all', () => {
// This is the one test in this file that is not about flavour. Contracts v9 makes
// auto-release contractual: no input, no counter, no firewall, no pry — the lock lifts
// on its own after AUDITOR_STASIS_TICKS. If a change ever makes a lock outlast this,
// the change is wrong, however clever the reason.
const sim = atBand(4);
const evs: SimEvent[] = [];
runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs);
const on = evs.find((e) => e.kind === 'stasis' && e.on)!;
const after: SimEvent[] = [];
run(sim, AUDITOR_STASIS_TICKS + 5, after);
const off = after.find((e) => e.kind === 'stasis' && !e.on);
expect(off, 'THE LOCK NEVER LIFTED — this is the safety, not a tuning knob').toBeDefined();
expect(off!.tick - on.tick).toBeLessThanOrEqual(AUDITOR_STASIS_TICKS + 2);
expect(off!.kind === 'stasis' && off!.lockHash)
.toBe(on.kind === 'stasis' ? on.lockHash : '');
// Nothing is left frozen afterwards.
expect(units(sim).some((u) => u.phase === 'stasis')).toBe(false);
});
it('ABSTENTION: craft nothing during the prescan and it finds nothing, and leaves', () => {
const sim = atBand(4);
const evs: SimEvent[] = [];
// Wait for the fax, then stop moving: pull the extractor so the works goes quiet. The
// sector still SCORES (deviation is a lifetime figure) — there is simply nothing to read.
runUntil(sim, 12000, () => evs.some((e) => e.kind === 'notice' && e.unit === 'hash-auditor'), evs);
sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } }); // the mine
sim.enqueue({ kind: 'remove', pos: { x: 2, y: 0 } }); // the forge
const quiet: SimEvent[] = [];
run(sim, AUDITOR_NOTICE_TICKS + AUDITOR_PRESCAN_TICKS + 60, quiet);
expect(quiet.filter((e) => e.kind === 'stasis'), 'it locked a sector that was standing still')
.toHaveLength(0);
});
it('FORGED HASH: holding one inside the prescan rect voids the audit', () => {
// Same seed and same works twice; the second is holding a forged hash in the sector.
// The splicer's two-input recipe is exactly the shape the empty-inputs catcher can't see.
const bare = atBand(4, {}, 21);
const bareEvs: SimEvent[] = [];
run(bare, 6000, bareEvs);
expect(bareEvs.some((e) => e.kind === 'stasis' && e.on), 'the control was never locked')
.toBe(true);
const forged = atBand(4, { splicer: true }, 21);
const forgedEvs: SimEvent[] = [];
// Sampled rather than checked at the end, because a forged hash is tier-3 DEVIATION and
// the splicer holding it is a legitimate warden target — a warden can turn up and roll
// the evidence back. What the audit reads is what is held AT THE PRESCAN, so the
// precondition to prove is that the sector was carrying one while the auditor was there.
let heldAHash = false;
for (let i = 0; i < 6000; i++) {
run(forged, 1, forgedEvs);
const sp = forged.snapshot().entities.find((e) => e.def === 'splicer');
if ((sp?.outputBuf[FORGED_HASH_ITEM] ?? 0) > 0) heldAHash = true;
}
expect(heldAHash, 'the splicer never made a forged hash, so nothing was tested').toBe(true);
expect(forgedEvs.some((e) => e.kind === 'stasis'), 'the forged hash did not void the audit')
.toBe(false);
// It still turned up and read the room — the counter is the signature, not invisibility.
expect(forgedEvs.some((e) => e.kind === 'notice' && e.unit === 'hash-auditor')).toBe(true);
});
});
// ================================================ determinism and persistence
describe('THE CORRECTION — determinism and save/load', () => {
it('two sims with the same seed run the same escalation, tick for tick', () => {
const build = (): Sim => {
const sim = atBand(2, { firewall: true }, 99);
run(sim, 8000);
return sim;
};
const a = build();
const b = build();
expect(b.snapshot()).toEqual(a.snapshot());
expect(a.snapshot().correction!.debt).toBeGreaterThan(0);
});
it('save v5 carries debt, units and seals through a mid-audit reload', () => {
const a = atBand(2, {}, 7);
const evs: SimEvent[] = [];
// Stop mid-approach, with a unit on the floor and a real debt figure.
runUntil(sim0(a), 12000, () => {
const w = unitOf(a, 'checksum-warden');
return !!w && (w.phase === 'approach' || w.phase === 'audit');
}, evs);
expect(unitOf(a, 'checksum-warden'), 'no warden to persist').toBeDefined();
const b = newSim(7);
b.load(a.save());
expect(b.snapshot().correction).toEqual(a.snapshot().correction);
// And the FUTURE matches, which is the bar — the audit lands identically in both.
run(a, 4000);
run(b, 4000);
expect(b.snapshot()).toEqual(a.snapshot());
expect(a.snapshot().entities.some((e) => e.sealed)).toBe(true);
});
it('SAVING DOES NOT SAVE YOU: round-tripping every tick does not stall an approach', () => {
// LANE-UI's finding: if any part of a unit's progress lived outside the save blob, a
// player (or a live UI harness) that round-trips constantly would be quietly immune to
// The Correction. Two sims, same seed, same tick count — one saves and reloads on EVERY
// tick, the other never does. They must land in the same place.
const straight = atBand(2, {}, 31);
run(straight, 3000);
const churned = atBand(2, {}, 31);
for (let i = 0; i < 3000; i++) {
churned.tick();
churned.drainEvents();
const blob = churned.save();
churned.load(blob);
}
const a = unitOf(straight, 'checksum-warden');
const b = unitOf(churned, 'checksum-warden');
expect(!!a).toBe(!!b);
if (a && b) {
expect(b.phase).toBe(a.phase);
expect(b.pos).toEqual(a.pos);
}
expect(churned.snapshot().entities.filter((e) => e.sealed).length)
.toBe(straight.snapshot().entities.filter((e) => e.sealed).length);
expect(churned.snapshot().correction).toEqual(straight.snapshot().correction);
});
it('a seal survives save/load and is still dead on the other side', () => {
const a = atBand(2, {}, 7);
const evs: SimEvent[] = [];
runUntil(sim0(a), 12000, () => evs.some((e) => e.kind === 'sealed'), evs);
const b = newSim(7);
b.load(a.save());
const forge = b.snapshot().entities.find((e) => e.def === 'forge')!;
expect(forge.sealed).toBe(true);
const after: SimEvent[] = [];
run(b, 1000, after);
expect(after.filter((e) => e.kind === 'crafted' && e.entity === forge.id)).toHaveLength(0);
});
});
/** Tiny identity helper so runUntil reads right when the predicate closes over the sim. */
function sim0(s: Sim): Sim { return s; }
// ============================================= the reference factory, real data
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function buildReference(): Sim {
const sim = createSim();
sim.init(DATA, 1337);
grantResearch(sim, referenceFactoryTech(DATA));
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
return sim;
}
describe('the reference factory under THE CORRECTION (real data)', () => {
it('climbs all five bands and runs every rung inside 20,000 ticks', () => {
const sim = buildReference();
const evs: SimEvent[] = [];
run(sim, 20000, evs);
const bands = evs.filter((e) => e.kind === 'debtBand').map((e) => e.kind === 'debtBand' && e.band);
expect(bands, 'the demo never left NOMINAL').toContain(1);
expect(bands).toContain(2);
expect(bands).toContain(3);
expect(bands).toContain(4);
// All three rungs were dispatched, and every one of them faxed first.
const noticed = new Set(evs.filter((e) => e.kind === 'notice').map((e) => e.kind === 'notice' && e.unit));
expect(noticed).toContain('checksum-warden');
expect(noticed).toContain('redundancy-gunship');
expect(noticed).toContain('hash-auditor');
// The auditor really locked the works, and every lock it opened it also closed.
const on = evs.filter((e) => e.kind === 'stasis' && e.on);
const off = evs.filter((e) => e.kind === 'stasis' && !e.on);
expect(on.length).toBeGreaterThan(0);
expect(off.length).toBeGreaterThanOrEqual(on.length - 1); // the last may still be open
});
it('THE VALVE: the demo reaches a compliance quota, and filing it vents a band', () => {
// LANE-DATA measured the old layout clearing ZERO commissions in 90,000 ticks, which
// made every quota above rung 1 dead content. The v7 compliance tap exists for this.
const sim = buildReference();
const evs: SimEvent[] = [];
run(sim, 20000, evs);
const done = evs.filter((e) => e.kind === 'commissionDone').map((e) => e.kind === 'commissionDone' && e.commission);
expect(done, 'rung 1 never cleared — the ore tap is not shipping').toContain('raw-payload');
expect(done, 'rung 2 never cleared — the luma tap is not shipping').toContain('first-refinement');
const quota = DATA.commissions.find((c) => c.compliance)!;
expect(done, `the debt valve (${quota.id}) is still unreachable`).toContain(quota.id);
// And filing it visibly moved the ladder DOWN — the vent is not decorative.
const filedAt = evs.find((e) => e.kind === 'commissionDone' && e.commission === quota.id)!.tick;
const dropped = evs.find(
(e) => e.kind === 'debtBand' && e.tick >= filedAt && e.tick < filedAt + 600,
);
expect(dropped, 'filing the quota vented nothing the gauge could see').toBeDefined();
});
it('keeps shipping melt through the whole escalation, and never browns out', () => {
const sim = buildReference();
const evs: SimEvent[] = [];
run(sim, 18000, evs);
const early = sim.snapshot().shippedTotal['melt'] ?? 0;
const late: SimEvent[] = [];
run(sim, 2000, late);
const window = late.filter((e) => e.kind === 'shipped' && e.item === 'melt').length;
expect(early, 'the melt line died somewhere in the escalation').toBeGreaterThan(0);
expect(window, 'melt had stopped by tick 20,000').toBeGreaterThan(0);
// The zero-brownout invariant, pinned at last: rounds 3-6 asserted it only in NOTES.
// v7 adds ~20 draw (the compliance tap, the second crime quantizer, the brick guard)
// and four more ASICs to pay for it.
expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(0);
expect(late.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(0);
});
it('survives a real warden seal: the doubled brick works absorbs it', () => {
// The guard lifted, so a warden actually lands. Round 7 measured the single-quantizer
// layout dying here: seal at t12073, coefficient packs back up into the demuxer, melt
// frozen at 69 for the rest of the run. The second crime quantizer is the answer, and
// this is the test that would notice if it were ever removed.
const sim = createSim();
sim.init(DATA, 1337);
grantResearch(sim, referenceFactoryTech(DATA));
const fw = firewallInData(DATA);
for (const cmd of referenceFactory(DATA)) {
// Drop the brick-works guard (the second firewall) so the audit gets through.
if (fw && cmd.kind === 'place' && cmd.def === fw.machine && cmd.pos.x === -22) continue;
sim.enqueue(cmd);
}
const evs: SimEvent[] = [];
const sealedAt = runUntil(sim, 20000, () => evs.some((e) => e.kind === 'sealed' && e.on), evs);
expect(sealedAt, 'no warden ever landed an audit').toBeGreaterThan(0);
const sealed = evs.find((e) => e.kind === 'sealed' && e.on)!;
const victim = sim.snapshot().entities.find(
(e) => e.id === (sealed.kind === 'sealed' ? sealed.entity : -1),
)!;
expect(victim.def, 'the audit landed somewhere unexpected').toBe('quantizer');
// The window that matters: the 3,000 ticks AFTER the seal. With one crime quantizer this
// is where melt stopped forever (measured: frozen at 69 from t12073). With two, the
// splitter skips the sealed lane and the coefficient surplus walks to the other one.
const late: SimEvent[] = [];
run(sim, 3000, late);
expect(
late.filter((e) => e.kind === 'shipped' && e.item === 'melt').length,
'a single seal killed the melt line — the brick works redundancy is gone',
).toBeGreaterThan(0);
// And the survivor really is carrying the load: bricks keep coming off the other one.
expect(late.some((e) => e.kind === 'crafted' && e.recipe === 'quantize-crime')).toBe(true);
});
it('RUNG 3 IS NOT INVISIBLE: a gunship tethers and mirrors in a factory WITH firewalls', () => {
// LANE-UI's round-7 finding, as a regression. They measured a gunship being canned
// inside its own notice window across 12,000 ticks of real play, so `mirrored` had never
// once fired from a real event — a whole rung reduced to nothing by a 2x2 machine the
// player had already built. The ruling that fixes it is in firewallPrey: a firewall
// catches what WALKS, and a gunship hovers. This test is the thing that keeps it fixed,
// and it deliberately uses the demo AS SHIPPED — two firewalls and all.
const sim = buildReference();
const evs: SimEvent[] = [];
run(sim, 20000, evs);
const tethered = evs.some((e) => e.kind === 'notice' && e.unit === 'redundancy-gunship');
expect(tethered, 'no gunship was ever dispatched').toBe(true);
const mirrors = evs.filter((e) => e.kind === 'mirrored');
expect(mirrors.length, 'the gunship never got a mirror off in a guarded factory')
.toBeGreaterThan(0);
for (const m of mirrors) {
if (m.kind !== 'mirrored') continue;
// Only DEVIATION is ever mirrored — never ore, never dust.
expect(DATA.items.find((i) => i.id === m.item)!.tier).toBeGreaterThanOrEqual(2);
expect(m.by, 'v9.3: the mirror must name the gunship that took it').toBeDefined();
}
// Meanwhile the guards are still doing their job on the rung that DOES walk.
expect(evs.filter((e) => e.kind === 'notice' && e.unit === 'checksum-warden').length)
.toBeGreaterThan(1);
});
});

File diff suppressed because it is too large Load Diff

258
fktry/src/sim/mite.test.ts Normal file
View File

@ -0,0 +1,258 @@
/**
* Parity mites v1 THE CORRECTION's first unit (codex §5).
*
* The design points these tests protect:
* - Mites are driven by CORRUPTION shipped (tier-2 product), not by a timer. Ship nothing
* and none arrive; the immune system only notices you once you're making product.
* - They correct corruption, never raw material tier-0/1 cargo is invisible to them.
* That's the balance as much as the fiction, and it's the first thing that would rot.
* - A FIREWALL is the counter, recognised by recipe SHAPE (empty inputs, cans a mite),
* exactly like the mosquito trap so DATA's real firewall lights up with zero code here.
*
* Fixture-based: DATA carries no firewall machine or `parity-mite` item yet (see NOTES round
* 6), so both are modelled here the way the orders describe. The sim keys off shape and tier,
* not ids, so the real data will drop straight in.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { FIREWALL_RADIUS, MITE_ITEM, MITE_MAX, MITE_SPAWN_PER } from './constants';
import type {
Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, WildlifeState,
} from '../contracts';
const N: Dir = 0, E: Dir = 1;
const item = (id: string, tier: number): ItemDef =>
({ id, name: id, codex: 'fixture', tier, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
// raw = tier-0 (raw material: mites must ignore it). product = tier-2 (corruption: mites eat).
const FIXTURE: GameData = {
items: [item('raw', 0), item('product', 2), item(MITE_ITEM, 2)],
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 3 }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 100 }),
mach({ id: 'forge', kind: 'crafter', recipes: ['make'] }),
// The firewall: no inputs, cans a live mite. The sim spots it by recipe shape.
mach({ id: 'firewall', kind: 'crafter', recipes: ['burn'], powerDraw: 1 }),
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { raw: 1 }, ticks: 4 }),
rec({ id: 'make', machine: 'forge', inputs: { raw: 1 }, outputs: { product: 1 }, ticks: 4 }),
rec({ id: 'burn', machine: 'firewall', inputs: {}, outputs: { [MITE_ITEM]: 1 }, ticks: 20 }),
],
tech: [],
commissions: [{ id: 'c1', flavor: 'product', wants: { product: 99999 }, rewardBandwidth: 1 }],
};
function newSim(seed = 1): Sim {
const sim = createSim();
sim.init(FIXTURE, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const mites = (sim: Sim): WildlifeState[] =>
sim.snapshot().wildlife!.filter((w) => w.kind === 'parity-mite');
/**
* A factory near origin that ships tier-2 product: mine -> raw belt -> forge -> a long
* product belt -> shipper. The raw belt sits right at origin where mites converge, so
* "mites ignore raw" is a real test, not a vacuous one.
*/
function corruptionFactory(sim: Sim, opts: { firewall?: boolean } = {}): void {
sim.enqueue(place('gen', 0, 12));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E)); // carries RAW (tier-0)
sim.enqueue(place('forge', 2, 0));
for (let x = 3; x <= 7; x++) sim.enqueue(place('belt', x, 0, E)); // carries PRODUCT (tier-2)
sim.enqueue(place('ship', 8, 0));
if (opts.firewall) sim.enqueue(place('firewall', 5, 2)); // guards the whole short product line
}
describe('parity mites — corruption drives them', () => {
it('do not appear until enough corruption has shipped', () => {
const sim = newSim();
corruptionFactory(sim);
const evs: SimEvent[] = [];
// Run until the first mite spawns, tracking corruption at that moment.
let shippedAtSpawn = -1;
for (let i = 0; i < 20000 && shippedAtSpawn < 0; i++) {
run(sim, 1, evs);
if (mites(sim).length > 0) shippedAtSpawn = sim.snapshot().shippedTotal['product'] ?? 0;
}
expect(shippedAtSpawn, 'no mite ever spawned').toBeGreaterThanOrEqual(0);
// A mite only arrives once corruption crosses the threshold — never on tick 0.
expect(shippedAtSpawn).toBeGreaterThanOrEqual(MITE_SPAWN_PER);
});
it('a factory that ships NO corruption is never noticed', () => {
// Same machines, but the product belt is severed before the shipper: nothing ships.
const sim = newSim();
sim.enqueue(place('gen', 0, 12));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('forge', 2, 0));
// product piles in the forge and on a stub belt; none reaches a shipper.
sim.enqueue(place('belt', 3, 0, E));
run(sim, 8000);
expect(sim.snapshot().shippedTotal['product'] ?? 0).toBe(0);
expect(mites(sim)).toHaveLength(0);
});
});
describe('parity mites — what they correct', () => {
it('seek a tier-2 product on a belt and repair it out of existence', () => {
const sim = newSim();
corruptionFactory(sim);
const evs: SimEvent[] = [];
run(sim, 6000, evs);
const repairs = evs.filter((e) => e.kind === 'repaired');
expect(repairs.length, 'no product was ever repaired').toBeGreaterThan(0);
// Every correction is a product (tier-2), and the event carries the item and a position.
for (const r of repairs) {
expect(r.kind).toBe('repaired');
if (r.kind === 'repaired') {
expect(r.item).toBe('product');
expect(r.pos).toBeDefined();
}
}
});
it('THE BALANCE POINT: never touch tier-0/1 cargo', () => {
const sim = newSim();
corruptionFactory(sim);
const evs: SimEvent[] = [];
// The raw belt at origin is thick with tier-0 items the whole run, and mites pass right
// over it to reach the product line — yet raw is never corrected.
run(sim, 12000, evs);
const raw = evs.filter((e) => e.kind === 'repaired' && e.item === 'raw');
expect(raw).toHaveLength(0);
// And there really WERE raw items sitting under the mites' path (not vacuously true).
expect(sim.snapshot().beltItems.some((b) => b.item === 'raw')).toBe(true);
});
});
describe('parity mites — escalation and cap', () => {
it('scale with corruption but never exceed MITE_MAX', () => {
const sim = newSim();
corruptionFactory(sim);
let peak = 0;
for (let i = 0; i < 40000; i++) {
run(sim, 1);
peak = Math.max(peak, mites(sim).length);
}
// Deep into a heavily-corrupting run the patrol saturates the cap, and never breaches it.
expect(peak).toBe(MITE_MAX);
const shipped = sim.snapshot().shippedTotal['product'] ?? 0;
expect(shipped).toBeGreaterThan(MITE_SPAWN_PER * MITE_MAX); // corruption was well past the cap
});
});
describe('the firewall — the counter', () => {
it('cans a mite that patrols into range, and the catch is cargo', () => {
const sim = newSim();
corruptionFactory(sim, { firewall: true });
const evs: SimEvent[] = [];
run(sim, 12000, evs);
// A firewall in range of the product line cans mites: the canned mite is a real item.
const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf[MITE_ITEM] ?? 0).toBeGreaterThan(0);
// Those catches show up as wildlife cleared events for parity-mites.
const caught = evs.filter(
(e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'parity-mite',
);
expect(caught.length).toBeGreaterThan(0);
});
it('with no mites in the world, a firewall just waits — it never cans air', () => {
// Sever the product line before the shipper: nothing ships, so corruption stays 0 and no
// mite ever arrives. The firewall has nothing to catch and holds honestly at 'starved'.
const sim = newSim();
sim.enqueue(place('gen', 0, 12));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('forge', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('firewall', 6, 0));
run(sim, 12000);
expect(mites(sim)).toHaveLength(0); // precondition: nothing to catch
const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf[MITE_ITEM] ?? 0).toBe(0);
expect(fw.jammed).toBe('starved'); // an idle firewall is waiting, not broken
});
it('guarding the approach cancels corrections — the counter works', () => {
const guarded = newSim(7);
corruptionFactory(guarded, { firewall: true });
const gEvs: SimEvent[] = [];
run(guarded, 20000, gEvs);
const open = newSim(7);
corruptionFactory(open);
const oEvs: SimEvent[] = [];
run(open, 20000, oEvs);
// Same seed, same layout but for the firewall. The firewall cans mites on the approach,
// so far fewer products are corrected mid-belt than on the undefended line.
const guardedRepairs = gEvs.filter((e) => e.kind === 'repaired').length;
const openRepairs = oEvs.filter((e) => e.kind === 'repaired').length;
expect(openRepairs).toBeGreaterThan(0); // the undefended line really was being corrected
expect(guardedRepairs).toBeLessThan(openRepairs);
// And the guard did its job by canning mites, not by luck.
const fw = guarded.snapshot().entities.find((e) => e.def === 'firewall')!;
expect(fw.outputBuf[MITE_ITEM] ?? 0).toBeGreaterThan(0);
});
it('FIREWALL_RADIUS is a real, bounded reach', () => {
expect(FIREWALL_RADIUS).toBeGreaterThan(0);
expect(Number.isFinite(FIREWALL_RADIUS)).toBe(true);
});
});
describe('parity mites — determinism and persistence', () => {
it('two sims with the same seed run the same wave', () => {
const build = (): Sim => {
const sim = newSim(4242);
corruptionFactory(sim, { firewall: true });
run(sim, 10000);
return sim;
};
const a = build();
const b = build();
expect(mites(a).length + (a.snapshot().shippedTotal[MITE_ITEM] ? 1 : 0)).toBeGreaterThanOrEqual(0);
expect(b.snapshot()).toEqual(a.snapshot());
});
it('survives save/load mid-wave, and the future still matches', () => {
const a = newSim(77);
corruptionFactory(a);
run(a, 7000);
expect(mites(a).length, 'no wave to persist').toBeGreaterThan(0);
const b = newSim(77);
b.load(a.save());
expect(b.snapshot().wildlife).toEqual(a.snapshot().wildlife);
run(a, 5000);
run(b, 5000);
expect(b.snapshot()).toEqual(a.snapshot());
});
});

View File

@ -9,6 +9,7 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory } from './reference';
import { grantAllResearch } from './testkit';
import type { GameData, Sim } from '../contracts';
import items from '../../data/items.json';
@ -22,6 +23,7 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD
function fresh(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the reference factory is a post-research build
return sim;
}
function run(sim: Sim, ticks: number): void {

View File

@ -8,6 +8,7 @@
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { HEAT_COOL_DEFAULT, HEAT_COOL_MAX } from './constants';
import type {
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
} from '../contracts';
@ -32,6 +33,9 @@ const FIXTURE: GameData = {
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
// v4 spatial cooling: radiates its coolPerTick to anything within 2 tiles.
mach({ id: 'cooler', kind: 'power', powerGen: 0, coolPerTick: 0.01, coolRadius: 2 }),
mach({ id: 'bigcooler', kind: 'power', powerGen: 0, coolPerTick: 0.04, coolRadius: 1 }),
mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }),
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
@ -269,6 +273,95 @@ describe('heat', () => {
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
});
/** Ticks until the generator at (x,y) first scrams, or -1 if it never does. */
const ticksToScram = (sim: Sim, limit = 1500): number => {
for (let i = 0; i < limit; i++) {
const evs: SimEvent[] = [];
run(sim, 1, evs);
if (evs.some((e) => e.kind === 'scram' && e.on)) return i + 1;
}
return -1;
};
describe('spatial cooling', () => {
it('a cooler in range extends a hot generators run before it scrams', () => {
const bare = newSim();
bare.enqueue(place('hotgen', 0, 0));
const alone = ticksToScram(bare);
expect(alone).toBeGreaterThan(0); // 0.02 gross - 0.004 default cooling
const cooled = newSim();
cooled.enqueue(place('hotgen', 0, 0));
cooled.enqueue(place('cooler', 2, 0)); // 2 tiles away: inside the radius
const withAura = ticksToScram(cooled);
expect(withAura).toBeGreaterThan(0);
// 0.02 - (0.004 + 0.01) = 0.006/tick instead of 0.016: roughly 2.5x the run.
expect(withAura).toBeGreaterThan(alone * 2);
});
it('the aura is bounded — a cooler out of range does nothing', () => {
const bare = newSim();
bare.enqueue(place('hotgen', 0, 0));
const alone = ticksToScram(bare);
const far = newSim();
far.enqueue(place('hotgen', 0, 0));
far.enqueue(place('cooler', 5, 0)); // radius is 2; this is 5 away
expect(ticksToScram(far)).toBe(alone);
expect(at(far, 0, 0)!.heat).toBe(at(bare, 0, 0)!.heat);
});
it('enough cooling in range stops it overheating at all', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
sim.enqueue(place('bigcooler', 1, 0)); // 0.004 + 0.04 = 0.044 > 0.02 gross
const evs: SimEvent[] = [];
run(sim, 2000, evs);
expect(evs.filter((e) => e.kind === 'scram')).toHaveLength(0);
expect(at(sim, 0, 0)!.heat).toBe(0);
expect(sim.snapshot().bandwidth.gen).toBe(10); // never throttled, never dropped
});
it('auras stack by sum, and the total is capped however many you pile on', () => {
// The oven takes 0.3 heat per bake and has no heatPerTick, so between bakes its heat
// moves by exactly -cooling. That makes the shed rate directly measurable.
const shedRate = (coolers: Array<[number, number]>): number => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('oven', 2, 0));
for (const [x, y] of coolers) sim.enqueue(place('bigcooler', x, y));
for (let i = 0; i < 400; i++) {
run(sim, 1);
if (at(sim, 2, 0)!.heat > 0.1) break; // a bake just landed
}
const before = at(sim, 2, 0)!.heat;
run(sim, 1);
return before - at(sim, 2, 0)!.heat;
};
expect(shedRate([])).toBeCloseTo(HEAT_COOL_DEFAULT, 6);
// One in range: own default + the aura, summed.
expect(shedRate([[3, 0]])).toBeCloseTo(HEAT_COOL_DEFAULT + 0.04, 6);
// Four in range would be 0.004 + 0.16 = 0.164 — the cap holds it at HEAT_COOL_MAX.
const piled = shedRate([[3, 0], [2, 1], [3, 1], [2, -1]]);
expect(piled).toBeCloseTo(HEAT_COOL_MAX, 6);
expect(HEAT_COOL_MAX).toBeLessThan(HEAT_COOL_DEFAULT + 4 * 0.04); // the cap really bites
});
it('removing a cooler puts the heat back', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
sim.enqueue(place('bigcooler', 1, 0));
run(sim, 300);
expect(at(sim, 0, 0)!.heat).toBe(0); // held cold by the aura
sim.enqueue({ kind: 'remove', pos: { x: 1, y: 0 } });
run(sim, 200);
expect(at(sim, 0, 0)!.heat).toBeGreaterThan(0); // aura gone, it heats again
});
});
it('leaves cold machines alone', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0)); // no heatPerTick

View File

@ -0,0 +1,171 @@
/**
* A real research run: raw ore in one end, a completed tech out the other, using nothing
* but DATA's own machines, recipes and tech tree. No fixture, no granted state.
*
* This exists because the tree was, earlier this round, impossible to enter: there was no
* lab, and every science pack was made on the artifact-bottler, which was itself locked
* behind a tech costing 10 analog-packs. DATA fixed both mid-round. This test is the proof
* that the fix holds, and the tripwire if the bootstrap ever closes over again.
*
* ore -> demuxer -+-> luma -> quantizer -> coefficient packs -> [split] -+-> crime -> bricks -+
* | +-> ringing -> halos -+
* +-> chroma slurry ------------------------------------------------------------+
* +-> mv-flux -> uplink |
* bricks + halos + slurry -> artifact bottler -> SPATIAL PACK
* -> archaeology lab -> tech
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import type { Command, Dir, GameData, Sim, SimEvent, TechDef } from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import techJson from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
/** The cheapest tech payable in packs the factory below can actually make. */
function cheapestSpatialTech(): TechDef | undefined {
return DATA.tech
.filter((t) => Object.keys(t.cost).length === 1 && t.cost['spatial-pack'] > 0)
.sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0];
}
function packFactory(): Command[] {
const cmds: Command[] = [];
const P = (def: string, x: number, y: number, dir: Dir = N) =>
cmds.push({ kind: 'place', def, pos: { x, y }, dir });
const b = (x: number, y: number, dir: Dir) => P('belt', x, y, dir);
const runX = (x0: number, x1: number, y: number, dir: Dir) => {
const s = x0 <= x1 ? 1 : -1;
for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
};
const runY = (y0: number, y1: number, x: number, dir: Dir) => {
const s = y0 <= y1 ? 1 : -1;
for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
};
const recipeAt = (x: number, y: number, recipe: string) =>
cmds.push({ kind: 'setRecipeAt', pos: { x, y }, recipe });
for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -8);
// ore
P('seam-extractor', -30, 0);
P('seam-extractor', -30, -3);
P('seam-extractor', -30, 3);
runY(-2, -1, -28, S);
runY(3, 1, -28, N);
runX(-28, -27, 0, E);
P('demuxer', -26, 0);
// mv-flux has no consumer here — sell it, or the demuxer jams on its own byproduct
runY(-1, -2, -25, N);
P('shipper', -26, -4);
// luma -> coefficient packs
runX(-24, -23, 0, E);
P('quantizer', -22, 0); // 'quantize' is the default recipe
runY(-1, -2, -22, N); // hf dust -> uplink
P('shipper', -23, -4);
b(-20, 0, E);
P('lane-splitter', -19, 0); // coefficient packs: bricks one way, halos the other
// 1 pack of bricks per craft vs 3 coefficient packs per halo — the splitter's
// skip-when-blocked does the balancing, as ever.
b(-18, 0, E);
P('quantizer', -17, 0);
recipeAt(-17, 0, 'quantize-crime'); // -> macroblock bricks
runY(-1, -2, -17, N);
P('shipper', -18, -4);
runX(-15, -14, 0, E); // bricks -> bottler
runY(1, 2, -19, S);
P('quantizer', -20, 3);
recipeAt(-20, 3, 'skim-ringing'); // -> ringing halos
runX(-21, -22, 3, W); // its hf dust -> uplink
P('shipper', -24, 2);
runX(-18, -15, 3, E); // halos -> bottler
runY(3, 2, -14, N);
b(-14, 1, E);
// chroma slurry -> bottler, the long way round below the works
runY(2, 5, -26, S);
b(-26, 6, E);
runX(-25, -14, 6, E);
b(-13, 6, N);
runY(5, 2, -13, N);
P('artifact-bottler', -13, 0);
recipeAt(-13, 0, 'bottle-spatial-pack');
runX(-11, -10, 0, E);
P('archaeology-lab', -9, 0);
return cmds;
}
describe('a real research run, on DATAs own tree', () => {
it('turns ore into a science pack and a science pack into a tech', () => {
const target = cheapestSpatialTech();
expect(target, 'DATA has no single-cost spatial-pack tech to aim at').toBeDefined();
const sim: Sim = createSim();
sim.init(DATA, 1337);
// Nothing granted: every machine below must be legal from a cold start.
expect(sim.snapshot().research!.unlocked).toEqual([]);
for (const c of packFactory()) sim.enqueue(c);
sim.enqueue({ kind: 'setResearch', tech: target!.id });
const evs: SimEvent[] = [];
let done = -1;
for (let i = 0; i < 12000 && done < 0; i++) {
sim.tick();
for (const e of sim.drainEvents()) {
evs.push(e);
if (e.kind === 'researched') done = e.tick;
}
}
// Every machine the layout asked for was legal from a cold start.
expect(sim.snapshot().entities.find((e) => e.def === 'archaeology-lab')).toBeDefined();
expect(sim.snapshot().entities.find((e) => e.def === 'artifact-bottler')).toBeDefined();
// The ladder ran, rung by rung, on real recipes.
for (const r of ['demux-ore', 'quantize', 'quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) {
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`)
.toBeGreaterThan(0);
}
expect(done, `${target!.id} never completed`).toBeGreaterThan(0);
expect(sim.snapshot().research!.unlocked).toEqual([target!.id]);
expect(sim.snapshot().research!.active).toBeNull();
});
it('opens what the tech promised: a recipe refused before is accepted after', () => {
const target = cheapestSpatialTech()!;
const unlockedRecipe = target.unlocks.find((u) =>
DATA.recipes.some((r) => r.id === u))!;
expect(unlockedRecipe, `${target.id} unlocks no recipe to test with`).toBeDefined();
const owner = DATA.recipes.find((r) => r.id === unlockedRecipe)!.machine;
const sim: Sim = createSim();
sim.init(DATA, 1337);
sim.enqueue({ kind: 'place', def: owner, pos: { x: 0, y: 0 }, dir: N });
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
sim.tick();
const before = sim.snapshot().entities.find((e) => e.pos.x === 0)!;
expect(before.recipe).not.toBe(unlockedRecipe); // silently refused
for (const c of packFactory()) sim.enqueue(c);
sim.enqueue({ kind: 'setResearch', tech: target.id });
for (let i = 0; i < 12000; i++) {
sim.tick();
if (sim.drainEvents().some((e) => e.kind === 'researched')) break;
}
expect(sim.snapshot().research!.unlocked).toEqual([target.id]);
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
sim.tick();
expect(sim.snapshot().entities.find((e) => e.pos.x === 0)!.recipe).toBe(unlockedRecipe);
});
});

Some files were not shown because too many files have changed in this diff Show More