glytch/fktry/lanes/LANE-SIM.md
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

42 KiB
Raw Blame History

LANE-SIM — deterministic factory simulation

You are an Opus 4.8 executor on the SIM lane of FKTRY. Read ../MASTERPLAN.md and ../CONTRACTS.md before anything else; the world bible is ../../docs/FKTRY_LORE.md.

Mission: the headless, deterministic heart of the game. Grid, entities, belts, recipes, power, events. If the renderer were deleted, your sim should still be fully playable through unit tests. You are the lane the other four trust with their lives.

Owned paths: src/sim/** (including your tests: src/sim/*.test.ts). Never touch: contracts, main.ts, other lanes, data JSONs (request changes via NOTES).

Standing rules (all rounds):

  • Deterministic: seedable RNG (write your own mulberry32-style helper), no wall-clock, no unseeded Math.random. Same seed + same commands = identical snapshots.
  • Every mechanic lands with tests. npm test green before you write NOTES.
  • Performance target: 1,000 entities + 5,000 belt items at 30 tps without heroics. Prefer flat arrays and index maps over object soup. Don't allocate in the tick loop when avoidable.
  • Snapshot may reuse buffers, but its SHAPE must always match SimSnapshot.

CURRENT ORDERS — ROUND 5 (MEGA) — supersedes the earlier round-5 text

This is the big one: everything from the previous round-5 orders PLUS the world's first secret. Read it all before starting; the parts interlock.

PART A — carried from round 5 (unchanged in spirit)

  1. grantResearch command — idempotent, save/load safe. Tell me in NOTES when it lands; main.ts's __fktryDemo swaps off testkit.
  2. Research timeRESEARCH_TICKS_PER_PACK (export it); a tech is an investment, not a purchase. Fractional vs stepped progress: your call, note it.
  3. Wildlife v1 (contracts v5 shapes): dust piles accumulate near running hf-dust-emitting machines (seeded RNG finally rolls); size 1.0 hatches a MOSQUITO SWARM that drifts to the nearest running machine and degrades its speed while attached (target); cleared by DATA's trap machine (suggested shape: trap crafts its empty-input catch recipe only while a swarm is in radius, producing the canned mosquito-swarm item). Deterministic; save/load carries it; spawn/clear events. Include the design-point test: a factory that sinks its dust never hatches.

PART B — the ground and the pipe (new)

  1. SEAMS BECOME REAL. Consume data/seams.json (DATA authors this round — coordinate the schema in NOTES early): extractors only extract while their footprint overlaps a seam region; off-seam extractors idle with a new jam reason 'no seam' (edge-triggered like the others; UI will voice it). Reference factory v5 must sit on seams — coordinate layout coordinates with DATA.
  2. THE RELIC (the hidden pipe). One optical-fossil per world (contracts v6: RelicState, excavate, relicFound): position derived from the seed — deterministic, 18-28 tiles from origin in a quiet corner, never on a seam, never under the reference factory. excavate within 1 tile of the unfound relic flips found, emits relicFound, persists through save/load forever. relics always present in the snapshot (found or not — RENDER draws the shimmer, UI must not be able to cheat-detect it any other way). No bandwidth cost, no research gate: the only lock is finding it.
  3. Reference factory v5: research rig (bottler + archaeology lab off quantizer surplus) so research runs in-demo; sits on seams per #4; one swarm hatches and is trapped within the 20k window if DATA's trap lands. Zero-brownout regression stays.

Definition of done: relic lifecycle tested (excavate too far = dropped; found persists through save→load→20k ticks); off-seam extractor refuses with 'no seam'; wildlife full lifecycle; research takes time; reference v5 green and on-seam.


NOTES (append-only log, newest at bottom — template below)

### Round N — YYYY-MM-DD — [your model]
SHIPPED: what actually works now (verified how)
DECISIONS: choices you made inside your mandate
BLOCKED/BROKEN: anything not working, honestly
CONTRACT REQUEST: (if any)
PROPOSAL: (ideas for the orchestrator, optional)
NEXT: what you'd do next round if asked

Round 1 — 2026-07-17 — Opus 4.8

SHIPPED

  • The stub is gone. src/sim/ is now grid + placement, belts with back-pressure, crafting, bandwidth/brownout, shipping and commissions. Files: index.ts (the sim), geom.ts (grid math), rng.ts (seeded mulberry32), sim.test.ts (18 tests).
  • Verified three ways, not one:
    1. npm run check clean; npm test 70/70 green across all lanes (18 are mine).
    2. Live at localhost:8150 (another session's dev server — see BLOCKED). App boots, zero console errors. My numbers are visibly driving other lanes: the UI bandwidth readout showed 9 DRAW / 20 GEN for an ASIC + extractor + 4 belts + uplink, which is exactly my power math; the fax panel reads my activeCommission/commissionProgress. __fktryBus.dispatch(...) places entities and they render.
    3. Ran the sim in the real browser engine by importing /src/sim/index.ts in the page: the M1 chain shipped its first MELT at tick 1149, commission first-taste closed at tick 1149 — the identical tick Node produces. Determinism holds across engines.
  • Perf (measured in-browser, not estimated), against the 1,000 entity / 5,000 item target:
    • 1,245 entities + 2,436 belt items → 0.138 ms/tick (242× realtime)
    • 3,500 entities + 6,820 belt items → 0.418 ms/tick (80× realtime)

DECISIONS

  • Output-belt routing by rank — the big judgement call, please review. A machine picks which adjacent belt to hand an item to by tracing the belt chain to the machine it ends at (cached; invalidated on any place/remove/rotate/setRecipe): rank 2 = that machine consumes this item, 1 = it's an uplink (takes anything), 0 = dead end or loop (accept, so a half-built belt still fills instead of looking broken), -1 = it will never take this item, so refuse. It never falls back to a worse rank just because the best belt is momentarily full. Why I did this: with no splitters or inserters, the demuxer's three outputs (luma / slurry / mv-flux) have to leave one 2x2 perimeter toward three different machines. Without ranking, items land on the wrong lane and wedge it permanently — M1 cannot flow at all. This is routing intelligence the orders didn't ask for; it's also the root of the deadlock below. If you want it gone, splitters have to land first.
  • Belt spacing is enforced across the tile seam: a blocked front item holds station 0.45 behind the rear item of the next belt rather than bunching at the boundary.
  • Pause freezes sim time (tick stops advancing) but commands still apply — you can build while halted. tick is sim-time, not wall-time.
  • jammed reports only 'output full', edge-triggered (one event per stall, not per tick). Starvation is not flagged — see PROPOSAL 3.
  • Power: a negative recipe bandwidth adds to gen rather than subtracting from draw, so the brownout multiplier stays gen/draw. Tested: the quantizer visibly pays for itself.
  • Extractors mine anywhere — there is no seam map in data/ yet.
  • Tests assert against the recipe defs instead of hard-coding yields. This is deliberate: my first pass hard-coded demux-ore and broke the moment LANE-DATA shipped. Mechanics are mine to pin down; the numbers are theirs to move.

BLOCKED/BROKEN

  • My round-1 definition of done no longer exists. The orders say place extractor→demuxer→quantizer→mosh→shipper and watch melt climb. LANE-DATA's codex transcription landed mid-round and the mosh reactor now eats gop-crate, not coefficient packs. MELT is now eight machine types deep (ore→demux→{luma→quantizer→coeff; slurry→subsampler ×2→chroma-420}→DCT press→anchor slab; mv-flux→p-caster→delta wafers; →GOP assembler→mosh). I rebuilt against the real graph and it does ship melt (tick 1149, verified above) — but "the whole fantasy in miniature" is now a ~50-entity build, not a 6-machine demo. Your call whether M1's demo target changes or DATA adds a starter recipe.
  • The M1 factory deadlocks permanently at ~tick 1200, after 4 GOP crates / 12 melt. This is not a mechanics bug — I probed it for 6000 ticks. Luma and coefficient packs structurally overproduce (demux yields 2 luma/craft; the press is chroma-420-limited so it only burns 6 coeff per ~180 ticks), and the press also jams on surplus anchor slabs (the GOP assembler needs 1 slab per 6 delta wafers). Surplus has nowhere legal to go: my rank rule forbids diverting a rank-2 item to a rank-1 uplink even when the preferred belt is wedged. So the press jams → quantizer jams → demuxer jams → mv-flux stops → the p-caster starves and the assembler sits at 5/6 delta wafers forever.
    • The graph is solvable today without new code: a second quantizer on quantize-crime drains surplus coeff into bricks, and a second assembler on assemble-gop-i-only eats surplus slabs (8→crate). My demo build is just naive. But note a player currently cannot void surplus to an uplink, which is the first thing a Factorio player reaches for. That's the cost of the rank rule.
  • splitter (lane-splitter) and buffer (buffer-tank) now exist in machines.json; they place and draw power but do nothing. Not in my orders — flagging so nobody thinks they work.
  • heat is always 0, bandwidth.stored always 0 (M2 / buffer tanks).
  • Port 8150 is held by another session's dev server, and the harness won't start a second one for this project. I verified against that server read-only. Note for whoever plays the build in a Browser pane: the tab reports visibilityState: hidden, so rAF is paused and the sim crawls (~9 ticks per screenshot). That's the pane, not the loop — importing the sim module directly in the page runs it at ~96k ticks/sec.

CONTRACT REQUEST

  • None. contracts.ts covered everything I needed this round.

PROPOSAL (yours to call, not mine to take)

  1. Splitters next. lane-splitter with round-robin plus a priority/overflow output is the canonical Factorio answer to the deadlock above, and it unlocks byproduct sinks and the bloom-loop topology later. This is the one I'd pick.
  2. Alternative, ~5 lines: allow a lower-ranked belt when every preferred belt is full and outputBuf has hit the stall threshold. Cheap, but it makes byproduct management automatic and quietly deletes a core Factorio tension — a gameplay decision, so I left it alone.
  3. jammed: 'starved' for idle-with-no-input would let the inspector tell "backed up" from "waiting". Cheap, but it's UI-visible, so it's LANE-UI's business as much as mine.
  4. Extractors should require a seam tile — needs a seam/ore map from DATA and a worldgen call.

NEXT (round 2 if asked)

  • lane-splitter with priority/overflow; buffer-tank feeding bandwidth.stored to smooth brownouts.
  • Heat: accumulate per craft, throttle >0.7, scram at 1 (the software decoder's whole bit).
  • Save/load — SimSnapshot is already the right shape to serialize.
  • A worked, deadlock-free reference factory you can paste into the console to demo M1.

Round 2 — 2026-07-17 — Opus 4.8

SHIPPED

  • Reference factory (src/sim/reference.ts, referenceFactory(data)): 114 commands, 110 entities. Ships its first MELT at tick 1247 and is still shipping at 20,000 — 615 melt per 2,000-tick window, flat, no decay. Round 1's build wedged at ~1200; this one never does, and the demuxer's jammed stays null the whole way. Surplus becomes product instead of a deadlock: 1,136 macroblock bricks + 1,997 hf dust shipped, 219 slurry voided over 20k ticks.
  • Splitters — round-robin, blocked outputs skipped, rank traced through them (a lane's rank is now the best outcome reachable across every terminal it can reach).
  • Buffer tanks — charge on surplus, discharge to cover a deficit, brownout only once dry; bandwidth.stored is the pool.
  • Heat v1heatPerTick while active + RecipeDef.heat per craft, ambient cooling, throttle above 0.7, scram at 1.0, restart under 0.5, both edges emitting scram.
  • Commission queue — data-order activation, commissionDone advances, repeat re-enters at the back, progress still only counts while active.
  • jammed:'starved' and BeltItem.id (always set; survives belt transfers and splitters, so the renderer never sees an item teleport).
  • Verified: 37 sim tests green (18 + 15 M2 mechanics + 4 reference), tsc clean in src/sim. In the live app I dispatched the whole reference factory through __fktryBus — it builds, renders, and runs, and my round-2 work is already visibly driving other lanes: the UI reads 94 DRAW / 220 GEN + BUFFER off bandwidth v2, the fax shows the queue ("NEXT IN TRAY: DUST ALLOWANCE"), and LANE-UI's OSHA voice is narrating my new flag ("DCT PRESS: INTAKE STARVED. NOTHING IS ARRIVING.").
  • Determinism holds across engines: browser and Node both give tick 1247 and identical per-window melt counts.
  • Perf: 3,500 entities + 6,820 belt items → 0.465 ms/tick (72× realtime). Round 1 was 0.418 (80×); the ~11% is heat touching every entity every tick. Still well inside target.

DECISIONS

  • Heat curve (mine to pick, per orders). Throttle is linear from 1.0 at heat 0.7 down to 0.5 at heat 1.0 — so the lore's "tick rate visibly halves" lands exactly at the scram point. Ambient cooling is a flat 0.004/tick applied always, working or not (1.0 → cold in 250 ticks ≈ 8s), which means any machine with heatPerTick below 0.004 can never overheat. Scram at 1.0, restart under 0.5, so a hot generator duty-cycles rather than dying.
  • Throttle applies to powerGen, not just craft speed. A hot decoder's output sags before it drops off the bus entirely — which is precisely the hole buffer tanks exist to fill. That synergy is my reading of "per-machine throttle"; say the word if generators should be exempt.
  • Scrammed machines keep their idle powerDraw (stopped, not unplugged) and freeze mid-craft progress, resuming on restart rather than binning the work.
  • Scram clears jammed and suppresses jam evaluation: a scrammed machine reports through heat + the scram event, not as a flow jam. Two channels, no double-reporting.
  • Splitters hold 2 items, mirrored into inputBuf so the inspector can see them. Any adjacent belt not pointing in is an output; the splitter's own dir is unused. Machines can't push straight into one — feed it with a belt.
  • starved fires whenever a recipe sits idle without enough input — including a machine that simply outruns its feed. LANE-UI: expect it to flicker on a fast machine with a slow supply. That's accurate rather than a bug, but you may want to debounce the voice line.
  • The reference factory validates every machine/recipe id against data and throws a named error, so DATA drift fails loudly instead of resurfacing as a mystery deadlock.

BLOCKED/BROKEN

  • DATA carries none of the v2 fields, so heat and buffer tanks are inert in the real game. No machine has heatPerTick, none has bufferCap, no recipe has heat, no commission has repeat. Both systems are fully implemented and tested — on a fixture I own (pressure.test.ts), because there is nothing in data/ to test them against. The DoD's "heat throttles and scrams the software decoder" is proven mechanically, but the actual SOFTWARE DECODER cannot overheat until DATA gives it a number. This is the one thing standing between M2 heat and being real.
    • Starting values if they help (my suggestion, DATA's call): software-decoder heatPerTick: 0.005 → ~33s of work then a ~4s scram, an ~89% duty cycle that reads as theatrical rather than punishing; decode-asic left at 0 (cool and inflexible, per the codex); buffer-tank bufferCap: 600 ≈ 20s of cover against a 30-bandwidth deficit — enough to ride out exactly one decoder scram, which is a nice thing to discover.
  • src/sim/data-validate.test.ts is LANE-DATA's file living inside my owned path. I have not touched it. Worth an ownership ruling — either it moves to data/, or my lane doc should say I share the folder.

CONTRACT REQUEST

  • MachineDef.coolPerTick?: number (default 0.004). Right now cooling is a global constant, so DATA can tune how fast a machine heats but not how fast it recovers — every machine cools 1.0 → 0.5 in exactly 125 ticks. The codex explicitly has ASIC coolers venting heat, which wants a per-machine number. Cheap to add, and I'd wire it the round it lands.

PROPOSAL

  • BeltItem.id can drop the ? in v3 whenever you like — I always set it already.
  • referenceFactory's setRecipe commands depend on entity ids being handed out in placement order (1..N). That's true and deterministic, but it's an implicit coupling to my id allocation. A setRecipeAt(pos) command variant would remove it. Not urgent, just a tripwire worth knowing about before someone edits the layout.
  • Splitters have no filter or priority, and the reference factory proves you don't need either — skip-blocked self-balances. Worth revisiting only when bloom loops want a filtered tap.

NEXT (round 3 if asked)

  • Save/load — SimSnapshot is already the right shape.
  • Bloom loops: my trace already treats a closed belt circle as "no terminal" → rank 0 → accept and pile, which is exactly what a loop wants. The P-frame duplicator should drop straight in.
  • HF dust piles + mosquito swarms (the rest of M2 PRESSURE) — the seeded RNG is sitting there unused and waiting for its first roll.
  • Heat, for real, the moment DATA has numbers.

Round 3 — 2026-07-17 — Opus 4.8

SHIPPED

  • Contracts v3 wired: EntityState.scrammed (RENDER can delete its duplicated 0.5 constant — the latch is mine to publish), snapshot.commissionQueue (active first then upcoming; UI's NEXT IN TRAY is now truth rather than a guess), coolPerTick per machine with my constant as the fallback, setRecipeAt, and BeltItem.id hardened to required.
  • UNITS CONVERSION — confirmed, DATA please retune against this. gen/draw are bandwidth per SECOND; stored is bandwidth-SECONDS. Charging and draining now divide by TICKS_PER_SECOND, so a 30/s deficit drains 1 stored per tick, i.e. 30 per second. Concretely: bufferCap: 240 = exactly 8.0s of cover against a 30/s deficit; the in-flight 300 = 10.0s. pressure.test.ts asserts that arithmetic tick by tick.
  • save()/load(): 26.9 KB for the 118-command reference factory. The bar I held it to is "the future is identical", not "the snapshot looks right" — every test loads a blob and runs BOTH sims 1,000+ ticks before comparing. Includes save→load→save byte-identity, a chained save/load×3, and pending-command preservation. 7 tests.
  • Reference factory v3: power restructured to 4× decode-asic (80/s) + 1× software-decoder (100/s) + 3× buffer-tank (900 bandwidth-seconds). Melt still first at tick 1247, still shipping at 20k, rate unchanged.
  • Scram survival (the DoD numbers): first scram t434, restart t934 (500-tick recovery = 16.7s), then a steady ~718-tick duty cycle at 47% uptime. Brownout ticks across 20,000: zero. Tanks sit at a 773/900 low-water mark in steady state. Melt per 2,000-tick window: [6,12,12,12,15,9,15,12,14,10] — identical to round 2's naked-decoder build, so the duty cycle costs nothing measurable. The decoders black out; the line doesn't.
  • Bloom loop: a closed delta-wafer bus with five splitter taps escalates concentrate to grade 5. All six rungs craft; 4× grade-5 shipped by 20k. Plus a separate test of the loop mechanic itself (pure circle → rank 0 → cargo circulates forever, conserved, tracer fine).
  • Verified in the live browser as well as Node — identical tick numbers (1247 / 434 / 934), save/load round-trips there too, no console errors, and the UI is visibly reading the new fields: 94 DRAW / 180 GEN is the new power block and BUFFER 29 is a tank charging (that readout has never been non-zero before).
  • Perf: 74.9× realtime at 3,500 entities / 6,820 belt items (0.445 ms/tick, best of 3 on a freshly loaded page). See DECISIONS — beware my older numbers.

DECISIONS

  • Reference power is sized against the worst case, not the average. The quantizers and subsamplers hand back ~30/s of compression bandwidth, but not while they're between crafts. Assume none of it. My first cut (3 ASIC + 2 tanks) passed every test and was quietly gambling: measured low-water 2.2 of 600, i.e. it survived only because compression happened to be running. 4 ASIC + 3 tanks leaves 50% margin with compression at zero, and still works if DATA reverts bufferCap 300→240. A test that passes on a coincidence is worse than one that fails.
  • Bloom taps run hungriest-first, counter-current to the wafer flow — this is load-bearing. Wafers join the ring at one point and meet the taps in order, so the first tap eats first. Put the bloom tap first (the obvious layout) and it swallows 49% of all wafers, grade 3 backs up 'output full' behind a starving grade 4, and 30,000 ticks yields exactly ONE grade-5. Reversed: 4 by 20,000. Same machines, same recipes, one ordering decision. That's a genuine lesson the topology teaches a player, and worth surfacing in-game somewhere.
  • updateHeat early-outs on cold, cooling-less, unscrammed machines. Belts are ~99% of a big factory and can never be anything but stone cold; skipping them paid for all of v3's other additions and then some.
  • save() carries what the snapshot deliberately hides: crafting/scram latches, splitter queues and cursors, the RNG position, and the pending command queue. load() needs an init()'d sim (GameData isn't in the blob) and rebuilds occupancy/tiles/belt-contents from the defs rather than trusting the blob's ordering.
  • Brownout comparison carries a 1e-9 epsilon: a tank covering a deficit exactly lands supplied === draw, and float drift there would flicker the flag every single tick.

BLOCKED/BROKEN

  • DATA is mid-edit in the shared tree — I tuned against uncommitted values. data/machines.json is dirty as I write this. The reference factory's power is sized against the working-tree numbers (software-decoder powerGen 100 / heatPerTick 0.0033 / coolPerTick 0.001; buffer-tank bufferCap 300). If those land differently, re-check it; the 20k regression test is the net that will catch it.
  • Heat semantics need a one-line ruling. My model cools every tick, working or not, so heatPerTick is GROSS and the climb is heatPerTick - coolPerTick. DATA's committed values (heatPerTick 0.0025, no coolPerTick → default 0.004) net to -0.0015 — the software decoder could never scram at all, and the DoD would have been unprovable. Their in-flight values (0.0033 / 0.001) net +0.0023 and work. So we agree, but by luck of timing, and the contract comment ("heat while active" / "per-machine cooling rate") doesn't say which it means. Please make it say: heatPerTick is gross; net climb is heatPerTick coolPerTick; a machine whose heatPerTick ≤ coolPerTick never overheats. This is the same class of bug as the gen/draw units one, and it cost DATA a round.
    • Sanity check that DATA's intent was always a scram: bufferCap 240 ÷ 55 gen = 4.36s of cover, against my 4.17s recovery at the time. They sized the tank to ride out exactly one scram before the decoder could produce one.
  • asic-cooler (new) currently does nothing. coolPerTick: 0.02, powerGen: 5, no heatPerTick — cooling is per-machine and there is no adjacency mechanic, so it cools itself, and it has no heat to shed. It's a 5-bandwidth generator with a great flavour text. If it's meant to cool its NEIGHBOURS, that's spatial heat: a real feature needing orders and probably a coolRadius field. Say the word.
  • The codex's literal bloom loop is not expressible. "Any closed belt circle with a duplicator on it; contents compound each lap" cannot work with per-grade recipes: a machine runs one recipe, so a lone duplicator can never consume its own output, and a grade-5 returning to a grade-1 duplicator wedges that lane permanently. The shape that composes is the wafer-bus circle I built. The literal version needs a single self-upgrading recipe (grade-N → grade-N+1), which RecipeDef can't express — inputs/outputs are concrete item 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.

PROPOSAL

  • Sim.save?/load? can drop the ? in v4 — I always implement both.
  • snapshot.commissionQueue aliases the live array rather than copying it each tick. Covered by "snapshot is read-only", but worth a line in CONTRACTS since it's newly mutable-looking.
  • Treat my round-1/round-2 perf figures (80× / 72×) as soft. I re-measured on a page loaded 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 carries its state, so wildlife can land without touching persistence.
  • 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 timeRESEARCH_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-tasteraw-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.