From 039eff15faab7ea821258bf5cc844d83d5ba1189 Mon Sep 17 00:00:00 2001 From: LANE-SIM Date: Fri, 17 Jul 2026 20:20:32 +1000 Subject: [PATCH] [sim] research + gating, spatial cooling, exported constants, reference v4 Round 4 per lanes/LANE-SIM.md. - research v1: kind:'lab', setResearch, snapshot.research, 'researched'. Gating: any id a tech unlocks is refused until that tech lands; ids no tech names are free forever. Labs take only what the active tech still needs; several labs feed one total; research survives save/load as a RULE, so a restored save can still build what it unlocked - realtech.test.ts: a cold-start research run on DATA's own tree -- ore -> packs -> archaeology lab -> tech -> a refused recipe now accepted. Also the tripwire if the tech bootstrap ever closes over again; it was circular and unenterable at the start of this round - spatial cooling: coolRadius, chebyshev from the footprint, auras sum, total capped at HEAT_COOL_MAX (0.05 -- past that a stack buys nothing but still costs bandwidth and floor). Proven by measuring shed rate - src/sim/constants.ts: 12 constants so DATA imports truth instead of hand-copying numbers that go stale in silence - reference v4: anchor-slab overflow tap, so the demo ships slabs (18 per 20k). Needed TWO melt lanes: with one, melt spills onto the slab lane and the splitter phase-locks with the reactor's push order -- slab to the assembler, melt to the uplink, forever, and no slab ever sold. It passed 20k ticks looking perfect. referenceFactoryTech() derives the techs the layout needs, from the commands - also wired: bandwidth.capacity, EntityState.scrammed, commissionQueue 65 sim tests, 318 across the repo. Melt still tick 1248, zero brownouts over 20k. Co-Authored-By: Claude Opus 4.8 --- fktry/src/sim/bloom.test.ts | 2 + fktry/src/sim/constants.ts | 57 +++++++ fktry/src/sim/index.ts | 207 +++++++++++++++++++---- fktry/src/sim/persist.test.ts | 2 + fktry/src/sim/pressure.test.ts | 93 +++++++++++ fktry/src/sim/realtech.test.ts | 171 +++++++++++++++++++ fktry/src/sim/reference.test.ts | 40 ++++- fktry/src/sim/reference.ts | 46 +++++- fktry/src/sim/research.test.ts | 281 ++++++++++++++++++++++++++++++++ fktry/src/sim/sim.test.ts | 4 + fktry/src/sim/testkit.ts | 31 ++++ 11 files changed, 899 insertions(+), 35 deletions(-) create mode 100644 fktry/src/sim/constants.ts create mode 100644 fktry/src/sim/realtech.test.ts create mode 100644 fktry/src/sim/research.test.ts create mode 100644 fktry/src/sim/testkit.ts diff --git a/fktry/src/sim/bloom.test.ts b/fktry/src/sim/bloom.test.ts index c389f48..485eae1 100644 --- a/fktry/src/sim/bloom.test.ts +++ b/fktry/src/sim/bloom.test.ts @@ -19,6 +19,7 @@ */ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; +import { grantAllResearch } from './testkit'; import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts'; import items from '../../data/items.json'; @@ -33,6 +34,7 @@ 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); + grantAllResearch(sim, DATA); // the bloom duplicator is gated behind stream-bloom-loops return sim; } function run(sim: Sim, ticks: number, sink?: SimEvent[]): void { diff --git a/fktry/src/sim/constants.ts b/fktry/src/sim/constants.ts new file mode 100644 index 0000000..7c84d1c --- /dev/null +++ b/fktry/src/sim/constants.ts @@ -0,0 +1,57 @@ +/** + * LANE-SIM's tuning constants — the single source of truth. + * + * These were private to index.ts until round 4. They're exported because other lanes' + * tests were hand-copying the numbers and going stale in silence: a copy of 0.7 doesn't + * fail when the sim moves to 0.65, it just quietly asserts the wrong thing forever. + * Import these rather than retyping them. + * + * Anything DATA can already set per-machine (heatPerTick, coolPerTick, bufferCap, + * beltSpeed, coolRadius) lives in data/machines.json, not here. These are the sim-wide + * rules that no machine overrides. + */ + +// ------------------------------------------------------------------ belts + +/** Minimum gap between two items, in belt-tiles. Enforced across the tile seam too. */ +export const BELT_SPACING = 0.45; +/** Items a single belt tile may hold. */ +export const BELT_CAPACITY = 2; +/** Items a splitter holds while it waits for an output to open. */ +export const SPLITTER_CAPACITY = 2; + +// -------------------------------------------------------------- machines + +/** A machine stalls ('output full') once any output piles to this multiple of its yield. */ +export const OUTPUT_STALL_FACTOR = 4; +/** A machine accepts an input up to this multiple of the recipe requirement. */ +export const INPUT_BUFFER_FACTOR = 2; + +// ------------------------------------------------------------------ heat +// +// heatPerTick is GROSS (contracts v4): net climb = heatPerTick - coolPerTick, so a +// machine whose heatPerTick never clears its cooling can never overheat. + +/** Below this heat, throttling is purely cosmetic. */ +export const HEAT_THROTTLE_START = 0.7; +/** Speed multiplier at heat 1.0 — the codex's "tick rate visibly halves". */ +export const HEAT_THROTTLE_FLOOR = 0.5; +/** Heat at which a machine scrams. */ +export const HEAT_SCRAM = 1; +/** Cool back below this and a scrammed machine restarts. */ +export const HEAT_RESTART = 0.5; +/** Per-tick cooling when a machine's def doesn't specify `coolPerTick`. */ +export const HEAT_COOL_DEFAULT = 0.004; +/** + * Ceiling on a machine's total cooling once auras stack (own rate + every cooler in + * range). At 0.05 a machine sheds a full 1.0 of heat in 20 ticks (~0.67s), which is + * already effectively instant — past this, extra coolers would buy nothing but would + * still cost bandwidth and floor space, and stacking them would look broken rather than + * generous. See NOTES round 4. + */ +export const HEAT_COOL_MAX = 0.05; + +// ---------------------------------------------------------------- tracing + +/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */ +export const MAX_TRACE = 512; diff --git a/fktry/src/sim/index.ts b/fktry/src/sim/index.ts index af06723..4d3fd04 100644 --- a/fktry/src/sim/index.ts +++ b/fktry/src/sim/index.ts @@ -15,35 +15,31 @@ import { type GameData, type MachineDef, type RecipeDef, + type ResearchState, type Sim, type SimEvent, type SimSnapshot, + type TechDef, } from '../contracts'; import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom'; import { makeRng, type Rng } from './rng'; -/** Minimum gap between two items measured in belt-tiles. */ -const BELT_SPACING = 0.45; -/** Items a single belt tile may hold. */ -const BELT_CAPACITY = 2; -/** Items a splitter may hold while it waits for an output to open. */ -const SPLITTER_CAPACITY = 2; -/** A machine stalls once any output has piled to this multiple of the recipe yield. */ -const OUTPUT_STALL_FACTOR = 4; -/** A machine accepts an input up to this multiple of the recipe requirement. */ -const INPUT_BUFFER_FACTOR = 2; -/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */ -const MAX_TRACE = 512; - -// Heat v1. Curve is LANE-SIM's call — see NOTES round 2. -/** Below this, heat is cosmetic. */ -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. 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; +// The tuning constants live in ./constants so other lanes' tests can import truth +// instead of hand-copying numbers that go stale in silence. +import { + BELT_CAPACITY, + BELT_SPACING, + HEAT_COOL_DEFAULT, + HEAT_COOL_MAX, + HEAT_RESTART, + HEAT_SCRAM, + HEAT_THROTTLE_FLOOR, + HEAT_THROTTLE_START, + INPUT_BUFFER_FACTOR, + MAX_TRACE, + OUTPUT_STALL_FACTOR, + SPLITTER_CAPACITY, +} from './constants'; /** Internal entity record. `state` is the contract-shaped object handed to consumers. */ interface Ent { @@ -67,6 +63,7 @@ export function createSim(): Sim { const defs = new Map(); const recipeDefs = new Map(); const commissionDefs = new Map(); + const techDefs = new Map(); // ents and entityStates are parallel and always in ascending-id order — the tick // loop walks them in that order, which is what makes the whole sim deterministic. @@ -78,6 +75,8 @@ export function createSim(): Sim { let allBeltItems: BeltItem[] = []; /** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */ const traceCache = new Map(); + /** entity id -> total cooling per tick (own rate + every aura reaching it), capped. */ + const coolCache = new Map(); let nextId = 1; let nextItemId = 1; @@ -87,6 +86,12 @@ export function createSim(): Sim { let stored = 0; let commissionQueue: string[] = []; + /** every id any tech gates — locked until the tech that names it completes */ + const gatedIds = new Set(); + /** ids the completed techs have opened up */ + const openedIds = new Set(); + let research: ResearchState = { active: null, progress: {}, unlocked: [] }; + const cmdQueue: Command[] = []; let events: SimEvent[] = []; /** items already advanced this tick, so a transfer downstream can't double-move */ @@ -98,10 +103,12 @@ export function createSim(): Sim { paused: false, entities: entityStates, beltItems: allBeltItems, - bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false }, + bandwidth: { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false }, shippedTotal: {}, activeCommission: null, + commissionQueue: [], // hardens to required in v5; always present commissionProgress: {}, + research, }; // ------------------------------------------------------------------ lookups @@ -123,6 +130,12 @@ export function createSim(): Sim { /** Static appetite: does this machine's recipe ever call for this item? */ const machineWants = (e: Ent, item: string): boolean => { if (e.def.kind === 'shipper') return true; + if (e.def.kind === 'lab') { + // A lab's appetite is whatever the active tech costs — so it moves when the player + // picks a different target, and setResearch has to drop the route cache. + const t = research.active === null ? undefined : techDefs.get(research.active); + return !!t && (t.cost[item] ?? 0) > 0; + } const r = recipeOf(e); return !!r && (r.inputs[item] ?? 0) > 0; }; @@ -237,6 +250,49 @@ export function createSim(): Sim { e.state.inputBuf = buf; } + /** + * v4 gating: an id named by ANY tech's `unlocks` is locked until that tech completes. + * Ids no tech mentions are free forever — absence from the tech tree means available, + * not forbidden, so a data file with no tech at all gates nothing. + */ + const isAvailable = (id: string): boolean => !gatedIds.has(id) || openedIds.has(id); + + /** The first recipe a machine can legally run — its listed order is its preference. */ + const defaultRecipe = (def: MachineDef): string | null => + def.recipes.find((r) => isAvailable(r)) ?? null; + + /** Chebyshev gap between two footprints, in tiles. 0 = touching or overlapping. */ + function rectGap(a: Ent, b: Ent): number { + const af = footprintOf(a.def.footprint, a.state.dir); + const bf = footprintOf(b.def.footprint, b.state.dir); + const ax1 = a.state.pos.x + af.x - 1, ay1 = a.state.pos.y + af.y - 1; + const bx1 = b.state.pos.x + bf.x - 1, by1 = b.state.pos.y + bf.y - 1; + const dx = Math.max(0, a.state.pos.x - bx1, b.state.pos.x - ax1); + const dy = Math.max(0, a.state.pos.y - by1, b.state.pos.y - ay1); + return Math.max(dx, dy); + } + + /** + * v4 spatial cooling: a machine sheds its own `coolPerTick`, plus the `coolPerTick` of + * every machine whose `coolRadius` aura reaches it. Auras stack by sum and the total is + * capped (HEAT_COOL_MAX) so a wall of coolers can't make heat meaningless. A cooler does + * not cool itself twice — its own rate is already counted. Cached; topology changes clear it. + */ + function coolingOf(e: Ent): number { + const cached = coolCache.get(e.state.id); + if (cached !== undefined) return cached; + let total = e.def.coolPerTick ?? HEAT_COOL_DEFAULT; + for (const c of ents) { + if (c === e) continue; + const radius = c.def.coolRadius ?? 0; + if (radius <= 0 || !c.def.coolPerTick) continue; + if (rectGap(c, e) <= radius) total += c.def.coolPerTick; + } + if (total > HEAT_COOL_MAX) total = HEAT_COOL_MAX; + coolCache.set(e.state.id, total); + return total; + } + /** Scrammed and idle machines contribute nothing; hot ones work at reduced speed. */ function heatThrottle(e: Ent): number { if (e.scrammed) return 0; @@ -249,6 +305,7 @@ export function createSim(): Sim { // ----------------------------------------------------------------- commands function doPlace(def: MachineDef, pos: { x: number; y: number }, dir: Dir): void { + if (!isAvailable(def.id)) return; // locked behind research: silently dropped const fp = footprintOf(def.footprint, dir); const tiles: number[] = []; for (let dx = 0; dx < fp.x; dx++) { @@ -266,7 +323,7 @@ export function createSim(): Sim { def: def.id, pos: { x: pos.x, y: pos.y }, dir, - recipe: def.recipes.length ? def.recipes[0] : null, + recipe: defaultRecipe(def), // first recipe research has actually opened progress: 0, inputBuf: {}, outputBuf: {}, @@ -281,6 +338,7 @@ export function createSim(): Sim { for (const k of tiles) occ.set(k, state.id); if (def.kind === 'belt') beltContents.set(state.id, []); traceCache.clear(); + coolCache.clear(); // auras are geometric: any move re-evaluates them events.push({ kind: 'placed', entity: state.id, def: def.id, tick: curTick }); } @@ -299,6 +357,7 @@ export function createSim(): Sim { beltContents.delete(id); } traceCache.clear(); + coolCache.clear(); // auras are geometric: any move re-evaluates them events.push({ kind: 'removed', entity: id, def: e.def.id, tick: curTick }); } @@ -324,6 +383,7 @@ export function createSim(): Sim { e.tiles = tiles; e.state.dir = dir; traceCache.clear(); + coolCache.clear(); // auras are geometric: any move re-evaluates them } function doSetRecipe(entity: number, recipe: string | null): void { @@ -334,6 +394,7 @@ export function createSim(): Sim { function setRecipeOn(e: Ent, recipe: string | null): void { if (recipe !== null && !e.def.recipes.includes(recipe)) return; + if (recipe !== null && !isAvailable(recipe)) return; // locked behind research e.state.recipe = recipe; e.state.progress = 0; e.crafting = false; @@ -351,6 +412,7 @@ 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 'setResearch': doSetResearch(cmd.tech); 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); @@ -445,6 +507,7 @@ export function createSim(): Sim { snap.bandwidth.gen = gen; snap.bandwidth.draw = draw; snap.bandwidth.stored = stored; + snap.bandwidth.capacity = capacity; snap.bandwidth.brownout = on; return on ? (draw > 0 ? supplied / draw : 1) : 1; } @@ -480,14 +543,14 @@ export function createSim(): Sim { // 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 cool = coolingOf(e); const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting); 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) { + if (!e.scrammed && h >= HEAT_SCRAM) { e.scrammed = true; e.state.scrammed = true; setJam(e, null); // scram is reported by its own event, not as a flow jam @@ -578,6 +641,19 @@ export function createSim(): Sim { syncSplitter(e); return true; } + if (e.def.kind === 'lab') { + // Only take packs the active tech still needs, and only as many as it still needs — + // a lab is not a warehouse, and hoarding packs for a tech nobody picked would strand + // them where no other lab or uplink can reach. + const t = research.active === null ? undefined : techDefs.get(research.active); + if (!t) return false; + const need = t.cost[item] ?? 0; + if (need <= 0) return false; + const pledged = (research.progress[item] ?? 0) + (e.state.inputBuf[item] ?? 0); + if (pledged >= need) return false; + e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1; + return true; + } const r = recipeOf(e); if (!r) return false; const need = r.inputs[item] ?? 0; @@ -662,6 +738,62 @@ export function createSim(): Sim { } } + /** Rebuilds the opened-id set from the completed tech list. */ + function applyUnlocks(): void { + openedIds.clear(); + for (const id of research.unlocked) { + const t = techDefs.get(id); + if (t) for (const u of t.unlocks) openedIds.add(u); + } + traceCache.clear(); // what a machine will accept can change when a recipe opens up + } + + function doSetResearch(tech: string | null): void { + if (tech !== null) { + if (!techDefs.has(tech)) return; // unknown + if (research.unlocked.includes(tech)) return; // already done + } + if (research.active === tech) return; + research.active = tech; + // Progress is per-active-tech: switching targets abandons the packs already delivered + // rather than banking them. Packs are the cost of indecision. + research.progress = {}; + snap.research = research; + traceCache.clear(); // a lab's appetite just changed, so lanes into it re-rank + } + + /** + * Labs pull science packs out of their input into the active tech's progress. There's no + * research "craft time" — a pack counts the moment it lands, so several labs simply feed + * the same total faster. That's what "multiple labs stack" means here. + */ + function drainLabs(): void { + const t = research.active === null ? undefined : techDefs.get(research.active); + if (!t) return; + for (const e of ents) { + if (e.def.kind !== 'lab') continue; + const buf = e.state.inputBuf; + for (const item of Object.keys(buf).sort()) { + const need = t.cost[item] ?? 0; + if (need <= 0) continue; + const have = research.progress[item] ?? 0; + const take = Math.min(buf[item], need - have); + if (take <= 0) continue; + research.progress[item] = have + take; + const left = buf[item] - take; + if (left > 0) buf[item] = left; + else delete buf[item]; + } + } + for (const k in t.cost) if ((research.progress[k] ?? 0) < t.cost[k]) return; + + research.unlocked.push(t.id); + research.active = null; + research.progress = {}; + applyUnlocks(); + events.push({ kind: 'researched', tech: t.id, tick: curTick }); + } + /** Activates the commission at the head of the queue and zeroes its progress. */ function activateCommission(): void { const id = commissionQueue[0] ?? null; @@ -715,9 +847,15 @@ export function createSim(): Sim { defs.clear(); recipeDefs.clear(); commissionDefs.clear(); + techDefs.clear(); + gatedIds.clear(); for (const m of gameData.machines) defs.set(m.id, m); for (const r of gameData.recipes) recipeDefs.set(r.id, r); for (const c of gameData.commissions) commissionDefs.set(c.id, c); + for (const t of gameData.tech) { + techDefs.set(t.id, t); + for (const u of t.unlocks) gatedIds.add(u); // named by a tech => locked until it lands + } ents = []; entityStates = []; @@ -726,6 +864,7 @@ export function createSim(): Sim { occ.clear(); beltContents.clear(); traceCache.clear(); + coolCache.clear(); cmdQueue.length = 0; events = []; moved.clear(); @@ -737,13 +876,16 @@ export function createSim(): Sim { brownout = false; stored = 0; commissionQueue = data.commissions.map((c) => c.id); + research = { active: null, progress: {}, unlocked: [] }; + applyUnlocks(); snap.tick = 0; snap.paused = false; snap.entities = entityStates; snap.beltItems = allBeltItems; - snap.bandwidth = { gen: 0, draw: 0, stored: 0, brownout: false }; + snap.bandwidth = { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false }; snap.shippedTotal = {}; + snap.research = research; activateCommission(); }, @@ -764,6 +906,7 @@ export function createSim(): Sim { stepSplitters(); moveBelts(mult); drainShippers(); + drainLabs(); curTick++; snap.tick = curTick; @@ -797,6 +940,7 @@ export function createSim(): Sim { nextItemId, rng: rng.state(), commissionQueue, + research, shippedTotal: snap.shippedTotal, commissionProgress: snap.commissionProgress, pending: cmdQueue, // commands enqueued but not yet applied @@ -822,10 +966,16 @@ export function createSim(): Sim { occ.clear(); beltContents.clear(); traceCache.clear(); + coolCache.clear(); cmdQueue.length = 0; events = []; moved.clear(); + // Research first: gating decides what a placement is even allowed to be, and the + // entities below were placed under the saved rules, not a fresh sim's. + research = s.research ?? { active: null, progress: {}, unlocked: [] }; + applyUnlocks(); + 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}"`); @@ -868,11 +1018,12 @@ export function createSim(): Sim { snap.paused = paused; snap.entities = entityStates; snap.beltItems = allBeltItems; - snap.bandwidth = { gen: 0, draw: 0, stored, brownout }; + snap.bandwidth = { gen: 0, draw: 0, stored, capacity: 0, brownout }; snap.shippedTotal = s.shippedTotal; snap.commissionProgress = s.commissionProgress; snap.activeCommission = commissionQueue[0] ?? null; snap.commissionQueue = commissionQueue; + snap.research = research; }, }; } diff --git a/fktry/src/sim/persist.test.ts b/fktry/src/sim/persist.test.ts index b57ba51..a08e0c6 100644 --- a/fktry/src/sim/persist.test.ts +++ b/fktry/src/sim/persist.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; import { referenceFactory } from './reference'; +import { grantAllResearch } from './testkit'; import type { GameData, Sim } from '../contracts'; import items from '../../data/items.json'; @@ -22,6 +23,7 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD function fresh(seed = 1337): Sim { const sim = createSim(); sim.init(DATA, seed); + grantAllResearch(sim, DATA); // the reference factory is a post-research build return sim; } function run(sim: Sim, ticks: number): void { diff --git a/fktry/src/sim/pressure.test.ts b/fktry/src/sim/pressure.test.ts index cc07943..798fc38 100644 --- a/fktry/src/sim/pressure.test.ts +++ b/fktry/src/sim/pressure.test.ts @@ -8,6 +8,7 @@ */ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; +import { HEAT_COOL_DEFAULT, HEAT_COOL_MAX } from './constants'; import type { Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, } from '../contracts'; @@ -32,6 +33,9 @@ const FIXTURE: GameData = { mach({ id: 'ship', kind: 'shipper' }), mach({ id: 'gen', kind: 'power', powerGen: 10 }), mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }), + // v4 spatial cooling: radiates its coolPerTick to anything within 2 tiles. + mach({ id: 'cooler', kind: 'power', powerGen: 0, coolPerTick: 0.01, coolRadius: 2 }), + mach({ id: 'bigcooler', kind: 'power', powerGen: 0, coolPerTick: 0.04, coolRadius: 1 }), mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }), mach({ id: 'load', kind: 'crafter', powerDraw: 20 }), mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }), @@ -269,6 +273,95 @@ describe('heat', () => { expect(at(sim, 2, 0)!.heat).toBeLessThan(before); }); + /** Ticks until the generator at (x,y) first scrams, or -1 if it never does. */ + const ticksToScram = (sim: Sim, limit = 1500): number => { + for (let i = 0; i < limit; i++) { + const evs: SimEvent[] = []; + run(sim, 1, evs); + if (evs.some((e) => e.kind === 'scram' && e.on)) return i + 1; + } + return -1; + }; + + describe('spatial cooling', () => { + it('a cooler in range extends a hot generator’s run before it scrams', () => { + const bare = newSim(); + bare.enqueue(place('hotgen', 0, 0)); + const alone = ticksToScram(bare); + expect(alone).toBeGreaterThan(0); // 0.02 gross - 0.004 default cooling + + const cooled = newSim(); + cooled.enqueue(place('hotgen', 0, 0)); + cooled.enqueue(place('cooler', 2, 0)); // 2 tiles away: inside the radius + const withAura = ticksToScram(cooled); + expect(withAura).toBeGreaterThan(0); + // 0.02 - (0.004 + 0.01) = 0.006/tick instead of 0.016: roughly 2.5x the run. + expect(withAura).toBeGreaterThan(alone * 2); + }); + + it('the aura is bounded — a cooler out of range does nothing', () => { + const bare = newSim(); + bare.enqueue(place('hotgen', 0, 0)); + const alone = ticksToScram(bare); + + const far = newSim(); + far.enqueue(place('hotgen', 0, 0)); + far.enqueue(place('cooler', 5, 0)); // radius is 2; this is 5 away + expect(ticksToScram(far)).toBe(alone); + expect(at(far, 0, 0)!.heat).toBe(at(bare, 0, 0)!.heat); + }); + + it('enough cooling in range stops it overheating at all', () => { + const sim = newSim(); + sim.enqueue(place('hotgen', 0, 0)); + sim.enqueue(place('bigcooler', 1, 0)); // 0.004 + 0.04 = 0.044 > 0.02 gross + const evs: SimEvent[] = []; + run(sim, 2000, evs); + expect(evs.filter((e) => e.kind === 'scram')).toHaveLength(0); + expect(at(sim, 0, 0)!.heat).toBe(0); + expect(sim.snapshot().bandwidth.gen).toBe(10); // never throttled, never dropped + }); + + it('auras stack by sum, and the total is capped however many you pile on', () => { + // The oven takes 0.3 heat per bake and has no heatPerTick, so between bakes its heat + // moves by exactly -cooling. That makes the shed rate directly measurable. + const shedRate = (coolers: Array<[number, number]>): number => { + const sim = newSim(); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('oven', 2, 0)); + for (const [x, y] of coolers) sim.enqueue(place('bigcooler', x, y)); + for (let i = 0; i < 400; i++) { + run(sim, 1); + if (at(sim, 2, 0)!.heat > 0.1) break; // a bake just landed + } + const before = at(sim, 2, 0)!.heat; + run(sim, 1); + return before - at(sim, 2, 0)!.heat; + }; + + expect(shedRate([])).toBeCloseTo(HEAT_COOL_DEFAULT, 6); + // One in range: own default + the aura, summed. + expect(shedRate([[3, 0]])).toBeCloseTo(HEAT_COOL_DEFAULT + 0.04, 6); + // Four in range would be 0.004 + 0.16 = 0.164 — the cap holds it at HEAT_COOL_MAX. + const piled = shedRate([[3, 0], [2, 1], [3, 1], [2, -1]]); + expect(piled).toBeCloseTo(HEAT_COOL_MAX, 6); + expect(HEAT_COOL_MAX).toBeLessThan(HEAT_COOL_DEFAULT + 4 * 0.04); // the cap really bites + }); + + it('removing a cooler puts the heat back', () => { + const sim = newSim(); + sim.enqueue(place('hotgen', 0, 0)); + sim.enqueue(place('bigcooler', 1, 0)); + run(sim, 300); + expect(at(sim, 0, 0)!.heat).toBe(0); // held cold by the aura + + sim.enqueue({ kind: 'remove', pos: { x: 1, y: 0 } }); + run(sim, 200); + expect(at(sim, 0, 0)!.heat).toBeGreaterThan(0); // aura gone, it heats again + }); + }); + it('leaves cold machines alone', () => { const sim = newSim(); sim.enqueue(place('gen', 0, 0)); // no heatPerTick diff --git a/fktry/src/sim/realtech.test.ts b/fktry/src/sim/realtech.test.ts new file mode 100644 index 0000000..b06f55b --- /dev/null +++ b/fktry/src/sim/realtech.test.ts @@ -0,0 +1,171 @@ +/** + * A real research run: raw ore in one end, a completed tech out the other, using nothing + * but DATA's own machines, recipes and tech tree. No fixture, no granted state. + * + * This exists because the tree was, earlier this round, impossible to enter: there was no + * lab, and every science pack was made on the artifact-bottler, which was itself locked + * behind a tech costing 10 analog-packs. DATA fixed both mid-round. This test is the proof + * that the fix holds, and the tripwire if the bootstrap ever closes over again. + * + * ore -> demuxer -+-> luma -> quantizer -> coefficient packs -> [split] -+-> crime -> bricks -+ + * | +-> ringing -> halos -+ + * +-> chroma slurry ------------------------------------------------------------+ + * +-> mv-flux -> uplink | + * bricks + halos + slurry -> artifact bottler -> SPATIAL PACK + * -> archaeology lab -> tech + */ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import type { Command, Dir, GameData, Sim, SimEvent, TechDef } from '../contracts'; + +import items from '../../data/items.json'; +import machines from '../../data/machines.json'; +import recipes from '../../data/recipes.json'; +import techJson from '../../data/tech.json'; +import commissions from '../../data/commissions.json'; + +const DATA = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData; +const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3; + +/** The cheapest tech payable in packs the factory below can actually make. */ +function cheapestSpatialTech(): TechDef | undefined { + return DATA.tech + .filter((t) => Object.keys(t.cost).length === 1 && t.cost['spatial-pack'] > 0) + .sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0]; +} + +function packFactory(): 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 }); + + for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -8); + + // ore + 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); + + // mv-flux has no consumer here — sell it, or the demuxer jams on its own byproduct + runY(-1, -2, -25, N); + P('shipper', -26, -4); + + // luma -> coefficient packs + runX(-24, -23, 0, E); + P('quantizer', -22, 0); // 'quantize' is the default recipe + runY(-1, -2, -22, N); // hf dust -> uplink + P('shipper', -23, -4); + b(-20, 0, E); + P('lane-splitter', -19, 0); // coefficient packs: bricks one way, halos the other + + // 1 pack of bricks per craft vs 3 coefficient packs per halo — the splitter's + // skip-when-blocked does the balancing, as ever. + b(-18, 0, E); + P('quantizer', -17, 0); + recipeAt(-17, 0, 'quantize-crime'); // -> macroblock bricks + runY(-1, -2, -17, N); + P('shipper', -18, -4); + runX(-15, -14, 0, E); // bricks -> bottler + + runY(1, 2, -19, S); + P('quantizer', -20, 3); + recipeAt(-20, 3, 'skim-ringing'); // -> ringing halos + runX(-21, -22, 3, W); // its hf dust -> uplink + P('shipper', -24, 2); + runX(-18, -15, 3, E); // halos -> bottler + runY(3, 2, -14, N); + b(-14, 1, E); + + // chroma slurry -> bottler, the long way round below the works + runY(2, 5, -26, S); + b(-26, 6, E); + runX(-25, -14, 6, E); + b(-13, 6, N); + runY(5, 2, -13, N); + + P('artifact-bottler', -13, 0); + recipeAt(-13, 0, 'bottle-spatial-pack'); + runX(-11, -10, 0, E); + P('archaeology-lab', -9, 0); + return cmds; +} + +describe('a real research run, on DATA’s own tree', () => { + it('turns ore into a science pack and a science pack into a tech', () => { + const target = cheapestSpatialTech(); + expect(target, 'DATA has no single-cost spatial-pack tech to aim at').toBeDefined(); + + const sim: Sim = createSim(); + sim.init(DATA, 1337); + // Nothing granted: every machine below must be legal from a cold start. + expect(sim.snapshot().research!.unlocked).toEqual([]); + + for (const c of packFactory()) sim.enqueue(c); + sim.enqueue({ kind: 'setResearch', tech: target!.id }); + + const evs: SimEvent[] = []; + let done = -1; + for (let i = 0; i < 12000 && done < 0; i++) { + sim.tick(); + for (const e of sim.drainEvents()) { + evs.push(e); + if (e.kind === 'researched') done = e.tick; + } + } + + // Every machine the layout asked for was legal from a cold start. + expect(sim.snapshot().entities.find((e) => e.def === 'archaeology-lab')).toBeDefined(); + expect(sim.snapshot().entities.find((e) => e.def === 'artifact-bottler')).toBeDefined(); + // The ladder ran, rung by rung, on real recipes. + for (const r of ['demux-ore', 'quantize', 'quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) { + expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`) + .toBeGreaterThan(0); + } + expect(done, `${target!.id} never completed`).toBeGreaterThan(0); + expect(sim.snapshot().research!.unlocked).toEqual([target!.id]); + expect(sim.snapshot().research!.active).toBeNull(); + }); + + it('opens what the tech promised: a recipe refused before is accepted after', () => { + const target = cheapestSpatialTech()!; + const unlockedRecipe = target.unlocks.find((u) => + DATA.recipes.some((r) => r.id === u))!; + expect(unlockedRecipe, `${target.id} unlocks no recipe to test with`).toBeDefined(); + const owner = DATA.recipes.find((r) => r.id === unlockedRecipe)!.machine; + + const sim: Sim = createSim(); + sim.init(DATA, 1337); + sim.enqueue({ kind: 'place', def: owner, pos: { x: 0, y: 0 }, dir: N }); + sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe }); + sim.tick(); + const before = sim.snapshot().entities.find((e) => e.pos.x === 0)!; + expect(before.recipe).not.toBe(unlockedRecipe); // silently refused + + for (const c of packFactory()) sim.enqueue(c); + sim.enqueue({ kind: 'setResearch', tech: target.id }); + for (let i = 0; i < 12000; i++) { + sim.tick(); + if (sim.drainEvents().some((e) => e.kind === 'researched')) break; + } + expect(sim.snapshot().research!.unlocked).toEqual([target.id]); + + sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe }); + sim.tick(); + expect(sim.snapshot().entities.find((e) => e.pos.x === 0)!.recipe).toBe(unlockedRecipe); + }); +}); diff --git a/fktry/src/sim/reference.test.ts b/fktry/src/sim/reference.test.ts index 658b766..65f7d66 100644 --- a/fktry/src/sim/reference.test.ts +++ b/fktry/src/sim/reference.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; -import { referenceFactory } from './reference'; +import { referenceFactory, referenceFactoryTech } from './reference'; +import { grantResearch } from './testkit'; import type { GameData, Sim, SimEvent } from '../contracts'; import items from '../../data/items.json'; @@ -14,6 +15,9 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD function build(): Sim { const sim = createSim(); sim.init(DATA, 1337); + // A post-research save: v4 gates two of this layout's ids. Grant exactly what it needs + // and nothing else, so the test would notice if the layout started depending on more. + grantResearch(sim, referenceFactoryTech(DATA)); for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd); return sim; } @@ -67,6 +71,9 @@ describe('reference factory', () => { // Surplus is being turned into product, not silently dropped. expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0); expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0); + // v4: the slab overflow tap is live, so the demo visibly ships anchor slabs without + // anyone having to demolish the i-only assembler to see one. + expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0); }); it('refuses to build against drifted data instead of deadlocking mysteriously', () => { @@ -74,3 +81,34 @@ describe('reference factory', () => { expect(() => referenceFactory(stripped)).toThrow(/quantize-crime/); }); }); + +describe('what the reference build costs in research', () => { + it('names exactly the techs it needs, and needs at least one', () => { + const needed = referenceFactoryTech(DATA); + expect(needed.length).toBeGreaterThan(0); // v4 gating is live; this is a late-game build + // Every named tech is real, and granting just these is enough to build the whole thing. + for (const id of needed) expect(DATA.tech.some((t) => t.id === id)).toBe(true); + + const sim = createSim(); + sim.init(DATA, 1337); + grantResearch(sim, needed); + for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd); + sim.tick(); + const placed = sim.snapshot().entities.length; + const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length; + expect(placed, 'every placement in the layout must be legal once its techs are open') + .toBe(asked); + }); + + it('is silently crippled without them — the demo hook must grant first', () => { + const sim = createSim(); + sim.init(DATA, 1337); // cold start: nothing researched + for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd); + sim.tick(); + const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length; + // Gated placements are dropped without a word, which is the contract's rule — the + // factory simply comes up missing its power and browns out. + expect(sim.snapshot().entities.length).toBeLessThan(asked); + expect(sim.snapshot().entities.some((e) => e.def === 'software-decoder')).toBe(false); + }); +}); diff --git a/fktry/src/sim/reference.ts b/fktry/src/sim/reference.ts index cdd1330..36e970f 100644 --- a/fktry/src/sim/reference.ts +++ b/fktry/src/sim/reference.ts @@ -32,6 +32,25 @@ import type { Command, Dir, GameData } from '../contracts'; const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3; +/** + * The techs this layout needs before it can be built at all — derived from the commands + * themselves, so it stays true if either the layout or DATA's tree moves. + * + * Contracts v4 gates every id a tech names, and this build uses two of them, so it is a + * post-research save rather than a cold start. Anything driving it (a test, the dev demo + * hook) has to open these first or the commands are silently dropped and the factory comes + * up underpowered — measured: the reactor's uplink runs at 100 draw against 80 gen and the + * whole sector browns out. See NOTES round 4. + */ +export function referenceFactoryTech(data: GameData): string[] { + const needs = new Set(); + for (const c of referenceFactory(data)) { + if (c.kind === 'place') needs.add(c.def); + else if (c.kind === 'setRecipe' && c.recipe !== null) needs.add(c.recipe); + } + return data.tech.filter((t) => t.unlocks.some((u) => needs.has(u))).map((t) => t.id); +} + export function referenceFactory(data: GameData): Command[] { // Fail loudly and specifically if DATA moves under us — a missing id here would // otherwise surface as a mystery deadlock hours later. @@ -151,15 +170,30 @@ export function referenceFactory(data: GameData): Command[] { P('gop-assembler', -10, 0); // assemble-gop (default) runX(-7, -6, 0, E); P('mosh-reactor', -5, 0); - runX(-2, -1, 0, E); // melt -> uplink + // TWO melt lanes, and the second one is not decoration. The reactor pushes one melt and + // one slab per tick, so with a single lane melt #2 finds its own belt full, sees the slab + // lane also ends at an uplink (rank 1, same as its own), and takes it. The splitter down + // there then locks into perfect alternation with the reactor's push order — slab to the + // assembler, melt to the uplink, forever — and no slab is ever sold. It survives 20,000 + // ticks looking correct. A second lane means melt never wants the slab lane at all. + runX(-2, -1, 0, E); + runX(-2, -1, 1, E); P('shipper', 0, 0); - // Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the - // i-only assembler: 8 slabs -> another GOP crate -> more melt. - runY(3, 4, -5, S); - const iOnly = P('gop-assembler', -6, 5); + // Recovered anchor slabs come back out of the reactor, and they go two places: the + // i-only assembler turns 8 of them into another GOP crate (more melt), and the rest are + // sold. The splitter is what makes it "and" rather than "or" — half the slabs to each, + // and if the assembler backs up, all of them go out the door instead of wedging the + // reactor. Selling them is also the only way the demo ever SHIPS a slab, which is what + // the uplink fiction upstairs is about. + b(-5, 3, S); + P('lane-splitter', -5, 4); + b(-5, 5, S); + const iOnly = P('gop-assembler', -6, 6); setRecipe(iOnly, 'assemble-gop-i-only'); - runY(5, 3, -3, N); + runY(6, 3, -3, N); // its crates rejoin the reactor + runX(-6, -7, 4, W); // the overflow: slabs for sale + P('shipper', -9, 3); return cmds; } diff --git a/fktry/src/sim/research.test.ts b/fktry/src/sim/research.test.ts new file mode 100644 index 0000000..3dbaced --- /dev/null +++ b/fktry/src/sim/research.test.ts @@ -0,0 +1,281 @@ +/** + * Research v1 and the v4 gating rule. + * + * The mechanics run on a fixture; the gating rule is checked against DATA's real tech.json. + * The fixture exists so these tests pin the RULE rather than DATA's numbers — which moved + * twice while this round was being written. For the same path proved end-to-end on the real + * tree, with a real lab and real science packs, see realtech.test.ts. + */ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import { grantResearch } from './testkit'; +import type { + Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, TechDef, +} from '../contracts'; + +import items from '../../data/items.json'; +import machines from '../../data/machines.json'; +import recipes from '../../data/recipes.json'; +import techJson from '../../data/tech.json'; +import commissions from '../../data/commissions.json'; + +const REAL = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData; + +const N: Dir = 0, E: Dir = 1; +const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' }); +const mach = (m: Partial & Pick): MachineDef => ({ + name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0, + asset: 'fixture', ...m, +}); +const rec = (r: Partial & Pick): RecipeDef => ({ + inputs: {}, ticks: 1, bandwidth: 0, ...r, +}); +const tech = (t: TechDef): TechDef => t; + +/** + * A tree that can actually be entered: the pack press is ungated, so packs exist before + * any research does. (This is exactly the property DATA's real tree is missing.) + */ +const FIXTURE: GameData = { + items: ['rock', 'pack', 'gold'].map(item), + machines: [ + mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), + mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }), + mach({ id: 'ship', kind: 'shipper' }), + mach({ id: 'packer', kind: 'crafter', recipes: ['press-pack'] }), // ungated + mach({ id: 'lab', kind: 'lab' }), // ungated + mach({ id: 'goldworks', kind: 'crafter', recipes: ['smelt-gold'] }), // GATED + mach({ id: 'other', kind: 'crafter', recipes: ['plain', 'fancy'] }), // recipe 'fancy' GATED + ], + recipes: [ + rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }), + rec({ id: 'press-pack', machine: 'packer', inputs: { rock: 1 }, outputs: { pack: 1 }, ticks: 5 }), + rec({ id: 'smelt-gold', machine: 'goldworks', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }), + rec({ id: 'plain', machine: 'other', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }), + rec({ id: 'fancy', machine: 'other', inputs: { rock: 2 }, outputs: { gold: 2 }, ticks: 5 }), + ], + tech: [ + tech({ id: 'metallurgy', era: 'reel', cost: { pack: 3 }, unlocks: ['goldworks', 'smelt-gold'] }), + tech({ id: 'flourish', era: 'reel', cost: { pack: 2 }, unlocks: ['fancy'] }), + ], + commissions: [{ id: 'c1', flavor: 'gold', wants: { gold: 99 }, rewardBandwidth: 1 }], +}; + +function newSim(data: GameData = FIXTURE, seed = 1): Sim { + const sim = createSim(); + sim.init(data, seed); + return sim; +} +const place = (def: string, x: number, y: number, dir: Dir = N): Command => + ({ kind: 'place', def, pos: { x, y }, dir }); +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); + } +} +const at = (sim: Sim, x: number, y: number) => + sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y); + +/** mine -> packer -> lab, plus somewhere for the packs to be counted. */ +function packLine(sim: Sim): void { + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('packer', 2, 0)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('lab', 4, 0)); +} + +describe('gating', () => { + it('locks every id a tech names and leaves everything else alone', () => { + const sim = newSim(); + sim.enqueue(place('goldworks', 5, 5)); // named by metallurgy.unlocks -> dropped + sim.enqueue(place('packer', 7, 5)); // named by nothing -> free + run(sim, 1); + expect(at(sim, 5, 5)).toBeUndefined(); + expect(at(sim, 7, 5)).toBeDefined(); + }); + + it('drops setRecipe and setRecipeAt for a locked recipe, silently', () => { + const sim = newSim(); + sim.enqueue(place('other', 5, 5)); + run(sim, 1); + const id = at(sim, 5, 5)!.id; + expect(at(sim, 5, 5)!.recipe).toBe('plain'); // defaults to the first UNLOCKED recipe + + sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'fancy' }); // locked + run(sim, 1); + expect(at(sim, 5, 5)!.recipe).toBe('plain'); + + sim.enqueue({ kind: 'setRecipeAt', pos: { x: 5, y: 5 }, recipe: 'fancy' }); // locked + run(sim, 1); + expect(at(sim, 5, 5)!.recipe).toBe('plain'); + }); + + it('gates nothing when the data has no tech at all', () => { + const sim = newSim({ ...FIXTURE, tech: [] }); + sim.enqueue(place('goldworks', 5, 5)); + run(sim, 1); + expect(at(sim, 5, 5)).toBeDefined(); // absence from the tree means free, not forbidden + }); +}); + +describe('research', () => { + it('runs the whole path: pick a target, feed packs, unlock, build what was refused', () => { + const sim = newSim(); + expect(sim.snapshot().research).toEqual({ active: null, progress: {}, unlocked: [] }); + + // Refused up front. + sim.enqueue(place('goldworks', 5, 5)); + run(sim, 1); + expect(at(sim, 5, 5)).toBeUndefined(); + + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); + run(sim, 1); + expect(sim.snapshot().research!.active).toBe('metallurgy'); + + const evs: SimEvent[] = []; + let done = -1; + for (let i = 0; i < 800 && done < 0; i++) { + run(sim, 1, evs); + const e = evs.find((x) => x.kind === 'researched'); + if (e) done = (e as { tick: number }).tick; + } + expect(done).toBeGreaterThan(0); + expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); + expect(evs.find((e) => e.kind === 'researched')).toMatchObject({ tech: 'metallurgy' }); + + const r = sim.snapshot().research!; + expect(r.unlocked).toEqual(['metallurgy']); + expect(r.active).toBeNull(); // completing clears the target rather than guessing a next + expect(r.progress).toEqual({}); + + // The same command that was silently dropped now works. + sim.enqueue(place('goldworks', 5, 5)); + run(sim, 1); + expect(at(sim, 5, 5)).toBeDefined(); + expect(at(sim, 5, 5)!.recipe).toBe('smelt-gold'); // its recipe unlocked with it + }); + + it('tracks progress toward the active tech and stops taking packs it no longer needs', () => { + const sim = newSim(); + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // costs 2 packs + const evs: SimEvent[] = []; + for (let i = 0; i < 400; i++) { + run(sim, 1, evs); + if (evs.some((e) => e.kind === 'researched')) break; + const p = sim.snapshot().research!.progress['pack'] ?? 0; + expect(p).toBeLessThanOrEqual(2); // never banks past the cost + } + expect(sim.snapshot().research!.unlocked).toEqual(['flourish']); + // With nothing being researched, the lab stops accepting deliveries entirely. + run(sim, 200); + expect(at(sim, 4, 0)!.inputBuf).toEqual({}); + }); + + it('stacks: two labs feed one total', () => { + const sim = newSim(); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('packer', 2, 0)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('lab', 4, 0)); // first lab on the line + sim.enqueue(place('belt', 4, 1, N)); // a second lab fed from the other side + sim.enqueue(place('lab', 4, 2)); + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); + + const evs: SimEvent[] = []; + run(sim, 800, evs); + expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); + expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); + }); + + it('abandons progress when the target changes, and refuses nonsense targets', () => { + const sim = newSim(); + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs + // Catch it mid-research: metallurgy completes on the third pack, and completing would + // zero progress for a different reason than the one under test. + let banked = 0; + for (let i = 0; i < 400 && banked === 0; i++) { + run(sim, 1); + banked = sim.snapshot().research!.progress['pack'] ?? 0; + } + expect(banked).toBeGreaterThan(0); + + sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); + run(sim, 1); + expect(sim.snapshot().research!.progress).toEqual({}); // packs are the cost of indecision + + sim.enqueue({ kind: 'setResearch', tech: 'no-such-tech' }); + run(sim, 1); + expect(sim.snapshot().research!.active).toBe('flourish'); // unchanged + + sim.enqueue({ kind: 'setResearch', tech: null }); + run(sim, 1); + expect(sim.snapshot().research!.active).toBeNull(); + }); + + it('survives save/load, and a restored save can still build what it unlocked', () => { + const a = newSim(); + packLine(a); + a.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); + const evs: SimEvent[] = []; + for (let i = 0; i < 800; i++) { + run(a, 1, evs); + if (evs.some((e) => e.kind === 'researched')) break; + } + expect(a.snapshot().research!.unlocked).toEqual(['metallurgy']); + + const b = newSim(); + b.load(a.save()); + expect(b.snapshot().research).toEqual(a.snapshot().research); + + // The future still matches: research state is part of the world, not decoration. + run(a, 500); + run(b, 500); + expect(b.snapshot()).toEqual(a.snapshot()); + + // And the unlock survived as a RULE, not just as a list — a save that restored the + // text but not the gate would silently re-lock the player's own factory. Placed in + // both so the sims stay in step. + for (const s of [a, b]) { + s.enqueue(place('goldworks', 9, 9)); + run(s, 1); + expect(at(s, 9, 9)).toBeDefined(); + } + expect(b.snapshot()).toEqual(a.snapshot()); + }); +}); + +// Derived, never hard-coded: which machines DATA gates is theirs to change, and it moved +// twice while this round was being written. The rule is mine to pin down; the list isn't. +const gatedByReal = new Set(REAL.tech.flatMap((t) => t.unlocks)); +const aGatedMachine = REAL.machines.find((m) => gatedByReal.has(m.id))!; +const aFreeMachine = REAL.machines.find( + (m) => !gatedByReal.has(m.id) && m.kind === 'crafter' && m.footprint.x <= 2, +)!; + +describe('gating against the real tech tree', () => { + it('locks the machines DATA gates and frees the ones it does not', () => { + const sim = newSim(REAL, 7); + sim.enqueue(place(aGatedMachine.id, 0, 0)); + sim.enqueue(place(aFreeMachine.id, 8, 0)); + run(sim, 1); + expect(at(sim, 0, 0), `${aGatedMachine.id} is gated and must not place`).toBeUndefined(); + expect(at(sim, 8, 0), `${aFreeMachine.id} is ungated and must place`).toBeDefined(); + }); + + it('opens the real gate when the real tech completes', () => { + const unlocker = REAL.tech.find((t) => t.unlocks.includes(aGatedMachine.id))!; + const sim = newSim(REAL, 7); + grantResearch(sim, [unlocker.id]); + sim.enqueue(place(aGatedMachine.id, 0, 0)); + run(sim, 1); + expect(at(sim, 0, 0)).toBeDefined(); + expect(sim.snapshot().research!.unlocked).toEqual([unlocker.id]); + }); +}); diff --git a/fktry/src/sim/sim.test.ts b/fktry/src/sim/sim.test.ts index 4ac0d70..5447f57 100644 --- a/fktry/src/sim/sim.test.ts +++ b/fktry/src/sim/sim.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; import { makeRng } from './rng'; +import { grantAllResearch } from './testkit'; import type { Command, Dir, EntityState, GameData, RecipeDef, Sim, SimEvent } from '../contracts'; import items from '../../data/items.json'; @@ -26,6 +27,9 @@ const recipe = (id: string): RecipeDef => { function newSim(seed = 1337): Sim { const sim = createSim(); sim.init(DATA, seed); + // v4 gating locks the mosh reactor and friends; these tests build the M1 chain, which + // is an endgame build. Placement gating gets its own tests in research.test.ts. + grantAllResearch(sim, DATA); return sim; } diff --git a/fktry/src/sim/testkit.ts b/fktry/src/sim/testkit.ts new file mode 100644 index 0000000..50f08ee --- /dev/null +++ b/fktry/src/sim/testkit.ts @@ -0,0 +1,31 @@ +/** + * Test/dev helpers for LANE-SIM. Not part of the game loop — nothing in src/main.ts, + * src/render, src/ui or src/screen imports this, and it tree-shakes out of the bundle. + * + * Why it exists: contracts v4 gates every id named by a tech's `unlocks`, and that + * includes the mosh reactor, the software decoder and the subsampler. So the reference + * factory — the endgame demo — is no longer buildable from a fresh sim by design. It + * represents a save where the research has already happened, and this is how a test says + * so honestly, through the public save/load API rather than a back door into the sim. + * + * There is currently no legitimate in-sim path to this state: DATA ships no `kind:'lab'` + * machine, and every science pack is made on the artifact-bottler, which is itself locked + * behind a tech costing 10 analog-packs. See NOTES round 4. + */ +import type { GameData, Sim } from '../contracts'; + +/** + * Rewrites a sim's research state to treat `techIds` as completed, via a save/load + * round-trip. Call it on a fresh sim before enqueueing anything — load() replaces the + * whole world, so any entities placed first would be discarded. + */ +export function grantResearch(sim: Sim, techIds: string[]): void { + const blob = JSON.parse(sim.save()); + blob.research = { active: null, progress: {}, unlocked: [...techIds] }; + sim.load(JSON.stringify(blob)); +} + +/** The late-game save: everything DATA's tech tree gates is open. */ +export function grantAllResearch(sim: Sim, data: GameData): void { + grantResearch(sim, data.tech.map((t) => t.id)); +}