Compare commits
5 Commits
673a06dab8
...
34e8b39fed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e8b39fed | ||
|
|
9f3a254d85 | ||
|
|
a6ecf88623 | ||
|
|
ea17e95318 | ||
|
|
45003db31a |
@ -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.
|
||||
|
||||
@ -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
232
fktry/src/sim/bloom.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
121
fktry/src/sim/persist.test.ts
Normal file
121
fktry/src/sim/persist.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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; },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
77
fktry/src/ui/chips.test.ts
Normal file
77
fktry/src/ui/chips.test.ts
Normal 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
134
fktry/src/ui/fax.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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());
|
||||
|
||||
83
fktry/src/ui/hotkeys.dom.test.ts
Normal file
83
fktry/src/ui/hotkeys.dom.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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
240
fktry/src/ui/live.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(/^#/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user