diff --git a/fktry/src/sim/bloom.test.ts b/fktry/src/sim/bloom.test.ts new file mode 100644 index 0000000..c389f48 --- /dev/null +++ b/fktry/src/sim/bloom.test.ts @@ -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(); + 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'); + }); +}); diff --git a/fktry/src/sim/index.ts b/fktry/src/sim/index.ts index 3b8c6ea..af06723 100644 --- a/fktry/src/sim/index.ts +++ b/fktry/src/sim/index.ts @@ -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; + }, }; } diff --git a/fktry/src/sim/persist.test.ts b/fktry/src/sim/persist.test.ts new file mode 100644 index 0000000..b57ba51 --- /dev/null +++ b/fktry/src/sim/persist.test.ts @@ -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/); + }); +}); diff --git a/fktry/src/sim/pressure.test.ts b/fktry/src/sim/pressure.test.ts index d9e6fed..cc07943 100644 --- a/fktry/src/sim/pressure.test.ts +++ b/fktry/src/sim/pressure.test.ts @@ -138,35 +138,43 @@ describe('splitters', () => { }); describe('buffer tanks', () => { + // v3 units: gen/draw are per SECOND, stored is bandwidth-SECONDS. A 30/s surplus adds + // 30 stored per second — i.e. 1 per tick — not 30 per tick. it('charges on surplus, covers a deficit, and only browns out once dry', () => { const sim = newSim(); sim.enqueue(place('gen', 0, 0)); sim.enqueue(place('gen', 1, 0)); - sim.enqueue(place('gen', 2, 0)); // gen 30 - sim.enqueue(place('tank', 3, 0)); // cap 100 + sim.enqueue(place('gen', 2, 0)); // gen 30/s + sim.enqueue(place('tank', 3, 0)); // cap 100 bandwidth-seconds run(sim, 1); - expect(sim.snapshot().bandwidth.stored).toBe(30); // surplus 30 -> charge 30 - run(sim, 3); - expect(sim.snapshot().bandwidth.stored).toBe(100); // clamps at capacity + expect(sim.snapshot().bandwidth.stored).toBe(1); // 30/s surplus over 1/30 s = 1 + run(sim, 99); + expect(sim.snapshot().bandwidth.stored).toBe(100); // full after 100 ticks, then clamps + run(sim, 10); + expect(sim.snapshot().bandwidth.stored).toBe(100); expect(sim.snapshot().bandwidth.brownout).toBe(false); - // Draw 40 against gen 30: a 10/tick deficit the tank should cover for 10 ticks. + // Draw 60 against gen 30: a 30/s deficit, which is 1 stored per tick. 100 stored is + // therefore exactly 100 ticks of cover. sim.enqueue(place('load', 0, 1)); sim.enqueue(place('load', 1, 1)); + sim.enqueue(place('load', 2, 1)); const evs: SimEvent[] = []; run(sim, 1, evs); - expect(sim.snapshot().bandwidth.draw).toBe(40); - expect(sim.snapshot().bandwidth.stored).toBe(90); - expect(sim.snapshot().bandwidth.brownout).toBe(false); // tank is carrying it + expect(sim.snapshot().bandwidth.draw).toBe(60); + expect(sim.snapshot().bandwidth.stored).toBe(99); + expect(sim.snapshot().bandwidth.brownout).toBe(false); // the tank is carrying it - run(sim, 9, evs); + run(sim, 99, evs); expect(sim.snapshot().bandwidth.stored).toBe(0); expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered + expect(evs.filter((e) => e.kind === 'brownout')).toHaveLength(0); run(sim, 1, evs); - expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go + expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1); + expect(sim.snapshot().bandwidth.gen).toBe(30); // still reported per second }); it('browns out immediately with no tank at all', () => { diff --git a/fktry/src/sim/reference.ts b/fktry/src/sim/reference.ts index 77cc7a3..cdd1330 100644 --- a/fktry/src/sim/reference.ts +++ b/fktry/src/sim/reference.ts @@ -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); diff --git a/fktry/src/sim/rng.ts b/fktry/src/sim/rng.ts index 9220fb7..91788b7 100644 --- a/fktry/src/sim/rng.ts +++ b/fktry/src/sim/rng.ts @@ -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; }, + }; } diff --git a/fktry/src/ui/buildbar.ts b/fktry/src/ui/buildbar.ts index 95f2ed2..f0c93cd 100644 --- a/fktry/src/ui/buildbar.ts +++ b/fktry/src/ui/buildbar.ts @@ -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 diff --git a/fktry/src/ui/index.ts b/fktry/src/ui/index.ts index a34a92b..493398c 100644 --- a/fktry/src/ui/index.ts +++ b/fktry/src/ui/index.ts @@ -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); diff --git a/fktry/src/ui/selection.test.ts b/fktry/src/ui/selection.test.ts index 3863d8a..ea69207 100644 --- a/fktry/src/ui/selection.test.ts +++ b/fktry/src/ui/selection.test.ts @@ -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(); diff --git a/fktry/src/ui/selection.ts b/fktry/src/ui/selection.ts index 98c52e8..7aefeb8 100644 --- a/fktry/src/ui/selection.ts +++ b/fktry/src/ui/selection.ts @@ -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); }, diff --git a/fktry/src/ui/style.ts b/fktry/src/ui/style.ts index b6f63fc..fb7f77a 100644 --- a/fktry/src/ui/style.ts +++ b/fktry/src/ui/style.ts @@ -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); diff --git a/fktry/src/ui/voice.ts b/fktry/src/ui/voice.ts index 216c837..acc730e 100644 --- a/fktry/src/ui/voice.ts +++ b/fktry/src/ui/voice.ts @@ -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. */