[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;
|
||||
/** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */
|
||||
const HEAT_THROTTLE_FLOOR = 0.5;
|
||||
/** Heat shed per tick, always, active or not. */
|
||||
const HEAT_DISSIPATION = 0.004;
|
||||
/** Heat shed per tick, always, active or not. Overridden per machine by `coolPerTick`. */
|
||||
const HEAT_COOL_DEFAULT = 0.004;
|
||||
/** Cool back below this and a scrammed machine comes back up. */
|
||||
const HEAT_RESTART = 0.5;
|
||||
|
||||
@ -272,6 +272,7 @@ export function createSim(): Sim {
|
||||
outputBuf: {},
|
||||
jammed: null,
|
||||
heat: 0,
|
||||
scrammed: false,
|
||||
};
|
||||
const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 };
|
||||
ents.push(e);
|
||||
@ -328,6 +329,10 @@ export function createSim(): Sim {
|
||||
function doSetRecipe(entity: number, recipe: string | null): void {
|
||||
const e = byId.get(entity);
|
||||
if (!e) return;
|
||||
setRecipeOn(e, recipe);
|
||||
}
|
||||
|
||||
function setRecipeOn(e: Ent, recipe: string | null): void {
|
||||
if (recipe !== null && !e.def.recipes.includes(recipe)) return;
|
||||
e.state.recipe = recipe;
|
||||
e.state.progress = 0;
|
||||
@ -346,6 +351,11 @@ export function createSim(): Sim {
|
||||
case 'remove': doRemove(cmd.pos); break;
|
||||
case 'rotate': doRotate(cmd.pos); break;
|
||||
case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break;
|
||||
case 'setRecipeAt': {
|
||||
const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works
|
||||
if (e) setRecipeOn(e, cmd.recipe);
|
||||
break;
|
||||
}
|
||||
case 'setPaused': paused = cmd.paused; snap.paused = paused; break;
|
||||
}
|
||||
}
|
||||
@ -386,10 +396,15 @@ export function createSim(): Sim {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bandwidth v2: gen vs draw with tank storage. Compression pays for itself — a recipe
|
||||
* Bandwidth v3: gen vs draw with tank storage. Compression pays for itself — a recipe
|
||||
* with negative bandwidth (the quantizer) generates instead of drawing. Surplus charges
|
||||
* the tanks; a deficit drains them. Only once the tanks run dry does the factory
|
||||
* brown out and run at gen/draw speed.
|
||||
* the tanks; a deficit drains them. Only once the tanks run dry does the factory brown
|
||||
* out and run at supplied/draw speed.
|
||||
*
|
||||
* UNITS (v3 ruling): gen and draw are bandwidth per SECOND; stored is bandwidth-SECONDS.
|
||||
* So a tick only ever moves one tick's worth of charge — surplus/TICKS_PER_SECOND — into
|
||||
* or out of the tanks. A 30/s deficit drains 30 stored per second, not per tick, which is
|
||||
* what makes DATA's bufferCap 240 the 8 seconds of cover they intended.
|
||||
*/
|
||||
function computePower(): number {
|
||||
let gen = 0;
|
||||
@ -411,16 +426,18 @@ export function createSim(): Sim {
|
||||
|
||||
let supplied = gen;
|
||||
if (gen >= draw) {
|
||||
const room = capacity - stored;
|
||||
const charge = Math.min(gen - draw, room);
|
||||
const charge = Math.min((gen - draw) / TICKS_PER_SECOND, capacity - stored);
|
||||
if (charge > 0) stored += charge;
|
||||
} else {
|
||||
const drawn = Math.min(draw - gen, stored);
|
||||
const wanted = (draw - gen) / TICKS_PER_SECOND; // bandwidth-seconds owed this tick
|
||||
const drawn = Math.min(wanted, stored);
|
||||
stored -= drawn;
|
||||
supplied = gen + drawn;
|
||||
supplied = gen + drawn * TICKS_PER_SECOND;
|
||||
}
|
||||
|
||||
const on = supplied < draw;
|
||||
// Epsilon: a tank covering a deficit exactly lands supplied on draw, and float drift
|
||||
// there would otherwise flicker the brownout flag on and off every tick.
|
||||
const on = supplied < draw - 1e-9;
|
||||
if (on !== brownout) {
|
||||
brownout = on;
|
||||
events.push({ kind: 'brownout', on, tick: curTick });
|
||||
@ -449,22 +466,35 @@ export function createSim(): Sim {
|
||||
}
|
||||
}
|
||||
|
||||
/** Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram. */
|
||||
/**
|
||||
* Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram.
|
||||
*
|
||||
* Cooling is applied every tick whether or not the machine is working, so `heatPerTick`
|
||||
* is GROSS heat and the net climb is heatPerTick - coolPerTick. A machine whose
|
||||
* heatPerTick doesn't clear its cooling simply never overheats. That's what makes the
|
||||
* codex's ASIC coolers mean something — and it's the trap DATA is currently in; see NOTES.
|
||||
*/
|
||||
function updateHeat(): void {
|
||||
for (const e of ents) {
|
||||
const hpt = e.def.heatPerTick ?? 0;
|
||||
// Most of a big factory is belts, and a belt can never be anything but stone cold.
|
||||
// Skipping them here is the difference between 59x and 70x realtime at 3,500 entities.
|
||||
if (hpt === 0 && e.state.heat === 0 && !e.scrammed) continue;
|
||||
const cool = e.def.coolPerTick ?? HEAT_COOL_DEFAULT;
|
||||
const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
|
||||
let h = e.state.heat + (active ? hpt : 0) - HEAT_DISSIPATION;
|
||||
let h = e.state.heat + (active ? hpt : 0) - cool;
|
||||
if (h < 0) h = 0;
|
||||
else if (h > 1) h = 1;
|
||||
e.state.heat = h;
|
||||
|
||||
if (!e.scrammed && h >= 1) {
|
||||
e.scrammed = true;
|
||||
e.state.scrammed = true;
|
||||
setJam(e, null); // scram is reported by its own event, not as a flow jam
|
||||
events.push({ kind: 'scram', entity: e.state.id, on: true, tick: curTick });
|
||||
} else if (e.scrammed && h < HEAT_RESTART) {
|
||||
e.scrammed = false;
|
||||
e.state.scrammed = false;
|
||||
events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick });
|
||||
}
|
||||
}
|
||||
@ -601,7 +631,7 @@ export function createSim(): Sim {
|
||||
if (c < cap) cap = c;
|
||||
}
|
||||
if (target > cap) target = cap;
|
||||
} else if (next && tryPushToMachine(next, it.item, it.id ?? 0)) {
|
||||
} else if (next && tryPushToMachine(next, it.item, it.id)) {
|
||||
items.splice(idx, 1);
|
||||
removeFlat(it);
|
||||
return true;
|
||||
@ -636,6 +666,7 @@ export function createSim(): Sim {
|
||||
function activateCommission(): void {
|
||||
const id = commissionQueue[0] ?? null;
|
||||
snap.activeCommission = id;
|
||||
snap.commissionQueue = commissionQueue; // active first, then upcoming: UI's "next in tray"
|
||||
snap.commissionProgress = {};
|
||||
const c = id === null ? undefined : commissionDefs.get(id);
|
||||
if (c) for (const k in c.wants) snap.commissionProgress[k] = 0;
|
||||
@ -747,5 +778,101 @@ export function createSim(): Sim {
|
||||
events = [];
|
||||
return out;
|
||||
},
|
||||
|
||||
/**
|
||||
* Everything the tick loop reads, including the state the snapshot deliberately hides:
|
||||
* the crafting/scram latches, splitter queues and cursors, and the RNG position. Miss
|
||||
* any of those and a loaded save diverges a few hundred ticks later, which is exactly
|
||||
* the bug that never reproduces. `data` is not saved — load() into a sim init()'d with
|
||||
* the same GameData.
|
||||
*/
|
||||
save(): string {
|
||||
return JSON.stringify({
|
||||
v: 3,
|
||||
tick: curTick,
|
||||
paused,
|
||||
brownout,
|
||||
stored,
|
||||
nextId,
|
||||
nextItemId,
|
||||
rng: rng.state(),
|
||||
commissionQueue,
|
||||
shippedTotal: snap.shippedTotal,
|
||||
commissionProgress: snap.commissionProgress,
|
||||
pending: cmdQueue, // commands enqueued but not yet applied
|
||||
entities: ents.map((e) => ({
|
||||
state: e.state,
|
||||
crafting: e.crafting,
|
||||
scrammed: e.scrammed,
|
||||
queue: e.queue,
|
||||
cursor: e.cursor,
|
||||
})),
|
||||
beltItems: allBeltItems,
|
||||
});
|
||||
},
|
||||
|
||||
load(json: string): void {
|
||||
const s = JSON.parse(json);
|
||||
if (s.v !== 3) throw new Error(`sim.load: unsupported save version ${s.v}`);
|
||||
|
||||
ents = [];
|
||||
entityStates = [];
|
||||
allBeltItems = [];
|
||||
byId.clear();
|
||||
occ.clear();
|
||||
beltContents.clear();
|
||||
traceCache.clear();
|
||||
cmdQueue.length = 0;
|
||||
events = [];
|
||||
moved.clear();
|
||||
|
||||
for (const rec of s.entities) {
|
||||
const def = defs.get(rec.state.def);
|
||||
if (!def) throw new Error(`sim.load: this GameData has no machine "${rec.state.def}"`);
|
||||
const state = rec.state as EntityState;
|
||||
const fp = footprintOf(def.footprint, state.dir);
|
||||
const tiles: number[] = [];
|
||||
for (let dx = 0; dx < fp.x; dx++) {
|
||||
for (let dy = 0; dy < fp.y; dy++) tiles.push(tileKey(state.pos.x + dx, state.pos.y + dy));
|
||||
}
|
||||
const e: Ent = {
|
||||
state, def, crafting: rec.crafting, scrammed: rec.scrammed,
|
||||
tiles, queue: rec.queue, cursor: rec.cursor,
|
||||
};
|
||||
ents.push(e);
|
||||
entityStates.push(state);
|
||||
byId.set(state.id, e);
|
||||
for (const k of tiles) occ.set(k, state.id);
|
||||
if (def.kind === 'belt') beltContents.set(state.id, []);
|
||||
}
|
||||
|
||||
for (const it of s.beltItems as BeltItem[]) {
|
||||
allBeltItems.push(it);
|
||||
beltContents.get(it.entity)?.push(it);
|
||||
}
|
||||
// Belt contents are ordered front-first everywhere else; restore that invariant
|
||||
// rather than trusting the blob's ordering.
|
||||
for (const list of beltContents.values()) list.sort((a, b) => b.t - a.t);
|
||||
|
||||
curTick = s.tick;
|
||||
paused = s.paused;
|
||||
brownout = s.brownout;
|
||||
stored = s.stored;
|
||||
nextId = s.nextId;
|
||||
nextItemId = s.nextItemId;
|
||||
rng.restore(s.rng);
|
||||
commissionQueue = s.commissionQueue;
|
||||
for (const cmd of s.pending ?? []) cmdQueue.push(cmd);
|
||||
|
||||
snap.tick = curTick;
|
||||
snap.paused = paused;
|
||||
snap.entities = entityStates;
|
||||
snap.beltItems = allBeltItems;
|
||||
snap.bandwidth = { gen: 0, draw: 0, stored, brownout };
|
||||
snap.shippedTotal = s.shippedTotal;
|
||||
snap.commissionProgress = s.commissionProgress;
|
||||
snap.activeCommission = commissionQueue[0] ?? null;
|
||||
snap.commissionQueue = commissionQueue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
121
fktry/src/sim/persist.test.ts
Normal file
121
fktry/src/sim/persist.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* save()/load() round-trip.
|
||||
*
|
||||
* The bar is not "the snapshot looks the same" — it's "the future is the same". A save
|
||||
* that restores visible state but drops a splitter cursor or the RNG position looks
|
||||
* perfect and then quietly diverges a few hundred ticks later. So every test here loads
|
||||
* a blob and then runs BOTH sims forward before comparing.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSim } from './index';
|
||||
import { referenceFactory } from './reference';
|
||||
import type { GameData, Sim } from '../contracts';
|
||||
|
||||
import items from '../../data/items.json';
|
||||
import machines from '../../data/machines.json';
|
||||
import recipes from '../../data/recipes.json';
|
||||
import tech from '../../data/tech.json';
|
||||
import commissions from '../../data/commissions.json';
|
||||
|
||||
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
|
||||
|
||||
function fresh(seed = 1337): Sim {
|
||||
const sim = createSim();
|
||||
sim.init(DATA, seed);
|
||||
return sim;
|
||||
}
|
||||
function run(sim: Sim, ticks: number): void {
|
||||
for (let i = 0; i < ticks; i++) { sim.tick(); sim.drainEvents(); }
|
||||
}
|
||||
|
||||
describe('save/load', () => {
|
||||
it('restores a running factory so both sims agree 1,000 ticks later', () => {
|
||||
const original = fresh();
|
||||
for (const c of referenceFactory(DATA)) original.enqueue(c);
|
||||
run(original, 1500); // mid-flight: belts loaded, a scram cycle already under way
|
||||
|
||||
const restored = fresh();
|
||||
restored.load!(original.save!());
|
||||
expect(restored.snapshot().tick).toBe(original.snapshot().tick);
|
||||
|
||||
run(original, 1000);
|
||||
run(restored, 1000);
|
||||
expect(restored.snapshot()).toEqual(original.snapshot());
|
||||
expect(restored.snapshot().shippedTotal['melt']).toBeGreaterThan(0); // not a dead factory
|
||||
});
|
||||
|
||||
it('round-trips through JSON without drifting', () => {
|
||||
const a = fresh();
|
||||
for (const c of referenceFactory(DATA)) a.enqueue(c);
|
||||
run(a, 800);
|
||||
|
||||
const blob = a.save!();
|
||||
expect(() => JSON.parse(blob)).not.toThrow();
|
||||
|
||||
const b = fresh();
|
||||
b.load!(blob);
|
||||
// Saving the restored sim must reproduce the same bytes: nothing was lost in the gap.
|
||||
expect(b.save!()).toBe(blob);
|
||||
});
|
||||
|
||||
it('survives a save/load/save/load chain', () => {
|
||||
const a = fresh();
|
||||
for (const c of referenceFactory(DATA)) a.enqueue(c);
|
||||
run(a, 600);
|
||||
|
||||
let carried = fresh();
|
||||
carried.load!(a.save!());
|
||||
for (let i = 0; i < 3; i++) {
|
||||
run(a, 200);
|
||||
run(carried, 200);
|
||||
const next = fresh();
|
||||
next.load!(carried.save!());
|
||||
carried = next;
|
||||
}
|
||||
run(a, 500);
|
||||
run(carried, 500);
|
||||
expect(carried.snapshot()).toEqual(a.snapshot());
|
||||
});
|
||||
|
||||
it('preserves splitter cursors and queues, not just what the snapshot shows', () => {
|
||||
const a = fresh();
|
||||
for (const c of referenceFactory(DATA)) a.enqueue(c);
|
||||
run(a, 1200); // long enough for the splitters to be mid-rotation
|
||||
|
||||
const b = fresh();
|
||||
b.load!(a.save!());
|
||||
// A dropped round-robin cursor is invisible at rest and shows up as divergence later.
|
||||
run(a, 2000);
|
||||
run(b, 2000);
|
||||
expect(b.snapshot()).toEqual(a.snapshot());
|
||||
});
|
||||
|
||||
it('preserves commands enqueued but not yet ticked', () => {
|
||||
const a = fresh();
|
||||
for (const c of referenceFactory(DATA)) a.enqueue(c);
|
||||
run(a, 100);
|
||||
a.enqueue({ kind: 'place', def: 'shipper', pos: { x: 20, y: 20 }, dir: 0 });
|
||||
|
||||
const b = fresh();
|
||||
b.load!(a.save!()); // saved with that place still pending
|
||||
run(a, 5);
|
||||
run(b, 5);
|
||||
expect(b.snapshot().entities.length).toBe(a.snapshot().entities.length);
|
||||
expect(b.snapshot()).toEqual(a.snapshot());
|
||||
});
|
||||
|
||||
it('refuses a save from an unknown version', () => {
|
||||
const sim = fresh();
|
||||
expect(() => sim.load!(JSON.stringify({ v: 99 }))).toThrow(/version/);
|
||||
});
|
||||
|
||||
it('refuses a save referencing machines this GameData does not have', () => {
|
||||
const a = fresh();
|
||||
a.enqueue({ kind: 'place', def: 'demuxer', pos: { x: 0, y: 0 }, dir: 0 });
|
||||
run(a, 1);
|
||||
const blob = a.save!().replace(/"demuxer"/g, '"machine-from-the-future"');
|
||||
|
||||
const b = fresh();
|
||||
expect(() => b.load!(blob)).toThrow(/machine-from-the-future/);
|
||||
});
|
||||
});
|
||||
@ -138,35 +138,43 @@ describe('splitters', () => {
|
||||
});
|
||||
|
||||
describe('buffer tanks', () => {
|
||||
// v3 units: gen/draw are per SECOND, stored is bandwidth-SECONDS. A 30/s surplus adds
|
||||
// 30 stored per second — i.e. 1 per tick — not 30 per tick.
|
||||
it('charges on surplus, covers a deficit, and only browns out once dry', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('gen', 0, 0));
|
||||
sim.enqueue(place('gen', 1, 0));
|
||||
sim.enqueue(place('gen', 2, 0)); // gen 30
|
||||
sim.enqueue(place('tank', 3, 0)); // cap 100
|
||||
sim.enqueue(place('gen', 2, 0)); // gen 30/s
|
||||
sim.enqueue(place('tank', 3, 0)); // cap 100 bandwidth-seconds
|
||||
|
||||
run(sim, 1);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(30); // surplus 30 -> charge 30
|
||||
run(sim, 3);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(100); // clamps at capacity
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(1); // 30/s surplus over 1/30 s = 1
|
||||
run(sim, 99);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(100); // full after 100 ticks, then clamps
|
||||
run(sim, 10);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(100);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false);
|
||||
|
||||
// Draw 40 against gen 30: a 10/tick deficit the tank should cover for 10 ticks.
|
||||
// Draw 60 against gen 30: a 30/s deficit, which is 1 stored per tick. 100 stored is
|
||||
// therefore exactly 100 ticks of cover.
|
||||
sim.enqueue(place('load', 0, 1));
|
||||
sim.enqueue(place('load', 1, 1));
|
||||
sim.enqueue(place('load', 2, 1));
|
||||
const evs: SimEvent[] = [];
|
||||
run(sim, 1, evs);
|
||||
expect(sim.snapshot().bandwidth.draw).toBe(40);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(90);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false); // tank is carrying it
|
||||
expect(sim.snapshot().bandwidth.draw).toBe(60);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(99);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false); // the tank is carrying it
|
||||
|
||||
run(sim, 9, evs);
|
||||
run(sim, 99, evs);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(0);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered
|
||||
expect(evs.filter((e) => e.kind === 'brownout')).toHaveLength(0);
|
||||
|
||||
run(sim, 1, evs);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go
|
||||
expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1);
|
||||
expect(sim.snapshot().bandwidth.gen).toBe(30); // still reported per second
|
||||
});
|
||||
|
||||
it('browns out immediately with no tank at all', () => {
|
||||
|
||||
@ -69,9 +69,24 @@ export function referenceFactory(data: GameData): Command[] {
|
||||
cmds.push({ kind: 'setRecipe', entity, recipe: recipeId(recipe) });
|
||||
};
|
||||
|
||||
// ---- power. Four decoders cover ~146 draw at full tilt, with room for the i-only
|
||||
// assembler's 24-bandwidth spikes.
|
||||
for (let i = 0; i < 4; i++) P('software-decoder', -30 + i * 3, -14);
|
||||
// ---- power: ASIC base + software-decoder burst + tanks.
|
||||
//
|
||||
// Round 2 ran four naked software decoders. Once DATA gave them real heat they all
|
||||
// scrammed in lockstep and the factory blacked out, which is why melt stopped arriving.
|
||||
// The fix is the shape the codex always implied: cool inflexible ASICs hold the floor,
|
||||
// one hot decoder bursts on top of them, and the tanks ride out its downtime.
|
||||
//
|
||||
// Sized against the WORST case, not the average: the quantizers and subsamplers hand
|
||||
// back ~30/s of compression bandwidth, but not while they're between crafts. Assume
|
||||
// none of it. Four ASICs (80/s) against ~107/s draw leaves a 27/s hole for the ~16.7s
|
||||
// the decoder is down = ~450 bandwidth-seconds, and three tanks hold 900. Two ASICs
|
||||
// fewer and it survives only while compression happens to be running — measured that,
|
||||
// it bottomed out at 2.2 of 600, which is not a margin, it's a coincidence.
|
||||
for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -14);
|
||||
P('software-decoder', -18, -14);
|
||||
P('buffer-tank', -15, -14);
|
||||
P('buffer-tank', -12, -14);
|
||||
P('buffer-tank', -9, -14);
|
||||
|
||||
// ---- mining. Three seams keep the demuxer at its 45-tick cadence (it eats 3 ore).
|
||||
P('seam-extractor', -30, 0);
|
||||
|
||||
@ -9,6 +9,10 @@ export interface Rng {
|
||||
next(): number;
|
||||
/** integer in [0, maxExclusive) */
|
||||
int(maxExclusive: number): number;
|
||||
/** current internal state, for save() */
|
||||
state(): number;
|
||||
/** restore a state() value, for load() */
|
||||
restore(state: number): void;
|
||||
}
|
||||
|
||||
export function makeRng(seed: number): Rng {
|
||||
@ -20,5 +24,10 @@ export function makeRng(seed: number): Rng {
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
return { next, int: (maxExclusive: number) => Math.floor(next() * maxExclusive) };
|
||||
return {
|
||||
next,
|
||||
int: (maxExclusive: number) => Math.floor(next() * maxExclusive),
|
||||
state: () => a,
|
||||
restore: (state: number) => { a = state >>> 0; },
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import type { GameData, MachineDef } from '../contracts';
|
||||
import { attrs, cls, el, panel, text } from './dom';
|
||||
import { paginate, pageOf, type BuildPage } from './pages';
|
||||
import { machineColor } from './palette';
|
||||
import { createAccentLookup, machineChassis } from './palette';
|
||||
import type { BuildSelection } from './selection';
|
||||
import { COPY, DIR_NAME } from './voice';
|
||||
|
||||
@ -19,6 +19,7 @@ export interface BuildBar {
|
||||
|
||||
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
const pages: BuildPage[] = paginate(data.machines);
|
||||
const accentOf = createAccentLookup(data);
|
||||
|
||||
const root = panel();
|
||||
root.id = 'fk-build';
|
||||
@ -42,6 +43,16 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
return tab;
|
||||
});
|
||||
|
||||
// REMOVE lives beside the tabs, not on a page: it is a mode, not a machine, and it
|
||||
// must be reachable whatever page you are on.
|
||||
const removeBtn = el('button', 'fk-tab fk-tab-remove', [
|
||||
COPY.removeLabel,
|
||||
el('span', 'fk-tab-key', [COPY.removeKey]),
|
||||
]);
|
||||
attrs(removeBtn, { type: 'button', title: 'REMOVE MODE (X)' });
|
||||
removeBtn.addEventListener('click', () => sel.toggleRemove());
|
||||
tabRow.append(removeBtn);
|
||||
|
||||
/** Buttons for the active page only; rebuilt on page change (nine at most). */
|
||||
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
|
||||
|
||||
@ -51,8 +62,13 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
const btn = el('button', 'fk-build-btn');
|
||||
attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` });
|
||||
|
||||
// Two-material rule (codex §8): grimy body, one impossible element.
|
||||
const ico = el('div', 'fk-build-ico');
|
||||
ico.style.background = machineColor(m);
|
||||
ico.style.background = machineChassis(m);
|
||||
const accent = el('div', 'fk-build-accent');
|
||||
accent.style.background = accentOf(m);
|
||||
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
|
||||
ico.append(accent);
|
||||
btn.append(ico);
|
||||
|
||||
const key = el('span', 'fk-build-key');
|
||||
@ -74,14 +90,21 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
|
||||
function sync() {
|
||||
const cur = sel.get();
|
||||
const removing = sel.isRemoving();
|
||||
|
||||
for (const b of buttons) cls(b.btn, 'is-sel', cur?.def === b.def);
|
||||
if (cur) {
|
||||
cls(removeBtn, 'is-armed', removing);
|
||||
|
||||
if (removing) {
|
||||
text(hint, COPY.removeHint);
|
||||
} else if (cur) {
|
||||
const def = data.machines.find((m) => m.id === cur.def);
|
||||
text(hint, `${def?.name ?? cur.def} · FACING ${DIR_NAME[cur.dir]} · ${COPY.placingHint}`);
|
||||
} else {
|
||||
text(hint, COPY.idleHint);
|
||||
}
|
||||
cls(hint, 'is-placing', !!cur);
|
||||
cls(hint, 'is-removing', removing);
|
||||
}
|
||||
|
||||
// A selection made from anywhere (including a future hotkey we don't own) pulls the
|
||||
|
||||
@ -58,9 +58,11 @@ export function createUI(): UI {
|
||||
keys.bind('[', () => build.pageBy(-1));
|
||||
keys.bind(']', () => build.pageBy(1));
|
||||
keys.bind('r', () => sel.rotate());
|
||||
keys.bind('x', () => sel.toggleRemove());
|
||||
keys.bind('escape', () => {
|
||||
// One key, two jobs, in the order the player expects: drop the tool first.
|
||||
if (sel.get()) sel.clear();
|
||||
// One key, several jobs, in the order the player expects: put the tool down
|
||||
// first (wrecking ball included), close the panel only when empty-handed.
|
||||
if (sel.isArmed()) sel.clear();
|
||||
else inspector.close();
|
||||
});
|
||||
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
|
||||
@ -86,6 +88,15 @@ export function createUI(): UI {
|
||||
const tile = bus.pickTile(e.clientX, e.clientY);
|
||||
if (!tile) return;
|
||||
|
||||
if (sel.isRemoving()) {
|
||||
// Only fire at something that's actually there, so a stray click on open
|
||||
// ground doesn't read as a broken tool. Sim owns the real teardown.
|
||||
if (entityAt(roster, machines, tile) !== null) {
|
||||
bus.dispatch({ kind: 'remove', pos: tile });
|
||||
}
|
||||
return; // remove mode stays armed: demolition is usually plural
|
||||
}
|
||||
|
||||
const hit = entityAt(roster, machines, tile);
|
||||
if (hit === null) inspector.close();
|
||||
else inspector.open(hit);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Command, Dir, UIBus, Vec2 } from '../contracts';
|
||||
import { createSelection } from './selection';
|
||||
import { createSelection, REMOVE_DEF } from './selection';
|
||||
|
||||
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
|
||||
|
||||
@ -59,6 +59,70 @@ describe('createSelection', () => {
|
||||
expect(sel.get()).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes the __remove sentinel on the bus so RENDER can tint the target', () => {
|
||||
// The ghost is the only channel the UI has to the renderer (main.ts feeds setGhost
|
||||
// from selectedBuild), so this sentinel IS the remove-mode hover contract.
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.toggleRemove();
|
||||
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
|
||||
expect(bus.selectedBuild()).toEqual({ def: REMOVE_DEF, dir: 0 });
|
||||
});
|
||||
|
||||
it('reports no build selection while removing, so nothing gets placed', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.toggleRemove();
|
||||
expect(sel.get()).toBeNull();
|
||||
expect(sel.isRemoving()).toBe(true);
|
||||
expect(sel.isArmed()).toBe(true);
|
||||
});
|
||||
|
||||
it('disarms remove when a machine is picked — you cannot build and demolish at once', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.toggleRemove();
|
||||
sel.select('belt');
|
||||
expect(sel.isRemoving()).toBe(false);
|
||||
expect(bus._sel).toEqual({ def: 'belt', dir: 0 });
|
||||
});
|
||||
|
||||
it('drops the held machine when remove is armed', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.select('belt');
|
||||
sel.toggleRemove();
|
||||
expect(sel.get()).toBeNull();
|
||||
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
|
||||
});
|
||||
|
||||
it('toggles remove off again, leaving empty hands', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.toggleRemove();
|
||||
sel.toggleRemove();
|
||||
expect(sel.isRemoving()).toBe(false);
|
||||
expect(sel.isArmed()).toBe(false);
|
||||
expect(bus._sel).toBeNull();
|
||||
});
|
||||
|
||||
it('clear() disarms remove mode, which is what Esc rides on', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.toggleRemove();
|
||||
sel.clear();
|
||||
expect(sel.isRemoving()).toBe(false);
|
||||
expect(bus._sel).toBeNull();
|
||||
});
|
||||
|
||||
it('does not rotate the wrecking ball', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.toggleRemove();
|
||||
sel.rotate();
|
||||
expect(bus._sel).toEqual({ def: REMOVE_DEF, dir: 0 });
|
||||
});
|
||||
|
||||
it('notifies listeners on change, and not on a no-op clear', () => {
|
||||
const sel = createSelection(makeBus());
|
||||
const cb = vi.fn();
|
||||
|
||||
@ -1,18 +1,41 @@
|
||||
/**
|
||||
* LANE-UI — the pending build selection.
|
||||
* LANE-UI — the pending build selection, and remove mode.
|
||||
*
|
||||
* main.ts reads this through `bus.selectedBuild()` for its ghost + click-to-place glue,
|
||||
* and the hook it documents is the `_sel` field on the bus object. UI is the only writer;
|
||||
* we write through to `_sel` on every change and treat our own copy as the truth.
|
||||
*
|
||||
* REMOVE MODE / the `__remove` sentinel
|
||||
* ------------------------------------
|
||||
* Remove-mode hover has to tint the target machine, and the renderer's only view of what
|
||||
* the player is holding is `setGhost(...)`, which main.ts feeds from `selectedBuild()`.
|
||||
* The UI has no Renderer reference, so the sentinel *is* the channel: while remove mode
|
||||
* is armed, `_sel` reads `{ def: '__remove', dir: 0 }`. LANE-RENDER keys its demolition
|
||||
* tint off that def id (agreed via NOTES).
|
||||
*
|
||||
* Safe because sim's `place` handler does `const def = defs.get(cmd.def); if (def) ...` —
|
||||
* main.ts's click-to-place fires a `place` for `__remove`, no machine matches, nothing
|
||||
* happens. The actual removal is dispatched by the UI's own handler.
|
||||
*
|
||||
* A `UIBus.setGhostMode(...)` would be cleaner than a magic string; CONTRACT REQUEST filed.
|
||||
*/
|
||||
import type { Dir, UIBus } from '../contracts';
|
||||
|
||||
/** Ghost sentinel meaning "remove mode is armed". Shared with LANE-RENDER by agreement. */
|
||||
export const REMOVE_DEF = '__remove';
|
||||
|
||||
export interface BuildSelection {
|
||||
/** The pending *build*, or null. Null whenever remove mode is armed. */
|
||||
get(): { def: string; dir: Dir } | null;
|
||||
/** Select a machine; selecting the one already selected clears it (toggle). */
|
||||
select(def: string): void;
|
||||
rotate(): void;
|
||||
/** Drop the build selection AND disarm remove mode. */
|
||||
clear(): void;
|
||||
isRemoving(): boolean;
|
||||
toggleRemove(): void;
|
||||
/** True when the player is holding anything at all (build or the wrecking ball). */
|
||||
isArmed(): boolean;
|
||||
onChange(cb: () => void): void;
|
||||
}
|
||||
|
||||
@ -21,26 +44,34 @@ type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
|
||||
export function createSelection(bus: UIBus): BuildSelection {
|
||||
const hook = bus as BusWithSel;
|
||||
let sel: { def: string; dir: Dir } | null = null;
|
||||
let removing = false;
|
||||
const listeners: Array<() => void> = [];
|
||||
|
||||
function commit(next: { def: string; dir: Dir } | null) {
|
||||
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
|
||||
sel = next;
|
||||
hook._sel = next;
|
||||
removing = nextRemoving;
|
||||
hook._sel = removing ? { def: REMOVE_DEF, dir: 0 } : next;
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => sel,
|
||||
get: () => (removing ? null : sel),
|
||||
select(def) {
|
||||
if (sel?.def === def) commit(null);
|
||||
else commit({ def, dir: sel?.dir ?? 0 }); // keep the dir across swaps
|
||||
// Picking a machine puts the wrecking ball down — you can't build and demolish.
|
||||
if (!removing && sel?.def === def) commit(null, false);
|
||||
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps
|
||||
},
|
||||
rotate() {
|
||||
if (sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir });
|
||||
if (!removing && sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir }, false);
|
||||
},
|
||||
clear() {
|
||||
if (sel) commit(null);
|
||||
if (sel || removing) commit(null, false);
|
||||
},
|
||||
isRemoving: () => removing,
|
||||
toggleRemove() {
|
||||
commit(null, !removing);
|
||||
},
|
||||
isArmed: () => removing || sel !== null,
|
||||
onChange(cb) {
|
||||
listeners.push(cb);
|
||||
},
|
||||
|
||||
@ -124,6 +124,18 @@ const CSS = `
|
||||
}
|
||||
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
|
||||
.fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
|
||||
|
||||
/* REMOVE is a mode, not a machine — it sits with the tabs and reads as a hazard. */
|
||||
.fk-tab-remove { margin-left: auto; color: var(--fk-dim); }
|
||||
.fk-tab-remove:hover { color: var(--fk-red); border-color: var(--fk-red); }
|
||||
.fk-tab-remove.is-armed {
|
||||
color: #12080a; background: var(--fk-red); border-color: var(--fk-red);
|
||||
animation: fk-armed 0.9s steps(2) infinite;
|
||||
}
|
||||
.fk-tab-key { margin-left: 5px; opacity: 0.65; }
|
||||
@keyframes fk-armed { 50% { background: #a8242c; } }
|
||||
|
||||
#fk-build-hint.is-removing { color: var(--fk-red); }
|
||||
.fk-build-btn {
|
||||
position: relative;
|
||||
width: 58px;
|
||||
@ -141,7 +153,13 @@ const CSS = `
|
||||
.fk-build-btn:hover { border-color: var(--fk-cool); color: var(--fk-fg); }
|
||||
.fk-build-btn.is-sel { border-color: var(--fk-hot); color: var(--fk-fg); background: #1c0a20; }
|
||||
.fk-build-btn.is-sel .fk-build-ico { box-shadow: 0 0 6px var(--fk-hot); }
|
||||
.fk-build-ico { width: 100%; height: 18px; margin-bottom: 3px; }
|
||||
/* Body + one emissive element — the chassis alone is a grey wall (codex §8). */
|
||||
.fk-build-ico {
|
||||
position: relative;
|
||||
width: 100%; height: 18px; margin-bottom: 3px;
|
||||
display: flex; align-items: flex-end;
|
||||
}
|
||||
.fk-build-accent { width: 100%; height: 4px; }
|
||||
.fk-build-key {
|
||||
position: absolute; top: 1px; right: 2px;
|
||||
font-size: 8px; color: var(--fk-faint);
|
||||
|
||||
@ -79,8 +79,10 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
|
||||
: `${machineName(ev.entity)} IS COOL ENOUGH TO CONTINUE. REGRETTABLY.`;
|
||||
case 'commissionDone':
|
||||
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
|
||||
case 'removed':
|
||||
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
|
||||
default:
|
||||
return null; // shipped/crafted/placed/removed are too frequent to toast
|
||||
return null; // shipped/crafted/placed are too frequent to toast
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,7 +115,11 @@ export const COPY = {
|
||||
sanitising: 'SANITISING',
|
||||
|
||||
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
|
||||
idleHint: '1-9 SELECT · [ ] PAGE · SPACE HALT · CLICK A UNIT TO INSPECT',
|
||||
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · SPACE HALT · CLICK A UNIT TO INSPECT',
|
||||
|
||||
removeLabel: 'REMOVE',
|
||||
removeKey: 'X',
|
||||
removeHint: 'REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN',
|
||||
} as const;
|
||||
|
||||
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user