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>
495 lines
34 KiB
Markdown
495 lines
34 KiB
Markdown
# 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 time** — `RESEARCH_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)
|
||
4. **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.
|
||
5. **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*.
|
||
6. **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 —
|
||
6–15 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 v1** — `heatPerTick` 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.
|