[ui] REMOVE mode: X arms it, click demolishes, Esc stands down

A misplaced machine is finally undoable by mouse. The REMOVE button sits
with the tabs rather than on a page, because it's a mode, not a machine, and
it has to be reachable from any page. It stays armed between clicks —
demolition is usually plural — and clicking open ground does nothing, so a
stray click never reads as a broken tool. Toast: "UNIT RECLAIMED. THE FLOOR
REMEMBERS."

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 18:34:05 +10:00
parent 673a06dab8
commit 45003db31a
12 changed files with 710 additions and 45 deletions

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

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

View File

@ -40,8 +40,8 @@ const MAX_TRACE = 512;
const HEAT_THROTTLE_START = 0.7; const HEAT_THROTTLE_START = 0.7;
/** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */ /** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */
const HEAT_THROTTLE_FLOOR = 0.5; const HEAT_THROTTLE_FLOOR = 0.5;
/** Heat shed per tick, always, active or not. */ /** Heat shed per tick, always, active or not. Overridden per machine by `coolPerTick`. */
const HEAT_DISSIPATION = 0.004; const HEAT_COOL_DEFAULT = 0.004;
/** Cool back below this and a scrammed machine comes back up. */ /** Cool back below this and a scrammed machine comes back up. */
const HEAT_RESTART = 0.5; const HEAT_RESTART = 0.5;
@ -272,6 +272,7 @@ export function createSim(): Sim {
outputBuf: {}, outputBuf: {},
jammed: null, jammed: null,
heat: 0, heat: 0,
scrammed: false,
}; };
const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 }; const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 };
ents.push(e); ents.push(e);
@ -328,6 +329,10 @@ export function createSim(): Sim {
function doSetRecipe(entity: number, recipe: string | null): void { function doSetRecipe(entity: number, recipe: string | null): void {
const e = byId.get(entity); const e = byId.get(entity);
if (!e) return; if (!e) return;
setRecipeOn(e, recipe);
}
function setRecipeOn(e: Ent, recipe: string | null): void {
if (recipe !== null && !e.def.recipes.includes(recipe)) return; if (recipe !== null && !e.def.recipes.includes(recipe)) return;
e.state.recipe = recipe; e.state.recipe = recipe;
e.state.progress = 0; e.state.progress = 0;
@ -346,6 +351,11 @@ export function createSim(): Sim {
case 'remove': doRemove(cmd.pos); break; case 'remove': doRemove(cmd.pos); break;
case 'rotate': doRotate(cmd.pos); break; case 'rotate': doRotate(cmd.pos); break;
case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); 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; 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 * 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 * the tanks; a deficit drains them. Only once the tanks run dry does the factory brown
* brown out and run at gen/draw speed. * 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 { function computePower(): number {
let gen = 0; let gen = 0;
@ -411,16 +426,18 @@ export function createSim(): Sim {
let supplied = gen; let supplied = gen;
if (gen >= draw) { if (gen >= draw) {
const room = capacity - stored; const charge = Math.min((gen - draw) / TICKS_PER_SECOND, capacity - stored);
const charge = Math.min(gen - draw, room);
if (charge > 0) stored += charge; if (charge > 0) stored += charge;
} else { } 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; 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) { if (on !== brownout) {
brownout = on; brownout = on;
events.push({ kind: 'brownout', on, tick: curTick }); 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 { function updateHeat(): void {
for (const e of ents) { for (const e of ents) {
const hpt = e.def.heatPerTick ?? 0; 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); 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; if (h < 0) h = 0;
else if (h > 1) h = 1; else if (h > 1) h = 1;
e.state.heat = h; e.state.heat = h;
if (!e.scrammed && h >= 1) { if (!e.scrammed && h >= 1) {
e.scrammed = true; e.scrammed = true;
e.state.scrammed = true;
setJam(e, null); // scram is reported by its own event, not as a flow jam 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 }); events.push({ kind: 'scram', entity: e.state.id, on: true, tick: curTick });
} else if (e.scrammed && h < HEAT_RESTART) { } else if (e.scrammed && h < HEAT_RESTART) {
e.scrammed = false; e.scrammed = false;
e.state.scrammed = false;
events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick }); 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 (c < cap) cap = c;
} }
if (target > cap) target = cap; 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); items.splice(idx, 1);
removeFlat(it); removeFlat(it);
return true; return true;
@ -636,6 +666,7 @@ export function createSim(): Sim {
function activateCommission(): void { function activateCommission(): void {
const id = commissionQueue[0] ?? null; const id = commissionQueue[0] ?? null;
snap.activeCommission = id; snap.activeCommission = id;
snap.commissionQueue = commissionQueue; // active first, then upcoming: UI's "next in tray"
snap.commissionProgress = {}; snap.commissionProgress = {};
const c = id === null ? undefined : commissionDefs.get(id); const c = id === null ? undefined : commissionDefs.get(id);
if (c) for (const k in c.wants) snap.commissionProgress[k] = 0; if (c) for (const k in c.wants) snap.commissionProgress[k] = 0;
@ -747,5 +778,101 @@ export function createSim(): Sim {
events = []; events = [];
return out; return out;
}, },
/**
* Everything the tick loop reads, including the state the snapshot deliberately hides:
* the crafting/scram latches, splitter queues and cursors, and the RNG position. Miss
* any of those and a loaded save diverges a few hundred ticks later, which is exactly
* the bug that never reproduces. `data` is not saved load() into a sim init()'d with
* the same GameData.
*/
save(): string {
return JSON.stringify({
v: 3,
tick: curTick,
paused,
brownout,
stored,
nextId,
nextItemId,
rng: rng.state(),
commissionQueue,
shippedTotal: snap.shippedTotal,
commissionProgress: snap.commissionProgress,
pending: cmdQueue, // commands enqueued but not yet applied
entities: ents.map((e) => ({
state: e.state,
crafting: e.crafting,
scrammed: e.scrammed,
queue: e.queue,
cursor: e.cursor,
})),
beltItems: allBeltItems,
});
},
load(json: string): void {
const s = JSON.parse(json);
if (s.v !== 3) throw new Error(`sim.load: unsupported save version ${s.v}`);
ents = [];
entityStates = [];
allBeltItems = [];
byId.clear();
occ.clear();
beltContents.clear();
traceCache.clear();
cmdQueue.length = 0;
events = [];
moved.clear();
for (const rec of s.entities) {
const def = defs.get(rec.state.def);
if (!def) throw new Error(`sim.load: this GameData has no machine "${rec.state.def}"`);
const state = rec.state as EntityState;
const fp = footprintOf(def.footprint, state.dir);
const tiles: number[] = [];
for (let dx = 0; dx < fp.x; dx++) {
for (let dy = 0; dy < fp.y; dy++) tiles.push(tileKey(state.pos.x + dx, state.pos.y + dy));
}
const e: Ent = {
state, def, crafting: rec.crafting, scrammed: rec.scrammed,
tiles, queue: rec.queue, cursor: rec.cursor,
};
ents.push(e);
entityStates.push(state);
byId.set(state.id, e);
for (const k of tiles) occ.set(k, state.id);
if (def.kind === 'belt') beltContents.set(state.id, []);
}
for (const it of s.beltItems as BeltItem[]) {
allBeltItems.push(it);
beltContents.get(it.entity)?.push(it);
}
// Belt contents are ordered front-first everywhere else; restore that invariant
// rather than trusting the blob's ordering.
for (const list of beltContents.values()) list.sort((a, b) => b.t - a.t);
curTick = s.tick;
paused = s.paused;
brownout = s.brownout;
stored = s.stored;
nextId = s.nextId;
nextItemId = s.nextItemId;
rng.restore(s.rng);
commissionQueue = s.commissionQueue;
for (const cmd of s.pending ?? []) cmdQueue.push(cmd);
snap.tick = curTick;
snap.paused = paused;
snap.entities = entityStates;
snap.beltItems = allBeltItems;
snap.bandwidth = { gen: 0, draw: 0, stored, brownout };
snap.shippedTotal = s.shippedTotal;
snap.commissionProgress = s.commissionProgress;
snap.activeCommission = commissionQueue[0] ?? null;
snap.commissionQueue = commissionQueue;
},
}; };
} }

View File

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

View File

@ -138,35 +138,43 @@ describe('splitters', () => {
}); });
describe('buffer tanks', () => { 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', () => { it('charges on surplus, covers a deficit, and only browns out once dry', () => {
const sim = newSim(); const sim = newSim();
sim.enqueue(place('gen', 0, 0)); sim.enqueue(place('gen', 0, 0));
sim.enqueue(place('gen', 1, 0)); sim.enqueue(place('gen', 1, 0));
sim.enqueue(place('gen', 2, 0)); // gen 30 sim.enqueue(place('gen', 2, 0)); // gen 30/s
sim.enqueue(place('tank', 3, 0)); // cap 100 sim.enqueue(place('tank', 3, 0)); // cap 100 bandwidth-seconds
run(sim, 1); run(sim, 1);
expect(sim.snapshot().bandwidth.stored).toBe(30); // surplus 30 -> charge 30 expect(sim.snapshot().bandwidth.stored).toBe(1); // 30/s surplus over 1/30 s = 1
run(sim, 3); run(sim, 99);
expect(sim.snapshot().bandwidth.stored).toBe(100); // clamps at capacity 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); 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', 0, 1));
sim.enqueue(place('load', 1, 1)); sim.enqueue(place('load', 1, 1));
sim.enqueue(place('load', 2, 1));
const evs: SimEvent[] = []; const evs: SimEvent[] = [];
run(sim, 1, evs); run(sim, 1, evs);
expect(sim.snapshot().bandwidth.draw).toBe(40); expect(sim.snapshot().bandwidth.draw).toBe(60);
expect(sim.snapshot().bandwidth.stored).toBe(90); expect(sim.snapshot().bandwidth.stored).toBe(99);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // tank is carrying it 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.stored).toBe(0);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered 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); 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(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', () => { it('browns out immediately with no tank at all', () => {

View File

@ -69,9 +69,24 @@ export function referenceFactory(data: GameData): Command[] {
cmds.push({ kind: 'setRecipe', entity, recipe: recipeId(recipe) }); cmds.push({ kind: 'setRecipe', entity, recipe: recipeId(recipe) });
}; };
// ---- power. Four decoders cover ~146 draw at full tilt, with room for the i-only // ---- power: ASIC base + software-decoder burst + tanks.
// assembler's 24-bandwidth spikes. //
for (let i = 0; i < 4; i++) P('software-decoder', -30 + i * 3, -14); // 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). // ---- mining. Three seams keep the demuxer at its 45-tick cadence (it eats 3 ore).
P('seam-extractor', -30, 0); P('seam-extractor', -30, 0);

View File

@ -9,6 +9,10 @@ export interface Rng {
next(): number; next(): number;
/** integer in [0, maxExclusive) */ /** integer in [0, maxExclusive) */
int(maxExclusive: number): number; 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 { 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); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}; };
return { next, int: (maxExclusive: number) => Math.floor(next() * maxExclusive) }; return {
next,
int: (maxExclusive: number) => Math.floor(next() * maxExclusive),
state: () => a,
restore: (state: number) => { a = state >>> 0; },
};
} }

View File

@ -5,7 +5,7 @@
import type { GameData, MachineDef } from '../contracts'; import type { GameData, MachineDef } from '../contracts';
import { attrs, cls, el, panel, text } from './dom'; import { attrs, cls, el, panel, text } from './dom';
import { paginate, pageOf, type BuildPage } from './pages'; import { paginate, pageOf, type BuildPage } from './pages';
import { machineColor } from './palette'; import { createAccentLookup, machineChassis } from './palette';
import type { BuildSelection } from './selection'; import type { BuildSelection } from './selection';
import { COPY, DIR_NAME } from './voice'; import { COPY, DIR_NAME } from './voice';
@ -19,6 +19,7 @@ export interface BuildBar {
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
const pages: BuildPage[] = paginate(data.machines); const pages: BuildPage[] = paginate(data.machines);
const accentOf = createAccentLookup(data);
const root = panel(); const root = panel();
root.id = 'fk-build'; root.id = 'fk-build';
@ -42,6 +43,16 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
return tab; 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). */ /** Buttons for the active page only; rebuilt on page change (nine at most). */
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = []; 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'); const btn = el('button', 'fk-build-btn');
attrs(btn, { type: 'button', title: `${m.name}${m.kind}` }); 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'); 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); btn.append(ico);
const key = el('span', 'fk-build-key'); const key = el('span', 'fk-build-key');
@ -74,14 +90,21 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
function sync() { function sync() {
const cur = sel.get(); const cur = sel.get();
const removing = sel.isRemoving();
for (const b of buttons) cls(b.btn, 'is-sel', cur?.def === b.def); 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); const def = data.machines.find((m) => m.id === cur.def);
text(hint, `${def?.name ?? cur.def} · FACING ${DIR_NAME[cur.dir]} · ${COPY.placingHint}`); text(hint, `${def?.name ?? cur.def} · FACING ${DIR_NAME[cur.dir]} · ${COPY.placingHint}`);
} else { } else {
text(hint, COPY.idleHint); text(hint, COPY.idleHint);
} }
cls(hint, 'is-placing', !!cur); 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 // A selection made from anywhere (including a future hotkey we don't own) pulls the

View File

@ -58,9 +58,11 @@ export function createUI(): UI {
keys.bind('[', () => build.pageBy(-1)); keys.bind('[', () => build.pageBy(-1));
keys.bind(']', () => build.pageBy(1)); keys.bind(']', () => build.pageBy(1));
keys.bind('r', () => sel.rotate()); keys.bind('r', () => sel.rotate());
keys.bind('x', () => sel.toggleRemove());
keys.bind('escape', () => { keys.bind('escape', () => {
// One key, two jobs, in the order the player expects: drop the tool first. // One key, several jobs, in the order the player expects: put the tool down
if (sel.get()) sel.clear(); // first (wrecking ball included), close the panel only when empty-handed.
if (sel.isArmed()) sel.clear();
else inspector.close(); else inspector.close();
}); });
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused })); keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
@ -86,6 +88,15 @@ export function createUI(): UI {
const tile = bus.pickTile(e.clientX, e.clientY); const tile = bus.pickTile(e.clientX, e.clientY);
if (!tile) return; 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); const hit = entityAt(roster, machines, tile);
if (hit === null) inspector.close(); if (hit === null) inspector.close();
else inspector.open(hit); else inspector.open(hit);

View File

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { Command, Dir, UIBus, Vec2 } from '../contracts'; 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 }; type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
@ -59,6 +59,70 @@ describe('createSelection', () => {
expect(sel.get()).toBeNull(); 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', () => { it('notifies listeners on change, and not on a no-op clear', () => {
const sel = createSelection(makeBus()); const sel = createSelection(makeBus());
const cb = vi.fn(); const cb = vi.fn();

View File

@ -1,18 +1,41 @@
/** /**
* LANE-UI the pending build selection. * LANE-UI the pending build selection, and remove mode.
* *
* main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue, * 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; * 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. * 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'; 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 { export interface BuildSelection {
/** The pending *build*, or null. Null whenever remove mode is armed. */
get(): { def: string; dir: Dir } | null; get(): { def: string; dir: Dir } | null;
/** Select a machine; selecting the one already selected clears it (toggle). */ /** Select a machine; selecting the one already selected clears it (toggle). */
select(def: string): void; select(def: string): void;
rotate(): void; rotate(): void;
/** Drop the build selection AND disarm remove mode. */
clear(): void; 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; onChange(cb: () => void): void;
} }
@ -21,26 +44,34 @@ type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection { export function createSelection(bus: UIBus): BuildSelection {
const hook = bus as BusWithSel; const hook = bus as BusWithSel;
let sel: { def: string; dir: Dir } | null = null; let sel: { def: string; dir: Dir } | null = null;
let removing = false;
const listeners: Array<() => void> = []; const listeners: Array<() => void> = [];
function commit(next: { def: string; dir: Dir } | null) { function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
sel = next; sel = next;
hook._sel = next; removing = nextRemoving;
hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next;
for (const cb of listeners) cb(); for (const cb of listeners) cb();
} }
return { return {
get: () => sel, get: () => (removing ? null : sel),
select(def) { select(def) {
if (sel?.def === def) commit(null); // Picking a machine puts the wrecking ball down — you can't build and demolish.
else commit({ def, dir: sel?.dir ?? 0 }); // keep the dir across swaps if (!removing && sel?.def === def) commit(null, false);
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps
}, },
rotate() { 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() { clear() {
if (sel) commit(null); if (sel || removing) commit(null, false);
}, },
isRemoving: () => removing,
toggleRemove() {
commit(null, !removing);
},
isArmed: () => removing || sel !== null,
onChange(cb) { onChange(cb) {
listeners.push(cb); listeners.push(cb);
}, },

View File

@ -124,6 +124,18 @@ const CSS = `
} }
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); } .fk-tab: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; } .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 { .fk-build-btn {
position: relative; position: relative;
width: 58px; width: 58px;
@ -141,7 +153,13 @@ const CSS = `
.fk-build-btn:hover { border-color: var(--fk-cool); color: var(--fk-fg); } .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 { 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-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 { .fk-build-key {
position: absolute; top: 1px; right: 2px; position: absolute; top: 1px; right: 2px;
font-size: 8px; color: var(--fk-faint); font-size: 8px; color: var(--fk-faint);

View File

@ -79,8 +79,10 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
: `${machineName(ev.entity)} IS COOL ENOUGH TO CONTINUE. REGRETTABLY.`; : `${machineName(ev.entity)} IS COOL ENOUGH TO CONTINUE. REGRETTABLY.`;
case 'commissionDone': case 'commissionDone':
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.'; return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
case 'removed':
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
default: 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', sanitising: 'SANITISING',
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL', 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; } as const;
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */ /** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */