[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:
parent
673a06dab8
commit
45003db31a
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;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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', () => {
|
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', () => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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; },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user