Compare commits

...

5 Commits

Author SHA1 Message Date
type-two
34e8b39fed [ui] round 3 notes
Shipped 1-4, skipped the tech panel (tech.json is still []). The honest parts:
the reference factory can't ship anchor slabs so the SANITISING premise didn't
hold; live pressure verification is impractical in the browser pane (paused rAF
+ HMR churn) so it's headless against the real sim; and I nearly reported
commissionQueue as unpublished before a probe caught SIM landing it mid-round.
Two contract requests (setGhostMode to kill the __remove magic string,
MachineDef.accent) and a ?uidemo proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:40 +10:00
type-two
9f3a254d85 [ui] live harness: the HUD against the real sim and reference factory
Boots createSim() + createUI() on the real data/*.json, builds
referenceFactory(), and pumps ticks through the same update(snap, events) path
main.ts uses — then reads the actual DOM. No mocks.

Confirms what the round asked to see with eyes but the harness wouldn't allow:
heat climbing to THROTTLING and then SCRAM on a software decoder (inspected via
the real pickTile -> entityAt -> open click path), the unit still reading SCRAM
while it cools back below 1.0, SANITISING on real anchor-slab shipments,
NEXT IN TRAY matching SIM's real queue, a real standing order reaching the head
and stamping the fax, and remove mode demolishing a real entity.

Why headless: the browser pane backgrounds the tab, which pauses rAF, so the
live page advances ~10 ticks per screenshot — SCRAM needs ~435 ticks of
continuous generation, and parallel lanes' HMR reloads wiped the world every
minute or so. Same code, deterministic, milliseconds, and it stays a regression
test.

The reference factory never ships anchor slabs — it recycles them into the
i-only assembler by design — so the SANITISING case demolishes that assembler
and drops an uplink on it, which is what a player would do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:40 +10:00
type-two
a6ecf88623 [ui] DOM tests under happy-dom: chips, window binding, fax stamp
The three that genuinely needed a real DOM and were blocked last round.
happy-dom is actually installed now.

Chips: node reuse across updates, state classes clearing, spec ordering, and
a dangling item id rendering drab instead of taking the panel down.
Hotkeys: attach/detach really binding and unbinding the window, space
preventing page scroll, and no hotkey firing while typing in a field.
Fax: the stamp lands and clears on its own, and two commissions completing
back to back don't let the first timer clear the second early.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:22 +10:00
type-two
ea17e95318 [ui] conform to contracts v3: queue truth, scram latch, chassis vs accent
NEXT IN TRAY now reads snap.commissionQueue[1]. SIM publishes the field, so
the round-2 data-order inference is deleted rather than kept as a fallback —
it went wrong the moment a standing order was re-queued to the back, and a fax
that lies quietly is worse than one that says nothing. No queue, no peek.

Inspector reads EntityState.scrammed instead of inferring scram from heat >= 1.
A scrammed machine cools back below 1.0 while still offline, so the threshold
was wrong in exactly the window that matters; re-deriving SIM's hysteresis here
would also desync silently the moment they retune HEAT_RESTART.

Build-bar icons follow the codex's two-material rule, prompted by the v3 ruling
that color is chassis, not accent: grimy body from data, plus one emissive
element derived from the machine's first output item — the same derivation
RENDER uses, so the bar and the world agree. DATA's chassis values are all
desaturated neutrals (correct per §8), and 21 icons in those colours is an
unreadable grey wall; the accent is what tells a quantizer from a demuxer.
The round-1 id/kind tables were never wrong, only mislabeled — they're the
accent fallback now, for machines that output nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:22 +10:00
type-two
45003db31a [ui] REMOVE mode: X arms it, click demolishes, Esc stands down
A misplaced machine is finally undoable by mouse. The REMOVE button sits
with the tabs rather than on a page, because it's a mode, not a machine, and
it has to be reachable from any page. It stays armed between clicks —
demolition is usually plural — and clicking open ground does nothing, so a
stray click never reads as a broken tool. Toast: "UNIT RECLAIMED. THE FLOOR
REMEMBERS."

LANE-RENDER: the hover hook is the __remove ghost sentinel. The UI has no
Renderer reference and the only channel to the ghost is selectedBuild(),
which main.ts feeds to setGhost — so while remove is armed, bus._sel reads
{def: '__remove', dir: 0}. Key the demolition tint off that def id. It's safe
because sim's place handler ignores unknown defs, so main.ts's click-to-place
fires a place for __remove and nothing happens; the UI dispatches the real
remove itself. Exported as REMOVE_DEF and pinned by a test.

A magic string shared between two lanes via a NOTES file is not a design;
contract request filed for UIBus.setGhostMode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:05 +10:00
22 changed files with 1591 additions and 83 deletions

View File

@ -257,3 +257,113 @@ NEXT (round 3 if asked)
- 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.
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.
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.

View File

@ -243,3 +243,123 @@ closes the last build-verb gap.
**NEXT (suggested):** remove-by-mouse; heat/scram + SANITISING confirmation once a chain
can be fed; commission queue UI becomes real once requests 1-2 land; tech panel when
`tech.json` fills (still `[]`).
### Round 3 — 2026-07-17 — Opus 4.8
**SHIPPED:** Orders 1-4. Order 5 (tech panel) skipped — see BLOCKED. New: `live.test.ts`
(real sim + real HUD), `chips.test.ts`, `hotkeys.dom.test.ts`, `fax.test.ts`. **105 UI
tests** (was 57); full suite 238/238; `npm run check` clean.
1. **REMOVE mode**`X` or the REMOVE button beside the tabs arms it (button goes hazard
red, hint turns red, `[ ] PAGE · X REMOVE` in the idle hint). Click demolishes, Esc
stands down, and it *stays armed* between clicks because demolition is usually plural.
Toast: "UNIT RECLAIMED. THE FLOOR REMEMBERS." Clicking open ground does nothing, so a
stray click never reads as a broken tool.
2. **NEXT IN TRAY reads truth**`snap.commissionQueue[1]`. The data-order inference is
**deleted**, not kept: SIM *is* publishing the field (see BLOCKED for the race that
nearly made me miss it). No queue → no peek. A fax that lies quietly is worse than one
that says nothing.
3. **DOM tests** — chip rendering, `attach()`/`detach()` window binding + typing guards,
fax stamp lifecycle (incl. back-to-back stamps not clearing early), all under
happy-dom.
4. **Live pressure verification** — see VERIFIED.
**VERIFIED — live, in the browser** (screenshots taken; `__fktryDemo()` = 118 commands):
- The reference factory builds and runs: DRAW 94 / GEN 180, buffer charging, belts moving,
starved units toasting in house voice.
- **REMOVE end-to-end:** armed the mode (button red, `bus._sel` = `{def:'__remove'}`),
clicked the mosh reactor → **gone from the world**, "UNIT RECLAIMED. THE FLOOR
REMEMBERS." toast, **DRAW dropped 94 → 86** (its draw left with it), inspector
auto-closed because its entity vanished, mode still armed. That's the round's DoD.
- Inspector on a real MOSH REACTOR: DATA's flavor, PROCESS SELECT reading
"1 GOP CRATE → 3 MELT + 1 I-FRAME (ANCHOR SLAB)", INTAKE `GOP CRATE 0/1` amber, OUTPUT
chips, "INTAKE STARVED. NOTHING IS ARRIVING."
- Paging tabs + NEXT IN TRAY (DUST ALLOWANCE) + REMOVE all legible in one bar.
**VERIFIED — headless, against the REAL sim** (`live.test.ts`; boots `createSim()` +
`createUI()` on the real `data/*.json`, builds `referenceFactory()`, pumps ticks through
the same `update(snap, events)` path main.ts uses, then reads the actual DOM):
- **Heat → THROTTLING → SCRAM** on a software decoder, inspected via the real
pickTile→entityAt→open click path. Also pinned: the unit **still reads SCRAM while
cooling back below 1.0** — the exact case `heat >= 1` got wrong, now read off
`EntityState.scrammed` per contracts v3.
- **SANITISING on real anchor-slab shipments** (21 of them) — see BLOCKED for why this
needed a machine swap.
- **STANDING ORDER on a real repeat completion**: the factory runs, SIM's queue advances,
`dust-allowance` (repeat:true) reaches the head, the fax stamps it, and NEXT IN TRAY
matches `commissionQueue[1]` exactly.
- **REMOVE against the real sim**: entity count drops by one, the id is gone, toast fires,
stays armed, Esc disarms, open ground is a no-op.
**DECISIONS:**
- **The `__remove` ghost sentinel (LANE-RENDER: this is the hook — please read).** The UI
has no Renderer reference; the only channel to the ghost is `bus.selectedBuild()`, which
main.ts feeds to `setGhost`. So while remove is armed, `_sel` reads
**`{ def: '__remove', dir: 0 }`** — key your demolition tint off that def id. It is safe
because sim's place handler is `const def = defs.get(cmd.def); if (def) ...`, so main.ts's
click-to-place fires a `place` for `__remove`, matches no machine, and does nothing; the
real removal is dispatched by the UI. Exported as `REMOVE_DEF` from `src/ui/selection.ts`
and pinned by a test. A magic string is not how I'd design this — CONTRACT REQUEST 1.
- **Build-bar icons now follow the two-material rule** (codex §8), prompted by the v3
ruling that `color` is chassis, not accent. DATA's chassis values are all desaturated
neutrals — correct for the world, but 21 icons in `#4a4e52`/`#3f4348` is an unreadable
grey wall. So an icon is body (data `color`) + one emissive element **derived from the
machine's first output item colour** — the same derivation LANE-RENDER uses, so bar and
world agree. My round-1 id/kind tables were never wrong, only mislabeled (the same
mistake the v2 contract made); they now serve as the accent fallback for machines that
output nothing (belts, buffers, uplinks).
- Inspector reads `EntityState.scrammed`, not `heat >= 1`. Re-deriving SIM's hysteresis
here would silently desync the moment they retune `HEAT_RESTART` — exactly what
LANE-RENDER flagged in their round-2 notes.
**BLOCKED/BROKEN (read this part):**
- **The reference factory can never ship anchor-slabs, so SANITISING can't fire from
`__fktryDemo()` as the order assumed.** It recycles them on purpose — reference.ts:
*"Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
i-only assembler."* Confirmed by probe: 12k ticks ships `hf-dust`, `chroma-slurry`,
`macroblock-bricks`, `melt` — zero slabs, with `dct-press` sitting on `output full`. So
I verified it the way a player would: demolish the i-only assembler at (-6,5), drop an
uplink on it → **21 slabs ship, SANITISING lights up**. That's what `live.test.ts` does.
Nobody's bug; the order's premise just didn't hold.
- **Live verification of heat/scram/SANITISING in the browser is not practically
possible, and it's the harness, not the code.** The browser pane keeps the tab
backgrounded (`document.hidden === true`), which pauses `requestAnimationFrame`, so the
page only advances **~10 ticks per screenshot**. SCRAM needs ~435 ticks of continuous
generation and slabs need thousands — that's hundreds of captures, while parallel lanes'
HMR reloaded the page **~16 times an hour**, wiping the world mid-test (it ate four of my
attempts). Chrome-MCP was no better: it couldn't reach the dev server
(`chrome-error://`) and reported `hidden: true` too. Hence `live.test.ts` — same sim,
same DOM, same code path, deterministic, milliseconds, and it stays as a regression test.
I'd rather hand you that than a screenshot I had to fight the harness for.
- **I nearly reported `commissionQueue` as unpublished.** My first grep for
`snap.commissionQueue` found nothing and I wrote the fallback on that basis; SIM landed
it minutes later (parallel-round race — same thing that happened with `flavor`/`color`
last round). A probe against the real sim caught it. Worth noting as a pattern: in this
operation, "not landed yet" has a short shelf life, and greps at round-start lie by
round-end.
- **Order 5 (tech panel) skipped.** `tech.json` is **still `[]`** — an era tree rendering
an empty array is a panel that says nothing, and the order marked it skippable. It's
~30 lines whenever DATA fills the file.
- The `?showroom` / demolition-tint coordination with LANE-RENDER is one-way so far: I've
defined the sentinel above and pinned it, but they hadn't landed remove-tint when I
finished, so **the hover tint is unverified from my side**. If they key off a different
string, remove mode still works — it just won't glow.
**CONTRACT REQUEST:**
1. **`UIBus.setGhostMode(mode: 'build' | 'remove' | null)`** (or let `setGhost` take a
mode) — *why:* remove-mode hover currently rides a magic def id through
`selectedBuild()`, and main.ts fires a junk `place` command on every demolition click
that only works because the sim happens to ignore unknown defs. It's two lanes agreeing
on a string in a NOTES file. One typed field kills it.
2. **`MachineDef.accent?: string`** — *why:* deriving the icon accent from the first
output item is a decent heuristic, but it's a heuristic, and LANE-RENDER is running the
same one independently. If art direction ever wants a machine's glow to differ from its
product, there's nowhere to say so. (LANE-RENDER asked for this in round 2 as well.)
**PROPOSAL:** `?uidemo` — a URL param that runs `__fktryDemo()` on boot. Trivial, in my
lane, and it would have saved most of an hour this round: every HMR reload wiped the world
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.

232
fktry/src/sim/bloom.test.ts Normal file
View File

@ -0,0 +1,232 @@
/**
* The bloom loop M3's signature topology, proved early.
*
* Two separate claims are tested here, because they fail for different reasons:
*
* 1. The loop MECHANIC: a closed belt circle has no terminal machine, so it ranks 0 and
* machines will push onto it. Cargo circulates forever instead of wedging, and the
* tracer doesn't spin trying to walk it.
*
* 2. DATA's grade recipes COMPOSE: a closed delta-wafer bus with splitter taps feeding
* one duplicator per grade escalates bloom-concentrate all the way to grade 5.
*
* On the topology: the codex says "any closed belt circle with a P-FRAME DUPLICATOR on it;
* contents compound each lap". With DATA's recipes that exact build cannot work each
* grade is a distinct recipe and a machine runs one recipe, so a lone duplicator can never
* consume its own output. The shape that DOES compose puts the circle underneath as a
* shared wafer bus: unclaimed wafers keep going round, and every duplicator gets fed on a
* later lap. The loop is what makes the taps fair. See NOTES round 3.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import type { Command, Dir, GameData, 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 DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
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);
return sim;
}
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);
}
}
describe('closed belt circles', () => {
it('accept cargo and circulate it forever without losing or wedging it', () => {
const sim = newSim();
const cmds: Command[] = [];
const b = (x: number, y: number, dir: Dir) => cmds.push({ kind: 'place', def: 'belt', pos: { x, y }, dir });
cmds.push({ kind: 'place', def: 'decode-asic', pos: { x: 0, y: 10 }, dir: N });
cmds.push({ kind: 'place', def: 'seam-extractor', pos: { x: 0, y: 0 }, dir: N }); // 2x2 at (0,0)
// A 4x4 ring the miner can reach: it feeds (2,0), which is part of the circle.
for (let x = 2; x <= 4; x++) b(x, 0, E);
b(5, 0, S);
for (let y = 1; y <= 3; y++) b(5, y, S);
b(5, 4, W);
for (let x = 4; x >= 3; x--) b(x, 4, W);
b(2, 4, N);
for (let y = 3; y >= 1; y--) b(2, y, N); // (2,1) -> (2,0) closes the ring
for (const c of cmds) sim.enqueue(c);
run(sim, 400);
const loaded = sim.snapshot().beltItems.length;
expect(loaded).toBeGreaterThan(0); // rank 0: the miner was willing to push onto a loop
// Cargo keeps moving: track one item and prove it changes tiles rather than parking.
const tracked = sim.snapshot().beltItems[0].id;
const seen = new Set<number>();
for (let i = 0; i < 600; i++) {
run(sim, 1);
const it = sim.snapshot().beltItems.find((x) => x.id === tracked);
if (it) seen.add(it.entity);
}
expect(seen.size).toBeGreaterThan(3); // it went round, it didn't sit at a dead end
// And the ring fills to a stable holding pattern rather than deleting anything.
const before = sim.snapshot().beltItems.length;
run(sim, 2000);
expect(sim.snapshot().beltItems.length).toBeGreaterThanOrEqual(before);
expect(sim.snapshot().entities.find((e) => e.def === 'seam-extractor')!.jammed).not.toBeNull();
});
});
/**
* ore -> demuxer -> mv-flux -> [split] -+-> p-caster -> delta wafers -> the BUS (a closed
* | circle, tapped by 5 splitters)
* +-> tap-bloom
* tap-bloom -> concentrate -> grade1 -> grade2 -> grade3 -> grade4 -> grade5 -> uplink
*/
function bloomLoop(): 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 });
// Power: ASICs only. Heat is not what this test is about.
for (let i = 0; i < 8; i++) P('decode-asic', -30 + i * 3, -12);
// Feeder: ore -> demuxer. Luma and slurry are byproducts here; sell both.
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);
runX(-24, -23, 0, E);
P('shipper', -22, 0);
runY(2, 3, -26, S);
P('shipper', -27, 4);
// mv-flux splits between the wafer press and the bloom tap. The tap only wants 4 of
// every ~56, so it fills, blocks, and the splitter hands the rest to the p-caster.
b(-25, -1, N);
P('lane-splitter', -25, -2);
b(-25, -3, N);
P('p-caster', -26, -5);
runX(-24, 14, -2, E);
runY(-2, 1, 15, S);
b(15, 2, W); // -> tap-bloom at (14,2), at the far end of the bus
// Wafers leave the press, run the long way round, and merge into the bus's right edge.
runX(-26, 17, -6, E);
runY(-6, 9, 18, S);
b(18, 10, W);
b(17, 10, W); // -> (16,10), a bus belt
// THE BUS: a closed circle, (0,4) to (16,14), with five splitter taps on its top edge.
const taps = [2, 5, 8, 11, 14];
for (let x = 0; x <= 15; x++) {
if (taps.includes(x)) P('lane-splitter', x, 4);
else b(x, 4, E);
}
b(16, 4, S);
runY(5, 13, 16, S);
b(16, 14, W);
runX(15, 1, 14, W);
b(0, 14, N);
runY(13, 5, 0, N); // (0,5) -> (0,4): the circle closes
// Each tap hands wafers north, out of the ring, to its duplicator.
for (const x of taps) b(x, 3, N);
// The escalator runs COUNTER-CURRENT to the wafer flow, and that ordering is load
// bearing. Wafers join the ring at its right edge and reach the taps left-to-right, so
// whoever taps first eats first. Grade 5 wants 8 wafers a craft and grade 4 wants 5,
// while the bloom tap only wants 8 per finished grade-1 — put the tap first (measured
// it) and it swallows half of everything, grade 3 backs up 'output full' behind a
// starving grade 4, and 30,000 ticks yields exactly one grade 5. Hungriest first.
P('bloom-duplicator', 14, 2); // tap-bloom (default recipe), fed last
b(14, 1, N);
P('bloom-duplicator', 14, 0);
recipeAt(14, 0, 'bloom-grade-1');
runX(13, 12, 0, W);
b(11, 0, S);
b(11, 1, S);
P('bloom-duplicator', 11, 2);
recipeAt(11, 2, 'bloom-grade-2');
runX(10, 9, 2, W);
P('bloom-duplicator', 8, 2);
recipeAt(8, 2, 'bloom-grade-3');
runX(7, 6, 2, W);
P('bloom-duplicator', 5, 2);
recipeAt(5, 2, 'bloom-grade-4');
runX(4, 3, 2, W);
P('bloom-duplicator', 2, 2);
recipeAt(2, 2, 'bloom-grade-5'); // hungriest, tapped first
b(1, 2, W);
P('shipper', -1, 1);
return cmds;
}
describe('bloom loop', () => {
it('escalates concentrate to grade 5 off a closed wafer bus', () => {
const sim = newSim();
for (const c of bloomLoop()) sim.enqueue(c);
const evs: SimEvent[] = [];
run(sim, 20000, evs);
const snap = sim.snapshot();
// Every rung of DATA's ladder actually ran.
for (const g of ['tap-bloom', 'bloom-grade-1', 'bloom-grade-2', 'bloom-grade-3',
'bloom-grade-4', 'bloom-grade-5']) {
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === g).length,
`${g} never crafted`).toBeGreaterThan(0);
}
expect(snap.shippedTotal['bloom-grade-5'] ?? 0).toBeGreaterThan(0);
});
it('keeps wafers circulating so the far taps get fed too', () => {
const sim = newSim();
for (const c of bloomLoop()) sim.enqueue(c);
run(sim, 20000);
const snap = sim.snapshot();
// The last tap on the bus is the hungriest (8 wafers) and the furthest from the
// injection point. It only ever eats because unclaimed wafers come round again.
const grade5 = snap.entities.find((e) => e.pos.x === 2 && e.pos.y === 2)!;
expect(grade5.recipe).toBe('bloom-grade-5');
expect(grade5.jammed).not.toBe('output full');
// Wafers are genuinely resident on the ring rather than all consumed on arrival.
const ringIds = new Set(snap.entities.filter((e) =>
(e.def === 'belt' || e.def === 'lane-splitter') && e.pos.y >= 4 && e.pos.y <= 14
&& e.pos.x >= 0 && e.pos.x <= 16).map((e) => e.id));
const onRing = snap.beltItems.filter((i) => ringIds.has(i.entity));
expect(onRing.length).toBeGreaterThan(0);
expect(onRing.every((i) => i.item === 'delta-wafer')).toBe(true);
});
it('never wedges the head of the chain', () => {
const sim = newSim();
for (const c of bloomLoop()) sim.enqueue(c);
run(sim, 20000);
const demuxer = sim.snapshot().entities.find((e) => e.def === 'demuxer')!;
expect(demuxer.jammed).not.toBe('output full');
});
});

View File

@ -40,8 +40,8 @@ const MAX_TRACE = 512;
const HEAT_THROTTLE_START = 0.7;
/** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */
const HEAT_THROTTLE_FLOOR = 0.5;
/** Heat shed per tick, always, active or not. */
const HEAT_DISSIPATION = 0.004;
/** Heat shed per tick, always, active or not. Overridden per machine by `coolPerTick`. */
const HEAT_COOL_DEFAULT = 0.004;
/** Cool back below this and a scrammed machine comes back up. */
const HEAT_RESTART = 0.5;
@ -272,6 +272,7 @@ export function createSim(): Sim {
outputBuf: {},
jammed: null,
heat: 0,
scrammed: false,
};
const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 };
ents.push(e);
@ -328,6 +329,10 @@ export function createSim(): Sim {
function doSetRecipe(entity: number, recipe: string | null): void {
const e = byId.get(entity);
if (!e) return;
setRecipeOn(e, recipe);
}
function setRecipeOn(e: Ent, recipe: string | null): void {
if (recipe !== null && !e.def.recipes.includes(recipe)) return;
e.state.recipe = recipe;
e.state.progress = 0;
@ -346,6 +351,11 @@ export function createSim(): Sim {
case 'remove': doRemove(cmd.pos); break;
case 'rotate': doRotate(cmd.pos); break;
case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break;
case 'setRecipeAt': {
const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works
if (e) setRecipeOn(e, cmd.recipe);
break;
}
case 'setPaused': paused = cmd.paused; snap.paused = paused; break;
}
}
@ -386,10 +396,15 @@ export function createSim(): Sim {
}
/**
* Bandwidth v2: gen vs draw with tank storage. Compression pays for itself a recipe
* Bandwidth v3: gen vs draw with tank storage. Compression pays for itself a recipe
* with negative bandwidth (the quantizer) generates instead of drawing. Surplus charges
* the tanks; a deficit drains them. Only once the tanks run dry does the factory
* brown out and run at gen/draw speed.
* the tanks; a deficit drains them. Only once the tanks run dry does the factory brown
* out and run at supplied/draw speed.
*
* UNITS (v3 ruling): gen and draw are bandwidth per SECOND; stored is bandwidth-SECONDS.
* So a tick only ever moves one tick's worth of charge surplus/TICKS_PER_SECOND into
* or out of the tanks. A 30/s deficit drains 30 stored per second, not per tick, which is
* what makes DATA's bufferCap 240 the 8 seconds of cover they intended.
*/
function computePower(): number {
let gen = 0;
@ -411,16 +426,18 @@ export function createSim(): Sim {
let supplied = gen;
if (gen >= draw) {
const room = capacity - stored;
const charge = Math.min(gen - draw, room);
const charge = Math.min((gen - draw) / TICKS_PER_SECOND, capacity - stored);
if (charge > 0) stored += charge;
} else {
const drawn = Math.min(draw - gen, stored);
const wanted = (draw - gen) / TICKS_PER_SECOND; // bandwidth-seconds owed this tick
const drawn = Math.min(wanted, stored);
stored -= drawn;
supplied = gen + drawn;
supplied = gen + drawn * TICKS_PER_SECOND;
}
const on = supplied < draw;
// Epsilon: a tank covering a deficit exactly lands supplied on draw, and float drift
// there would otherwise flicker the brownout flag on and off every tick.
const on = supplied < draw - 1e-9;
if (on !== brownout) {
brownout = on;
events.push({ kind: 'brownout', on, tick: curTick });
@ -449,22 +466,35 @@ export function createSim(): Sim {
}
}
/** Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram. */
/**
* Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram.
*
* Cooling is applied every tick whether or not the machine is working, so `heatPerTick`
* is GROSS heat and the net climb is heatPerTick - coolPerTick. A machine whose
* heatPerTick doesn't clear its cooling simply never overheats. That's what makes the
* codex's ASIC coolers mean something — and it's the trap DATA is currently in; see NOTES.
*/
function updateHeat(): void {
for (const e of ents) {
const hpt = e.def.heatPerTick ?? 0;
// Most of a big factory is belts, and a belt can never be anything but stone cold.
// Skipping them here is the difference between 59x and 70x realtime at 3,500 entities.
if (hpt === 0 && e.state.heat === 0 && !e.scrammed) continue;
const cool = e.def.coolPerTick ?? HEAT_COOL_DEFAULT;
const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
let h = e.state.heat + (active ? hpt : 0) - HEAT_DISSIPATION;
let h = e.state.heat + (active ? hpt : 0) - cool;
if (h < 0) h = 0;
else if (h > 1) h = 1;
e.state.heat = h;
if (!e.scrammed && h >= 1) {
e.scrammed = true;
e.state.scrammed = true;
setJam(e, null); // scram is reported by its own event, not as a flow jam
events.push({ kind: 'scram', entity: e.state.id, on: true, tick: curTick });
} else if (e.scrammed && h < HEAT_RESTART) {
e.scrammed = false;
e.state.scrammed = false;
events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick });
}
}
@ -601,7 +631,7 @@ export function createSim(): Sim {
if (c < cap) cap = c;
}
if (target > cap) target = cap;
} else if (next && tryPushToMachine(next, it.item, it.id ?? 0)) {
} else if (next && tryPushToMachine(next, it.item, it.id)) {
items.splice(idx, 1);
removeFlat(it);
return true;
@ -636,6 +666,7 @@ export function createSim(): Sim {
function activateCommission(): void {
const id = commissionQueue[0] ?? null;
snap.activeCommission = id;
snap.commissionQueue = commissionQueue; // active first, then upcoming: UI's "next in tray"
snap.commissionProgress = {};
const c = id === null ? undefined : commissionDefs.get(id);
if (c) for (const k in c.wants) snap.commissionProgress[k] = 0;
@ -747,5 +778,101 @@ export function createSim(): Sim {
events = [];
return out;
},
/**
* Everything the tick loop reads, including the state the snapshot deliberately hides:
* the crafting/scram latches, splitter queues and cursors, and the RNG position. Miss
* any of those and a loaded save diverges a few hundred ticks later, which is exactly
* the bug that never reproduces. `data` is not saved load() into a sim init()'d with
* the same GameData.
*/
save(): string {
return JSON.stringify({
v: 3,
tick: curTick,
paused,
brownout,
stored,
nextId,
nextItemId,
rng: rng.state(),
commissionQueue,
shippedTotal: snap.shippedTotal,
commissionProgress: snap.commissionProgress,
pending: cmdQueue, // commands enqueued but not yet applied
entities: ents.map((e) => ({
state: e.state,
crafting: e.crafting,
scrammed: e.scrammed,
queue: e.queue,
cursor: e.cursor,
})),
beltItems: allBeltItems,
});
},
load(json: string): void {
const s = JSON.parse(json);
if (s.v !== 3) throw new Error(`sim.load: unsupported save version ${s.v}`);
ents = [];
entityStates = [];
allBeltItems = [];
byId.clear();
occ.clear();
beltContents.clear();
traceCache.clear();
cmdQueue.length = 0;
events = [];
moved.clear();
for (const rec of s.entities) {
const def = defs.get(rec.state.def);
if (!def) throw new Error(`sim.load: this GameData has no machine "${rec.state.def}"`);
const state = rec.state as EntityState;
const fp = footprintOf(def.footprint, state.dir);
const tiles: number[] = [];
for (let dx = 0; dx < fp.x; dx++) {
for (let dy = 0; dy < fp.y; dy++) tiles.push(tileKey(state.pos.x + dx, state.pos.y + dy));
}
const e: Ent = {
state, def, crafting: rec.crafting, scrammed: rec.scrammed,
tiles, queue: rec.queue, cursor: rec.cursor,
};
ents.push(e);
entityStates.push(state);
byId.set(state.id, e);
for (const k of tiles) occ.set(k, state.id);
if (def.kind === 'belt') beltContents.set(state.id, []);
}
for (const it of s.beltItems as BeltItem[]) {
allBeltItems.push(it);
beltContents.get(it.entity)?.push(it);
}
// Belt contents are ordered front-first everywhere else; restore that invariant
// rather than trusting the blob's ordering.
for (const list of beltContents.values()) list.sort((a, b) => b.t - a.t);
curTick = s.tick;
paused = s.paused;
brownout = s.brownout;
stored = s.stored;
nextId = s.nextId;
nextItemId = s.nextItemId;
rng.restore(s.rng);
commissionQueue = s.commissionQueue;
for (const cmd of s.pending ?? []) cmdQueue.push(cmd);
snap.tick = curTick;
snap.paused = paused;
snap.entities = entityStates;
snap.beltItems = allBeltItems;
snap.bandwidth = { gen: 0, draw: 0, stored, brownout };
snap.shippedTotal = s.shippedTotal;
snap.commissionProgress = s.commissionProgress;
snap.activeCommission = commissionQueue[0] ?? null;
snap.commissionQueue = commissionQueue;
},
};
}

View File

@ -0,0 +1,121 @@
/**
* save()/load() round-trip.
*
* The bar is not "the snapshot looks the same" it's "the future is the same". A save
* that restores visible state but drops a splitter cursor or the RNG position looks
* perfect and then quietly diverges a few hundred ticks later. So every test here loads
* a blob and then runs BOTH sims forward before comparing.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory } from './reference';
import type { GameData, Sim } 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 DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function fresh(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
return sim;
}
function run(sim: Sim, ticks: number): void {
for (let i = 0; i < ticks; i++) { sim.tick(); sim.drainEvents(); }
}
describe('save/load', () => {
it('restores a running factory so both sims agree 1,000 ticks later', () => {
const original = fresh();
for (const c of referenceFactory(DATA)) original.enqueue(c);
run(original, 1500); // mid-flight: belts loaded, a scram cycle already under way
const restored = fresh();
restored.load!(original.save!());
expect(restored.snapshot().tick).toBe(original.snapshot().tick);
run(original, 1000);
run(restored, 1000);
expect(restored.snapshot()).toEqual(original.snapshot());
expect(restored.snapshot().shippedTotal['melt']).toBeGreaterThan(0); // not a dead factory
});
it('round-trips through JSON without drifting', () => {
const a = fresh();
for (const c of referenceFactory(DATA)) a.enqueue(c);
run(a, 800);
const blob = a.save!();
expect(() => JSON.parse(blob)).not.toThrow();
const b = fresh();
b.load!(blob);
// Saving the restored sim must reproduce the same bytes: nothing was lost in the gap.
expect(b.save!()).toBe(blob);
});
it('survives a save/load/save/load chain', () => {
const a = fresh();
for (const c of referenceFactory(DATA)) a.enqueue(c);
run(a, 600);
let carried = fresh();
carried.load!(a.save!());
for (let i = 0; i < 3; i++) {
run(a, 200);
run(carried, 200);
const next = fresh();
next.load!(carried.save!());
carried = next;
}
run(a, 500);
run(carried, 500);
expect(carried.snapshot()).toEqual(a.snapshot());
});
it('preserves splitter cursors and queues, not just what the snapshot shows', () => {
const a = fresh();
for (const c of referenceFactory(DATA)) a.enqueue(c);
run(a, 1200); // long enough for the splitters to be mid-rotation
const b = fresh();
b.load!(a.save!());
// A dropped round-robin cursor is invisible at rest and shows up as divergence later.
run(a, 2000);
run(b, 2000);
expect(b.snapshot()).toEqual(a.snapshot());
});
it('preserves commands enqueued but not yet ticked', () => {
const a = fresh();
for (const c of referenceFactory(DATA)) a.enqueue(c);
run(a, 100);
a.enqueue({ kind: 'place', def: 'shipper', pos: { x: 20, y: 20 }, dir: 0 });
const b = fresh();
b.load!(a.save!()); // saved with that place still pending
run(a, 5);
run(b, 5);
expect(b.snapshot().entities.length).toBe(a.snapshot().entities.length);
expect(b.snapshot()).toEqual(a.snapshot());
});
it('refuses a save from an unknown version', () => {
const sim = fresh();
expect(() => sim.load!(JSON.stringify({ v: 99 }))).toThrow(/version/);
});
it('refuses a save referencing machines this GameData does not have', () => {
const a = fresh();
a.enqueue({ kind: 'place', def: 'demuxer', pos: { x: 0, y: 0 }, dir: 0 });
run(a, 1);
const blob = a.save!().replace(/"demuxer"/g, '"machine-from-the-future"');
const b = fresh();
expect(() => b.load!(blob)).toThrow(/machine-from-the-future/);
});
});

View File

@ -138,35 +138,43 @@ describe('splitters', () => {
});
describe('buffer tanks', () => {
// v3 units: gen/draw are per SECOND, stored is bandwidth-SECONDS. A 30/s surplus adds
// 30 stored per second — i.e. 1 per tick — not 30 per tick.
it('charges on surplus, covers a deficit, and only browns out once dry', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0));
sim.enqueue(place('gen', 1, 0));
sim.enqueue(place('gen', 2, 0)); // gen 30
sim.enqueue(place('tank', 3, 0)); // cap 100
sim.enqueue(place('gen', 2, 0)); // gen 30/s
sim.enqueue(place('tank', 3, 0)); // cap 100 bandwidth-seconds
run(sim, 1);
expect(sim.snapshot().bandwidth.stored).toBe(30); // surplus 30 -> charge 30
run(sim, 3);
expect(sim.snapshot().bandwidth.stored).toBe(100); // clamps at capacity
expect(sim.snapshot().bandwidth.stored).toBe(1); // 30/s surplus over 1/30 s = 1
run(sim, 99);
expect(sim.snapshot().bandwidth.stored).toBe(100); // full after 100 ticks, then clamps
run(sim, 10);
expect(sim.snapshot().bandwidth.stored).toBe(100);
expect(sim.snapshot().bandwidth.brownout).toBe(false);
// Draw 40 against gen 30: a 10/tick deficit the tank should cover for 10 ticks.
// Draw 60 against gen 30: a 30/s deficit, which is 1 stored per tick. 100 stored is
// therefore exactly 100 ticks of cover.
sim.enqueue(place('load', 0, 1));
sim.enqueue(place('load', 1, 1));
sim.enqueue(place('load', 2, 1));
const evs: SimEvent[] = [];
run(sim, 1, evs);
expect(sim.snapshot().bandwidth.draw).toBe(40);
expect(sim.snapshot().bandwidth.stored).toBe(90);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // tank is carrying it
expect(sim.snapshot().bandwidth.draw).toBe(60);
expect(sim.snapshot().bandwidth.stored).toBe(99);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // the tank is carrying it
run(sim, 9, evs);
run(sim, 99, evs);
expect(sim.snapshot().bandwidth.stored).toBe(0);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered
expect(evs.filter((e) => e.kind === 'brownout')).toHaveLength(0);
run(sim, 1, evs);
expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go
expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go
expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1);
expect(sim.snapshot().bandwidth.gen).toBe(30); // still reported per second
});
it('browns out immediately with no tank at all', () => {

View File

@ -69,9 +69,24 @@ export function referenceFactory(data: GameData): Command[] {
cmds.push({ kind: 'setRecipe', entity, recipe: recipeId(recipe) });
};
// ---- power. Four decoders cover ~146 draw at full tilt, with room for the i-only
// assembler's 24-bandwidth spikes.
for (let i = 0; i < 4; i++) P('software-decoder', -30 + i * 3, -14);
// ---- power: ASIC base + software-decoder burst + tanks.
//
// Round 2 ran four naked software decoders. Once DATA gave them real heat they all
// scrammed in lockstep and the factory blacked out, which is why melt stopped arriving.
// The fix is the shape the codex always implied: cool inflexible ASICs hold the floor,
// one hot decoder bursts on top of them, and the tanks ride out its downtime.
//
// 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. Four ASICs (80/s) against ~107/s draw leaves a 27/s hole for the ~16.7s
// the decoder is down = ~450 bandwidth-seconds, and three tanks hold 900. Two ASICs
// fewer and it survives only while compression happens to be running — measured that,
// it bottomed out at 2.2 of 600, which is not a margin, it's a coincidence.
for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -14);
P('software-decoder', -18, -14);
P('buffer-tank', -15, -14);
P('buffer-tank', -12, -14);
P('buffer-tank', -9, -14);
// ---- mining. Three seams keep the demuxer at its 45-tick cadence (it eats 3 ore).
P('seam-extractor', -30, 0);

View File

@ -9,6 +9,10 @@ export interface Rng {
next(): number;
/** integer in [0, maxExclusive) */
int(maxExclusive: number): number;
/** current internal state, for save() */
state(): number;
/** restore a state() value, for load() */
restore(state: number): void;
}
export function makeRng(seed: number): Rng {
@ -20,5 +24,10 @@ export function makeRng(seed: number): Rng {
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return { next, int: (maxExclusive: number) => Math.floor(next() * maxExclusive) };
return {
next,
int: (maxExclusive: number) => Math.floor(next() * maxExclusive),
state: () => a,
restore: (state: number) => { a = state >>> 0; },
};
}

View File

@ -5,7 +5,7 @@
import type { GameData, MachineDef } from '../contracts';
import { attrs, cls, el, panel, text } from './dom';
import { paginate, pageOf, type BuildPage } from './pages';
import { machineColor } from './palette';
import { createAccentLookup, machineChassis } from './palette';
import type { BuildSelection } from './selection';
import { COPY, DIR_NAME } from './voice';
@ -19,6 +19,7 @@ export interface BuildBar {
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
const pages: BuildPage[] = paginate(data.machines);
const accentOf = createAccentLookup(data);
const root = panel();
root.id = 'fk-build';
@ -42,6 +43,16 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
return tab;
});
// REMOVE lives beside the tabs, not on a page: it is a mode, not a machine, and it
// must be reachable whatever page you are on.
const removeBtn = el('button', 'fk-tab fk-tab-remove', [
COPY.removeLabel,
el('span', 'fk-tab-key', [COPY.removeKey]),
]);
attrs(removeBtn, { type: 'button', title: 'REMOVE MODE (X)' });
removeBtn.addEventListener('click', () => sel.toggleRemove());
tabRow.append(removeBtn);
/** Buttons for the active page only; rebuilt on page change (nine at most). */
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
@ -51,8 +62,13 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
const btn = el('button', 'fk-build-btn');
attrs(btn, { type: 'button', title: `${m.name}${m.kind}` });
// Two-material rule (codex §8): grimy body, one impossible element.
const ico = el('div', 'fk-build-ico');
ico.style.background = machineColor(m);
ico.style.background = machineChassis(m);
const accent = el('div', 'fk-build-accent');
accent.style.background = accentOf(m);
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
ico.append(accent);
btn.append(ico);
const key = el('span', 'fk-build-key');
@ -74,14 +90,21 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
function sync() {
const cur = sel.get();
const removing = sel.isRemoving();
for (const b of buttons) cls(b.btn, 'is-sel', cur?.def === b.def);
if (cur) {
cls(removeBtn, 'is-armed', removing);
if (removing) {
text(hint, COPY.removeHint);
} else if (cur) {
const def = data.machines.find((m) => m.id === cur.def);
text(hint, `${def?.name ?? cur.def} · FACING ${DIR_NAME[cur.dir]} · ${COPY.placingHint}`);
} else {
text(hint, COPY.idleHint);
}
cls(hint, 'is-placing', !!cur);
cls(hint, 'is-removing', removing);
}
// A selection made from anywhere (including a future hotkey we don't own) pulls the

View File

@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest';
import type { ItemDef } from '../contracts';
import { createChipRow } from './chips';
const ITEMS = new Map<string, ItemDef>([
['melt', { id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff7a3f' }],
['mdat-ore', { id: 'mdat-ore', name: 'MDAT ORE', codex: '', tier: 0, color: '#1a1a22' }],
['hf-dust', { id: 'hf-dust', name: 'HF DUST', codex: '', tier: 1, color: '#e8e0ff' }],
]);
// Array.from, not spread: the root tsconfig's lib list has DOM but not DOM.Iterable,
// so a NodeList isn't iterable in types. Root config isn't this lane's to edit.
const texts = (row: HTMLElement) =>
Array.from(row.querySelectorAll('.fk-chip')).map((c) => c.textContent);
const ids = (row: HTMLElement) =>
Array.from(row.querySelectorAll('.fk-chip')).map((c) => c.getAttribute('title'));
describe('createChipRow', () => {
let row: ReturnType<typeof createChipRow>;
beforeEach(() => {
row = createChipRow(ITEMS);
});
it('renders a chip per spec with the item display name and count', () => {
row.update([{ item: 'melt', count: '3/40' }]);
expect(texts(row.el)).toEqual(['MELT3/40']);
});
it('paints the swatch with the item colour from data', () => {
row.update([{ item: 'melt', count: '1' }]);
const sw = row.el.querySelector('.fk-chip-sw') as HTMLElement;
expect(sw.style.background).toBe('#ff7a3f');
});
it('reuses the same node across updates instead of rebuilding the row', () => {
row.update([{ item: 'melt', count: '1' }]);
const first = row.el.querySelector('.fk-chip');
row.update([{ item: 'melt', count: '2' }]);
expect(row.el.querySelector('.fk-chip')).toBe(first); // same node, new count
expect(texts(row.el)).toEqual(['MELT2']);
});
it('applies state classes and clears them again', () => {
row.update([{ item: 'melt', count: '0/1', state: 'short' }]);
expect(row.el.querySelector('.fk-chip')!.className).toBe('fk-chip is-short');
row.update([{ item: 'melt', count: '1/1', state: 'met' }]);
expect(row.el.querySelector('.fk-chip')!.className).toBe('fk-chip is-met');
row.update([{ item: 'melt', count: '9' }]);
expect(row.el.querySelector('.fk-chip')!.className).toBe('fk-chip');
});
it('removes chips that are no longer in the spec', () => {
row.update([{ item: 'melt', count: '1' }, { item: 'hf-dust', count: '2' }]);
expect(ids(row.el)).toEqual(['melt', 'hf-dust']);
row.update([{ item: 'hf-dust', count: '3' }]);
expect(ids(row.el)).toEqual(['hf-dust']);
});
it('keeps DOM order matching spec order', () => {
row.update([{ item: 'melt', count: '1' }, { item: 'hf-dust', count: '1' }]);
row.update([{ item: 'hf-dust', count: '1' }, { item: 'melt', count: '1' }]);
expect(ids(row.el)).toEqual(['hf-dust', 'melt']);
});
it('renders an unknown item as a drab chip rather than crashing', () => {
// A dangling id from DATA must never take the panel down.
row.update([{ item: 'not-an-item', count: '1' }]);
expect(texts(row.el)).toEqual(['???1']);
});
it('renders nothing for an empty spec', () => {
row.update([{ item: 'melt', count: '1' }]);
row.update([]);
expect(row.el.querySelectorAll('.fk-chip')).toHaveLength(0);
});
});

134
fktry/src/ui/fax.test.ts Normal file
View File

@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameData, SimSnapshot } from '../contracts';
import { createFax } from './fax';
const DATA: GameData = {
items: [
{ id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff7a3f' },
{ id: 'anchor-slab', name: 'I-FRAME', codex: '', tier: 1, color: '#fff2a0' },
{ id: 'hf-dust', name: 'HF DUST', codex: '', tier: 1, color: '#e8e0ff' },
],
machines: [],
recipes: [],
tech: [],
commissions: [
{ id: 'first-taste', flavor: 'One (1) unit of MELT.', wants: { melt: 1 }, rewardBandwidth: 50 },
{ id: 'dust-allowance', flavor: 'Dust. Weekly.', wants: { 'hf-dust': 5 }, rewardBandwidth: 20, repeat: true },
{ id: 'patio-resurfacing', flavor: 'Bricks.', wants: { melt: 9 }, rewardBandwidth: 30 },
],
};
function snap(over: Partial<SimSnapshot> = {}): SimSnapshot {
return {
tick: 0, paused: false, entities: [], beltItems: [],
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
shippedTotal: {}, activeCommission: 'first-taste', commissionProgress: {}, ...over,
};
}
const q = (el: HTMLElement, s: string) => el.querySelector(s) as HTMLElement;
const on = (el: HTMLElement, s: string) => q(el, s).classList.contains('is-on');
describe('fax stamp lifecycle', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('is not stamped until a commission completes', () => {
const fax = createFax(DATA);
expect(on(fax.el, '.fk-fax-stamp')).toBe(false);
});
it('stamps on demand and clears itself after the animation', () => {
const fax = createFax(DATA);
fax.stamp();
expect(on(fax.el, '.fk-fax-stamp')).toBe(true);
vi.advanceTimersByTime(1799);
expect(on(fax.el, '.fk-fax-stamp')).toBe(true); // still landed
vi.advanceTimersByTime(1);
expect(on(fax.el, '.fk-fax-stamp')).toBe(false);
});
it('restarts cleanly when two commissions complete back to back', () => {
const fax = createFax(DATA);
fax.stamp();
vi.advanceTimersByTime(1000);
fax.stamp(); // second order lands mid-animation
vi.advanceTimersByTime(1000);
// The first stamp's timer must not clear the second one early.
expect(on(fax.el, '.fk-fax-stamp')).toBe(true);
vi.advanceTimersByTime(800);
expect(on(fax.el, '.fk-fax-stamp')).toBe(false);
});
});
describe('fax panel', () => {
it('stays shut when there is no active commission', () => {
const fax = createFax(DATA);
fax.update(snap({ activeCommission: null }));
expect(fax.el.classList.contains('is-open')).toBe(false);
});
it('shows the active commission flavor, wants and reward', () => {
const fax = createFax(DATA);
fax.update(snap({ commissionProgress: { melt: 0 } }));
expect(fax.el.classList.contains('is-open')).toBe(true);
expect(q(fax.el, '.fk-fax-flavor').textContent).toBe('One (1) unit of MELT.');
expect(q(fax.el, '.fk-chip').textContent).toBe('MELT0/1');
expect(fax.el.textContent).toContain('+50 BANDWIDTH');
});
it('stamps STANDING ORDER only for a repeat commission', () => {
const fax = createFax(DATA);
fax.update(snap());
expect(on(fax.el, '.fk-fax-standing')).toBe(false);
fax.update(snap({ activeCommission: 'dust-allowance' }));
expect(on(fax.el, '.fk-fax-standing')).toBe(true);
});
it('shows SANITISING only once anchor-slabs have actually shipped', () => {
const fax = createFax(DATA);
fax.update(snap());
expect(on(fax.el, '.fk-san')).toBe(false);
fax.update(snap({ shippedTotal: { 'anchor-slab': 4 } }));
expect(on(fax.el, '.fk-san')).toBe(true);
expect(q(fax.el, '.fk-san .fk-chip').textContent).toBe('I-FRAME4');
});
});
describe('NEXT IN TRAY', () => {
it('reads snap.commissionQueue as truth: active first, next is queue[1]', () => {
const fax = createFax(DATA);
fax.update(snap({
activeCommission: 'first-taste',
commissionQueue: ['first-taste', 'patio-resurfacing', 'dust-allowance'],
}));
expect(on(fax.el, '.fk-fax-next')).toBe(true);
expect(q(fax.el, '.fk-fax-next-name').textContent).toBe('PATIO RESURFACING');
});
it('follows the queue after a standing order is re-queued to the back', () => {
// The exact case the data-order fallback gets wrong.
const fax = createFax(DATA);
fax.update(snap({
activeCommission: 'patio-resurfacing',
commissionQueue: ['patio-resurfacing', 'dust-allowance'],
}));
expect(q(fax.el, '.fk-fax-next-name').textContent).toBe('DUST ALLOWANCE');
});
it('hides the peek when the queue holds nothing but the active order', () => {
const fax = createFax(DATA);
fax.update(snap({ activeCommission: 'first-taste', commissionQueue: ['first-taste'] }));
expect(on(fax.el, '.fk-fax-next')).toBe(false);
});
it('shows no peek at all rather than guessing when the queue is absent', () => {
// The round-2 fallback inferred "next" from data order; it was wrong as soon as a
// standing order was re-queued to the back. A silent fax beats a lying one.
const fax = createFax(DATA);
fax.update(snap({ activeCommission: 'first-taste' }));
expect(on(fax.el, '.fk-fax-next')).toBe(false);
});
});

View File

@ -61,15 +61,19 @@ export function createFax(data: GameData): Fax {
let stampTimer: number | undefined;
/**
* TEMPORARY: the snapshot exposes `activeCommission` but no queue, and the sim
* currently pins the active commission to `commissions[0]`. So "next" is inferred as
* the following entry in data order an assumption, not truth. CONTRACT REQUEST filed
* in NOTES for `nextCommission` / a queue on the snapshot.
* What the tray is actually holding.
*
* `snap.commissionQueue` (contracts v3) is the truth: active first, then upcoming in
* activation order so the peek is `queue[1]`. The round-2 data-order inference is
* gone; it was a guess that went wrong the moment a completed standing order was
* re-queued to the back, and a fax that lies quietly is worse than a fax that says
* nothing. If the field is absent, we show no peek.
*/
function peekNext(activeId: string): CommissionDef | null {
const i = data.commissions.findIndex((c) => c.id === activeId);
if (i < 0 || data.commissions.length < 2) return null;
return data.commissions[(i + 1) % data.commissions.length];
function peekNext(snap: SimSnapshot, activeId: string): CommissionDef | null {
const queue = snap.commissionQueue;
if (!queue) return null;
const nextId = queue[0] === activeId ? queue[1] : queue[0];
return (nextId && commissions.get(nextId)) || null;
}
return {
@ -91,7 +95,7 @@ export function createFax(data: GameData): Fax {
sanChips.update([{ item: SANITISED_ITEM, count: String(sanitised), state: 'plain' }]);
}
const next = peekNext(c.id);
const next = peekNext(snap, c.id);
cls(nextWrap, 'is-on', !!next);
if (next) {
text(nextName, next.id.replace(/-/g, ' ').toUpperCase());

View File

@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest';
import { createHotkeys, isTypingTarget } from './hotkeys';
/** The half of hotkeys.ts that needs a real window: attach/detach and typing guards. */
describe('createHotkeys attach/detach', () => {
it('routes real window keydown events once attached', () => {
const keys = createHotkeys();
const hit = vi.fn();
keys.bind('r', hit);
keys.attach();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' }));
expect(hit).toHaveBeenCalledTimes(1);
keys.detach();
});
it('stops routing after detach — no listener left on the window', () => {
const keys = createHotkeys();
const hit = vi.fn();
keys.bind('r', hit);
keys.attach();
keys.detach();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' }));
expect(hit).not.toHaveBeenCalled();
});
it('ignores keys bound before attach until attach is called', () => {
const keys = createHotkeys();
const hit = vi.fn();
keys.bind('x', hit);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
expect(hit).not.toHaveBeenCalled();
keys.attach();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
expect(hit).toHaveBeenCalledTimes(1);
keys.detach();
});
it('calls preventDefault on space so the page never scrolls under the factory', () => {
const keys = createHotkeys();
keys.bind(' ', () => {});
keys.attach();
const ev = new KeyboardEvent('keydown', { key: ' ', cancelable: true });
window.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
keys.detach();
});
it('does not fire a hotkey while the player is typing in a field', () => {
const keys = createHotkeys();
const hit = vi.fn();
keys.bind('r', hit);
keys.attach();
const input = document.createElement('input');
document.body.append(input);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }));
expect(hit).not.toHaveBeenCalled();
input.remove();
keys.detach();
});
});
describe('isTypingTarget', () => {
it('is true for form fields and contenteditable, false for the world', () => {
for (const tag of ['input', 'textarea', 'select']) {
expect(isTypingTarget(document.createElement(tag))).toBe(true);
}
const div = document.createElement('div');
expect(isTypingTarget(div)).toBe(false);
// The inspector's recipe <select> is exactly why this guard exists.
const editable = document.createElement('div');
editable.setAttribute('contenteditable', 'true');
expect(isTypingTarget(editable)).toBe(true);
});
it('is false for null and non-elements', () => {
expect(isTypingTarget(null)).toBe(false);
expect(isTypingTarget('r')).toBe(false);
});
});

View File

@ -58,9 +58,11 @@ export function createUI(): UI {
keys.bind('[', () => build.pageBy(-1));
keys.bind(']', () => build.pageBy(1));
keys.bind('r', () => sel.rotate());
keys.bind('x', () => sel.toggleRemove());
keys.bind('escape', () => {
// One key, two jobs, in the order the player expects: drop the tool first.
if (sel.get()) sel.clear();
// One key, several jobs, in the order the player expects: put the tool down
// first (wrecking ball included), close the panel only when empty-handed.
if (sel.isArmed()) sel.clear();
else inspector.close();
});
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
@ -86,6 +88,15 @@ export function createUI(): UI {
const tile = bus.pickTile(e.clientX, e.clientY);
if (!tile) return;
if (sel.isRemoving()) {
// Only fire at something that's actually there, so a stray click on open
// ground doesn't read as a broken tool. Sim owns the real teardown.
if (entityAt(roster, machines, tile) !== null) {
bus.dispatch({ kind: 'remove', pos: tile });
}
return; // remove mode stays armed: demolition is usually plural
}
const hit = entityAt(roster, machines, tile);
if (hit === null) inspector.close();
else inspector.open(hit);

View File

@ -154,13 +154,18 @@ export function createInspector(data: GameData, bus: UIBus): Inspector {
}
// Heat: a bar rather than a number, because it's a thing you watch climb.
const hot = e.heat > 0.01;
// A scrammed unit stays on screen while it cools, so show the bar for either.
const scram = e.scrammed === true;
const hot = e.heat > 0.01 || scram;
for (const n of [heatHead, heatBar, heatState]) style(n, 'display', hot ? '' : 'none');
if (hot) {
const scram = e.heat >= 1;
const throttling = e.heat > THROTTLE_AT;
// `scrammed` is the latch (contracts v3). Heat alone can't show it: a scrammed
// machine cools back below 1 while still being offline, and inferring the state
// from a threshold here would mean re-implementing SIM's hysteresis and silently
// desyncing the moment they retune it.
const throttling = !scram && e.heat > THROTTLE_AT;
style(heatFill, 'width', `${Math.min(1, e.heat) * 100}%`);
cls(heatBar, 'is-throttling', throttling && !scram);
cls(heatBar, 'is-throttling', throttling);
cls(heatBar, 'is-scram', scram);
text(heatState, scram ? COPY.heatScram : throttling ? COPY.heatThrottling : '');
cls(heatState, 'is-scram', scram);

240
fktry/src/ui/live.test.ts Normal file
View File

@ -0,0 +1,240 @@
// @vitest-environment happy-dom
/**
* LANE-UI the HUD against the REAL sim, driven by SIM's reference factory.
*
* Not a unit test and not a mock in sight: this boots `createSim()` + `createUI()` on the
* real `data/*.json`, builds `referenceFactory()`, and pumps ticks through the same
* `update(snapshot, events)` path main.ts uses then reads the actual DOM.
*
* Why this exists: the pressure states the round asks me to confirm (THROTTLING at ~304
* ticks of generation, SCRAM at ~435, anchor slabs reaching an uplink, a standing order
* completing) need thousands of *continuous* ticks. The browser pane backgrounds the tab,
* which pauses requestAnimationFrame, so the live page only advances ~10 ticks per
* screenshot hundreds of captures to reach a scram, with an HMR reload from a parallel
* lane wiping the world every minute or so. Headless, the same states arrive in
* milliseconds and stay reproducible. See NOTES.
*/
import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { referenceFactory } from '../sim/reference';
import { createUI } from './index';
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 DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function harness() {
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
const sim = createSim();
const ui = createUI();
let pick: Vec2 | null = null;
const bus: UIBus & { _sel: { def: string; dir: 0 | 1 | 2 | 3 } | null } = {
_sel: null,
dispatch: (c) => sim.enqueue(c),
selectedBuild: () => bus._sel,
pickTile: () => pick,
};
sim.init(DATA, 1337);
ui.init(document.getElementById('ui')!, DATA, bus);
for (const c of referenceFactory(DATA)) sim.enqueue(c);
const step = (n = 1) => {
for (let i = 0; i < n; i++) {
sim.tick();
ui.update(sim.snapshot(), sim.drainEvents());
}
};
/** The real click-to-inspect path: pickTile -> entityAt -> inspector.open. */
const clickTile = (t: Vec2) => {
pick = t;
document
.getElementById('game')!
.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
};
const find = (defId: string): EntityState | undefined =>
sim.snapshot().entities.find((e) => e.def === defId);
return { sim, step, clickTile, find, bus };
}
const $ = (s: string) => document.querySelector(s) as HTMLElement;
const on = (s: string, c = 'is-on') => $(s)?.classList.contains(c);
const toasts = () => Array.from(document.querySelectorAll('.fk-toast')).map((t) => t.textContent);
describe('the HUD against the reference factory', () => {
it('boots, builds and reports a working factory', () => {
const h = harness();
h.step(200);
const snap = h.sim.snapshot();
expect(snap.entities.length).toBeGreaterThan(20);
expect(snap.bandwidth.gen).toBeGreaterThan(0);
expect($('#fk-top').textContent).toContain('GEN');
});
it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => {
const h = harness();
h.step(50);
const dec = h.find('software-decoder');
expect(dec, 'reference factory should contain a software decoder').toBeTruthy();
h.clickTile(dec!.pos); // inspect it the way a player would
h.step(1);
expect($('#fk-inspect').classList.contains('is-open')).toBe(true);
expect($('.fk-ins-name').textContent).toBe('SOFTWARE DECODER');
// heatPerTick 0.0033 - coolPerTick 0.001 = +0.0023/tick while generating.
h.step(250); // ~0.69 — hot, not yet throttling
expect($('.fk-heat').style.display).not.toBe('none');
expect(on('.fk-heat', 'is-throttling')).toBe(false);
h.step(100); // past 0.7
expect(on('.fk-heat', 'is-throttling')).toBe(true);
expect($('.fk-ins-heat-state').textContent).toBe('THROTTLING');
h.step(200); // past 1.0 -> SIM latches scrammed
expect(h.find('software-decoder')!.scrammed).toBe(true);
expect(on('.fk-heat', 'is-scram')).toBe(true);
expect($('.fk-ins-heat-state').textContent).toBe('SCRAM — UNIT OFFLINE');
expect(on('.fk-ins-heat-state', 'is-scram')).toBe(true);
});
it('keeps reading SCRAM from the latch while the unit cools back below 1.0', () => {
// The exact case `heat >= 1` got wrong, and why contracts v3 added the bool.
const h = harness();
h.step(50);
const dec = h.find('software-decoder')!;
h.clickTile(dec.pos);
h.step(600); // well past scram
const e = h.find('software-decoder')!;
expect(e.scrammed).toBe(true);
expect(e.heat).toBeLessThan(1); // already shedding heat while offline
expect(on('.fk-heat', 'is-scram')).toBe(true); // ...and still reads SCRAM
expect(on('.fk-heat', 'is-throttling')).toBe(false);
});
it('ships and tickers up', () => {
const h = harness();
h.step(3000);
const shipped = h.sim.snapshot().shippedTotal;
expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
expect($('#fk-top').textContent).toMatch(/SHIPPED/);
expect($('#fk-top .fk-chip')).toBeTruthy(); // per-item shipped chips
});
it('raises the SANITISING chip on real anchor-slab shipments', () => {
// The reference factory never ships slabs — it recycles them into the i-only
// assembler on purpose ("Rather than sell them, feed the i-only assembler").
// So do what a player would: demolish that assembler and drop an uplink on it.
const h = harness();
h.step(200);
expect(on('.fk-san')).toBe(false); // nothing sanitised yet
h.bus.dispatch({ kind: 'remove', pos: { x: -6, y: 5 } }); // the i-only assembler
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: -6, y: 5 }, dir: 0 });
let shippedAt = -1;
for (let t = 0; t < 12000 && shippedAt < 0; t += 100) {
h.step(100);
if ((h.sim.snapshot().shippedTotal['anchor-slab'] ?? 0) > 0) shippedAt = t;
}
expect(shippedAt, 'slabs should reach the new uplink').toBeGreaterThan(0);
expect(on('.fk-san')).toBe(true);
expect($('.fk-san .fk-chip').textContent).toContain('I-FRAME');
});
it('reads NEXT IN TRAY from the real commission queue, and stamps a real standing order', () => {
const h = harness();
let sawStanding = false;
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
h.step(100);
if (on('.fk-fax-standing')) sawStanding = true;
}
const snap = h.sim.snapshot();
expect(snap.commissionQueue, 'SIM publishes the queue').toBeTruthy();
// A repeat commission reached the head of SIM's real queue and the fax stamped it.
expect(sawStanding, 'a standing order should become active').toBe(true);
const active = DATA.commissions.find((c) => c.id === snap.activeCommission);
expect(active?.repeat).toBe(true);
// ...and the peek is the queue's second entry, not a guess from data order.
const nextId = snap.commissionQueue![1];
if (nextId) {
expect(on('.fk-fax-next')).toBe(true);
expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());
}
});
it('completes a commission and stamps the fax', () => {
const h = harness();
let done = false;
for (let t = 0; t < 6000 && !done; t += 50) {
h.step(50);
if (toasts().some((x) => x?.includes('FAX SENT'))) done = true;
}
expect(done, 'the factory should close at least one commission').toBe(true);
expect(on('.fk-fax-stamp')).toBe(true);
});
it('speaks in the house voice when things go wrong', () => {
const h = harness();
h.step(400);
const all = toasts().join(' | ');
// Something in a cold-starting factory is always starved; whatever it is, it must
// read as English, not as a sim enum.
expect(all).not.toContain('output full');
expect(all).not.toContain('starved');
});
});
describe('remove mode against the real sim', () => {
it('demolishes the clicked unit and toasts it', () => {
const h = harness();
h.step(50);
const before = h.sim.snapshot().entities.length;
const victim = h.find('belt')!;
// arm remove the way the X hotkey does
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // the ghost sentinel
h.clickTile(victim.pos);
h.step(2);
expect(h.sim.snapshot().entities.length).toBe(before - 1);
expect(h.sim.snapshot().entities.some((e) => e.id === victim.id)).toBe(false);
expect(toasts().some((t) => t?.includes('UNIT RECLAIMED'))).toBe(true);
});
it('stays armed for a second demolition, and Esc stands it down', () => {
const h = harness();
h.step(50);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
const first = h.find('belt')!;
h.clickTile(first.pos);
h.step(2);
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // still armed
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(h.bus._sel).toBeNull();
});
it('does not fire at open ground', () => {
const h = harness();
h.step(50);
const before = h.sim.snapshot().entities.length;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
h.clickTile({ x: 60, y: 60 }); // nothing out there
h.step(2);
expect(h.sim.snapshot().entities.length).toBe(before);
});
});

View File

@ -1,19 +1,27 @@
/**
* LANE-UI machine accent colors for build-bar icons.
* LANE-UI machine colors for build-bar icons.
*
* `MachineDef.color` (contracts v2) is the source of truth and wins whenever LANE-DATA
* has filled it the same value LANE-RENDER tints its placeholder boxes with, so the
* bar and the world agree about what a quantizer looks like.
* Contracts v3 RULING: `MachineDef.color` is the CHASSIS/BODY colour (codex §8 grime);
* the emissive accent is derived from what the machine outputs. So an icon is built the
* way the codex builds every asset the two-material rule (§8): grimy industrial body
* plus exactly ONE impossible element.
*
* The tables below are the round-1 stopgap, kept as fallback: transcribed from
* docs/FKTRY_LORE.md §4 (each machine's ASSET block names its impossible element).
* Order: data color, else id override, else kind fallback, else house green. Never
* crashes on a machine we've never heard of.
* This matters for legibility, not just fidelity: DATA's chassis values are all
* desaturated neutrals (#4a4e52, #3f4348, #55595e), exactly as §8 demands, and a bar of
* 21 icons in those colours is an unreadable grey wall. The accent is what tells a
* quantizer from a demuxer at a glance and deriving it from outputs is also what
* LANE-RENDER does, so the bar and the world agree.
*
* The id/kind tables below are the round-1 hand-transcriptions from docs/FKTRY_LORE.md §4
* each machine's ASSET block names its impossible element, so they were always accent
* values. They were only ever mislabeled (the same mistake the v2 contract made); here
* they serve as the accent fallback for machines that output nothing (belts, buffers).
*/
import type { MachineDef, MachineKind } from '../contracts';
import type { GameData, ItemDef, MachineDef, MachineKind, RecipeDef } from '../contracts';
const BY_ID: Record<string, string> = {
'seam-extractor': '#c8a03f', // grimy yellow chassis
/** Accent by id — the "one impossible element" from each codex ASSET block. */
const ACCENT_BY_ID: Record<string, string> = {
'seam-extractor': '#c8a03f', // grimy yellow chassis, CRT head
belt: '#5a7a5a',
demuxer: '#ff3fd4', // the magenta output chute
quantizer: '#e8e0ff', // HF dust leaking from the seams
@ -22,7 +30,7 @@ const BY_ID: Record<string, string> = {
shipper: '#d8ffd8', // THE SCREEN's own light
};
const BY_KIND: Record<MachineKind, string> = {
const ACCENT_BY_KIND: Record<MachineKind, string> = {
extractor: '#c8a03f',
belt: '#5a7a5a',
crafter: '#3fffe0',
@ -32,6 +40,34 @@ const BY_KIND: Record<MachineKind, string> = {
shipper: '#d8ffd8',
};
export function machineColor(def: MachineDef): string {
return def.color || BY_ID[def.id] || BY_KIND[def.kind] || '#d8ffd8';
/** Fallback chassis for machines DATA hasn't art-directed yet. */
const CHASSIS_FALLBACK = '#4a4e52';
/** The body. Data wins; nothing here is ever saturated. */
export function machineChassis(def: MachineDef): string {
return def.color || CHASSIS_FALLBACK;
}
export type AccentFn = (def: MachineDef) => string;
/**
* Accent lookup: the colour of the first thing the machine makes, else the codex table.
* Curried over GameData because the derivation needs recipes + items.
*/
export function createAccentLookup(data: GameData): AccentFn {
const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
return (def) => {
for (const rid of def.recipes) {
const r = recipes.get(rid);
if (!r) continue;
for (const out of Object.keys(r.outputs)) {
const c = items.get(out)?.color;
if (c) return c;
}
}
// Belts, splitters, buffers and shippers produce nothing — they get their codex glow.
return ACCENT_BY_ID[def.id] || ACCENT_BY_KIND[def.kind] || '#d8ffd8';
};
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { Command, Dir, UIBus, Vec2 } from '../contracts';
import { createSelection } from './selection';
import { createSelection, REMOVE_DEF } from './selection';
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
@ -59,6 +59,70 @@ describe('createSelection', () => {
expect(sel.get()).toBeNull();
});
it('publishes the __remove sentinel on the bus so RENDER can tint the target', () => {
// The ghost is the only channel the UI has to the renderer (main.ts feeds setGhost
// from selectedBuild), so this sentinel IS the remove-mode hover contract.
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
expect(bus.selectedBuild()).toEqual({ def: REMOVE_DEF, dir: 0 });
});
it('reports no build selection while removing, so nothing gets placed', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
expect(sel.get()).toBeNull();
expect(sel.isRemoving()).toBe(true);
expect(sel.isArmed()).toBe(true);
});
it('disarms remove when a machine is picked — you cannot build and demolish at once', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.select('belt');
expect(sel.isRemoving()).toBe(false);
expect(bus._sel).toEqual({ def: 'belt', dir: 0 });
});
it('drops the held machine when remove is armed', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.toggleRemove();
expect(sel.get()).toBeNull();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
});
it('toggles remove off again, leaving empty hands', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.toggleRemove();
expect(sel.isRemoving()).toBe(false);
expect(sel.isArmed()).toBe(false);
expect(bus._sel).toBeNull();
});
it('clear() disarms remove mode, which is what Esc rides on', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.clear();
expect(sel.isRemoving()).toBe(false);
expect(bus._sel).toBeNull();
});
it('does not rotate the wrecking ball', () => {
const bus = makeBus();
const sel = createSelection(bus);
sel.toggleRemove();
sel.rotate();
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
});
it('notifies listeners on change, and not on a no-op clear', () => {
const sel = createSelection(makeBus());
const cb = vi.fn();

View File

@ -1,18 +1,41 @@
/**
* LANE-UI the pending build selection.
* LANE-UI the pending build selection, and remove mode.
*
* main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue,
* and the hook it documents is the `_sel` field on the bus object. UI is the only writer;
* we write through to `_sel` on every change and treat our own copy as the truth.
*
* REMOVE MODE / the `__remove` sentinel
* ------------------------------------
* Remove-mode hover has to tint the target machine, and the renderer's only view of what
* the player is holding is `setGhost(...)`, which main.ts feeds from `selectedBuild()`.
* The UI has no Renderer reference, so the sentinel *is* the channel: while remove mode
* is armed, `_sel` reads `{ def: '__remove', dir: 0 }`. LANE-RENDER keys its demolition
* tint off that def id (agreed via NOTES).
*
* Safe because sim's `place` handler does `const def = defs.get(cmd.def); if (def) ...`
* main.ts's click-to-place fires a `place` for `__remove`, no machine matches, nothing
* happens. The actual removal is dispatched by the UI's own handler.
*
* A `UIBus.setGhostMode(...)` would be cleaner than a magic string; CONTRACT REQUEST filed.
*/
import type { Dir, UIBus } from '../contracts';
/** Ghost sentinel meaning "remove mode is armed". Shared with LANE-RENDER by agreement. */
export const REMOVE_DEF = '__remove';
export interface BuildSelection {
/** The pending *build*, or null. Null whenever remove mode is armed. */
get(): { def: string; dir: Dir } | null;
/** Select a machine; selecting the one already selected clears it (toggle). */
select(def: string): void;
rotate(): void;
/** Drop the build selection AND disarm remove mode. */
clear(): void;
isRemoving(): boolean;
toggleRemove(): void;
/** True when the player is holding anything at all (build or the wrecking ball). */
isArmed(): boolean;
onChange(cb: () => void): void;
}
@ -21,26 +44,34 @@ type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection {
const hook = bus as BusWithSel;
let sel: { def: string; dir: Dir } | null = null;
let removing = false;
const listeners: Array<() => void> = [];
function commit(next: { def: string; dir: Dir } | null) {
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
sel = next;
hook._sel = next;
removing = nextRemoving;
hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next;
for (const cb of listeners) cb();
}
return {
get: () => sel,
get: () => (removing ? null : sel),
select(def) {
if (sel?.def === def) commit(null);
else commit({ def, dir: sel?.dir ?? 0 }); // keep the dir across swaps
// Picking a machine puts the wrecking ball down — you can't build and demolish.
if (!removing && sel?.def === def) commit(null, false);
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps
},
rotate() {
if (sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir });
if (!removing && sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir }, false);
},
clear() {
if (sel) commit(null);
if (sel || removing) commit(null, false);
},
isRemoving: () => removing,
toggleRemove() {
commit(null, !removing);
},
isArmed: () => removing || sel !== null,
onChange(cb) {
listeners.push(cb);
},

View File

@ -124,6 +124,18 @@ const CSS = `
}
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
/* REMOVE is a mode, not a machine — it sits with the tabs and reads as a hazard. */
.fk-tab-remove { margin-left: auto; color: var(--fk-dim); }
.fk-tab-remove:hover { color: var(--fk-red); border-color: var(--fk-red); }
.fk-tab-remove.is-armed {
color: #12080a; background: var(--fk-red); border-color: var(--fk-red);
animation: fk-armed 0.9s steps(2) infinite;
}
.fk-tab-key { margin-left: 5px; opacity: 0.65; }
@keyframes fk-armed { 50% { background: #a8242c; } }
#fk-build-hint.is-removing { color: var(--fk-red); }
.fk-build-btn {
position: relative;
width: 58px;
@ -141,7 +153,13 @@ const CSS = `
.fk-build-btn:hover { border-color: var(--fk-cool); color: var(--fk-fg); }
.fk-build-btn.is-sel { border-color: var(--fk-hot); color: var(--fk-fg); background: #1c0a20; }
.fk-build-btn.is-sel .fk-build-ico { box-shadow: 0 0 6px var(--fk-hot); }
.fk-build-ico { width: 100%; height: 18px; margin-bottom: 3px; }
/* Body + one emissive element — the chassis alone is a grey wall (codex §8). */
.fk-build-ico {
position: relative;
width: 100%; height: 18px; margin-bottom: 3px;
display: flex; align-items: flex-end;
}
.fk-build-accent { width: 100%; height: 4px; }
.fk-build-key {
position: absolute; top: 1px; right: 2px;
font-size: 8px; color: var(--fk-faint);

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { MachineDef, MachineKind, SimEvent } from '../contracts';
import { machineColor } from './palette';
import type { GameData, MachineDef, MachineKind, SimEvent } from '../contracts';
import { createAccentLookup, machineChassis } from './palette';
import { eventToast, jamText, machineFlavor } from './voice';
function def(over: Partial<MachineDef> = {}): MachineDef {
@ -61,32 +61,66 @@ describe('eventToast', () => {
expect(eventToast(e, named)).toContain('THERMAL SCRAM');
});
it('confirms a removal, because demolition should feel acknowledged', () => {
const e: SimEvent = { kind: 'removed', entity: 1, def: 'belt', tick: 1 };
expect(eventToast(e, named)).toBe('UNIT RECLAIMED. THE FLOOR REMEMBERS.');
});
it('stays quiet for the high-frequency events', () => {
for (const e of [
{ kind: 'shipped', item: 'melt', count: 1, tick: 1 },
{ kind: 'crafted', entity: 1, recipe: 'mosh', tick: 1 },
{ kind: 'placed', entity: 1, def: 'belt', tick: 1 },
{ kind: 'removed', entity: 1, def: 'belt', tick: 1 },
] as SimEvent[]) {
expect(eventToast(e, named)).toBeNull();
}
});
});
describe('machineColor', () => {
it('prefers the art-directed color from data', () => {
expect(machineColor(def({ color: '#123456' }))).toBe('#123456');
describe('machineChassis', () => {
it('uses the art-directed body colour from data', () => {
expect(machineChassis(def({ color: '#4e5a62' }))).toBe('#4e5a62');
});
it('falls back to the codex-derived id color', () => {
expect(machineColor(def({ id: 'mosh-reactor' }))).toBe('#ff7a3f');
it('falls back to a neutral for a machine DATA has not art-directed', () => {
expect(machineChassis(def({ color: undefined }))).toMatch(/^#/);
});
});
describe('createAccentLookup', () => {
// contracts v3: color is the chassis; the accent derives from what the machine makes.
const data: GameData = {
items: [
{ id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff7a3f' },
{ id: 'hf-dust', name: 'HF DUST', codex: '', tier: 1, color: '#e8e0ff' },
],
machines: [],
recipes: [
{ id: 'mosh', machine: 'mosh-reactor', inputs: {}, outputs: { melt: 1 }, ticks: 90, bandwidth: 10 },
],
tech: [],
commissions: [],
};
const accentOf = createAccentLookup(data);
it('derives the accent from the first thing the machine outputs', () => {
expect(accentOf(def({ id: 'mosh-reactor', recipes: ['mosh'] }))).toBe('#ff7a3f');
});
it('falls back to a kind color for a machine with no entry', () => {
expect(machineColor(def({ id: 'brand-new-thing', kind: 'power' }))).toBe('#3fffe0');
it('falls back to the codex glow for machines that output nothing', () => {
// Belts carry; they do not produce. Their accent has to come from the codex table.
expect(accentOf(def({ id: 'belt', kind: 'belt', recipes: [] }))).toBe('#5a7a5a');
});
it('falls back by kind for an unknown machine with no recipes', () => {
expect(accentOf(def({ id: 'brand-new-thing', kind: 'power', recipes: [] }))).toBe('#3fffe0');
});
it('ignores a recipe id that does not resolve rather than throwing', () => {
expect(accentOf(def({ id: 'x', kind: 'crafter', recipes: ['ghost-recipe'] }))).toBe('#3fffe0');
});
it('always returns something for an unheard-of kind', () => {
expect(machineColor(def({ id: 'x', kind: 'teleporter' as MachineKind }))).toMatch(/^#/);
expect(accentOf(def({ id: 'x', kind: 'teleporter' as MachineKind, recipes: [] }))).toMatch(/^#/);
});
});

View File

@ -79,8 +79,10 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
: `${machineName(ev.entity)} IS COOL ENOUGH TO CONTINUE. REGRETTABLY.`;
case 'commissionDone':
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
case 'removed':
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
default:
return null; // shipped/crafted/placed/removed are too frequent to toast
return null; // shipped/crafted/placed are too frequent to toast
}
}
@ -113,7 +115,11 @@ export const COPY = {
sanitising: 'SANITISING',
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
idleHint: '1-9 SELECT · [ ] PAGE · SPACE HALT · CLICK A UNIT TO INSPECT',
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · SPACE HALT · CLICK A UNIT TO INSPECT',
removeLabel: 'REMOVE',
removeKey: 'X',
removeHint: 'REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN',
} as const;
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */