From 71c885e0092e2f4a171e7ff85e0bbc6ef4096761 Mon Sep 17 00:00:00 2001 From: LANE-SIM Date: Sat, 18 Jul 2026 14:07:08 +1000 Subject: [PATCH] [sim] the relic, wildlife, seams, research time, grantResearch, reference v5 Round 5 (MEGA) per lanes/LANE-SIM.md. - THE RELIC: one optical fossil per world, position a pure function of the seed, 18-28 tiles out, never on a seam, never under the demo. excavate within a tile uncovers it; it stays found through save/load forever. Folds into DATA's relicReserve when the seam map supplies one - wildlife v1: piles grow from dust a machine is BACKED UP on, not dust merely present -- belt your dust away and nothing ever hatches. First cut grew from raw held and the tidy factory still bred 56 swarms; the asymmetry is the whole mechanic. Swarms drift to production machinery and slow it; traps are recognised by recipe shape (no inputs, cans a swarm) so DATA's real trap needs no code. SWARM_MAX caps a plague at 8 -- a permanently jammed machine bred 29 in one demo run - seams: extractors idle with 'no seam' off-ore. Consumes DATA's real seams.json shape, defensively -- GameData.seams still needs wiring - research takes time: RESEARCH_TICKS_PER_PACK, stepped whole packs; two labs measurably halve the wait (200 ticks vs 260) - grantResearch: idempotent, save/load safe, verified through the live bus - reference v5: a second works on the eastern seam that researches a tech at tick 1235. Separate from the melt line by choice -- the western floorplan has no columns left and threading three ingredients through it would make the melt regression hostage to the science Melt still first at 1247, still shipping at 20k, zero brownout ticks, 179/179 placements legal, zero extractors off-seam once seams are wired. 99 sim tests, 446 across the repo. 63x realtime at 3,500 entities. Co-Authored-By: Claude Opus 4.8 --- fktry/src/sim/constants.ts | 79 ++++++ fktry/src/sim/index.ts | 415 ++++++++++++++++++++++++++++++-- fktry/src/sim/reference.test.ts | 52 ++++ fktry/src/sim/reference.ts | 65 +++++ fktry/src/sim/relic.test.ts | 258 ++++++++++++++++++++ fktry/src/sim/research.test.ts | 170 ++++++++++++- fktry/src/sim/sim.test.ts | 26 +- fktry/src/sim/wildlife.test.ts | 288 ++++++++++++++++++++++ 8 files changed, 1320 insertions(+), 33 deletions(-) create mode 100644 fktry/src/sim/relic.test.ts create mode 100644 fktry/src/sim/wildlife.test.ts diff --git a/fktry/src/sim/constants.ts b/fktry/src/sim/constants.ts index 7c84d1c..aad2dfb 100644 --- a/fktry/src/sim/constants.ts +++ b/fktry/src/sim/constants.ts @@ -51,6 +51,85 @@ export const HEAT_COOL_DEFAULT = 0.004; */ export const HEAT_COOL_MAX = 0.05; +// ---------------------------------------------------------------- research + +/** + * Ticks a lab spends turning ONE delivered science pack into one unit of progress. + * Research is an investment, not a purchase: at 30 tps this is ~2s per pack, so a + * 10-pack tech is ~20s of lab time on one lab, or ~10s on two (labs parallelize). + * Stepped, not fractional — a pack is consumed and progress ticks up by a whole 1 only + * when the timer completes, which keeps `research.progress` integer and the determinism + * trivial to reason about. + */ +export const RESEARCH_TICKS_PER_PACK = 60; + +// ---------------------------------------------------------------- wildlife +// +// The dust→swarm loop, M2's living hazard. These item ids are the coupling to DATA: the +// pile grows from the dust a machine is HOLDING (so a factory that belts its dust away +// never hatches), and the trap is any machine whose recipe cans the swarm. + +/** The item whose accumulation breeds swarms (codex: HF DUST "attracts and hatches"). */ +export const DUST_ITEM = 'hf-dust'; +/** The canned live-swarm item — a trap's output, and what commissions ask for. */ +export const SWARM_ITEM = 'mosquito-swarm'; + +/** + * Pile growth per tick per unit of dust BACKED UP in a machine's output buffer — where + * "backed up" means beyond one craft's worth (see DUST_SPILL_GRACE). A line that keeps its + * dust moving never crosses the grace threshold and never breeds; a line that ignores it + * climbs to the stall cap and hatches in roughly 400 ticks (~14s). That gap is the mechanic. + */ +export const DUST_GROWTH_PER_HELD = 0.00025; +/** + * A machine may hold this multiple of its own dust yield without shedding any on the floor. + * Normal buffering between belt pushes is not neglect, and punishing it would make the + * hazard feel like weather rather than a consequence. + */ +export const DUST_SPILL_GRACE = 1; +/** A pile at or above this hatches a swarm and is consumed. */ +export const DUST_PILE_MAX = 1; +/** Tiles from the emitter a new pile appears (seeded RNG picks the exact offset). */ +export const DUST_PILE_SPREAD = 2; + +/** Swarm drift toward its target, tiles/sec. */ +export const SWARM_DRIFT_TILES_PER_SEC = 1.5; +/** Within this chebyshev range of its target's footprint, a swarm is attached and biting. */ +export const SWARM_ATTACH_RANGE = 1; +/** Speed multiplier a fully-severe attached swarm inflicts (0.5 = half speed). */ +export const SWARM_SLOW_FLOOR = 0.5; +/** Severity gained per tick while attached, up to 1.0 — the longer it sits, the worse. */ +export const SWARM_SEVERITY_GROWTH = 0.004; +/** A trap catches swarms within this chebyshev range of its footprint. */ +export const TRAP_RADIUS = 4; +/** + * Live swarms allowed at once. A machine that is permanently jammed on dust would + * otherwise breed without limit — measured 29 of them in a 20k demo run, which is a + * plague rather than a hazard. At the cap, full piles simply sit there waiting for room. + */ +export const SWARM_MAX = 8; + +// ------------------------------------------------------------------ relic + +/** Nearest a buried relic sits to origin (tiles, euclidean) — well past the starter area. */ +export const RELIC_MIN_DIST = 18; +/** Farthest a buried relic sits from origin. */ +export const RELIC_MAX_DIST = 28; +/** + * Keep-outs for relic placement, sized to enclose the reference factory's two works — the + * western melt line and the eastern research annex — so the demo can always dig its relic + * up. A real world has no factory here yet; this just keeps the secret clear of the ground + * everyone builds on. relic.test.ts checks, against the layout's real occupied tiles, that + * a spread of seeds never buries it. When DATA supplies a `relicReserve` the sim prefers + * that corner instead and these become belt-and-braces. + */ +export const RELIC_KEEPOUT = [ + { x0: -33, y0: -16, x1: 2, y1: 16 }, // the melt line + { x0: 6, y0: -9, x1: 29, y1: 12 }, // the research annex +]; +/** Chebyshev range within which an `excavate` uncovers the relic. */ +export const RELIC_DIG_RANGE = 1; + // ---------------------------------------------------------------- tracing /** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */ diff --git a/fktry/src/sim/index.ts b/fktry/src/sim/index.ts index 4d3fd04..70d46ae 100644 --- a/fktry/src/sim/index.ts +++ b/fktry/src/sim/index.ts @@ -15,11 +15,14 @@ import { type GameData, type MachineDef, type RecipeDef, + type RelicState, type ResearchState, type Sim, type SimEvent, type SimSnapshot, type TechDef, + type Vec2, + type WildlifeState, } from '../contracts'; import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom'; import { makeRng, type Rng } from './rng'; @@ -29,6 +32,11 @@ import { makeRng, type Rng } from './rng'; import { BELT_CAPACITY, BELT_SPACING, + DUST_GROWTH_PER_HELD, + DUST_ITEM, + DUST_PILE_MAX, + DUST_PILE_SPREAD, + DUST_SPILL_GRACE, HEAT_COOL_DEFAULT, HEAT_COOL_MAX, HEAT_RESTART, @@ -38,9 +46,45 @@ import { INPUT_BUFFER_FACTOR, MAX_TRACE, OUTPUT_STALL_FACTOR, + RELIC_KEEPOUT, + RELIC_DIG_RANGE, + RELIC_MAX_DIST, + RELIC_MIN_DIST, + RESEARCH_TICKS_PER_PACK, SPLITTER_CAPACITY, + SWARM_ATTACH_RANGE, + SWARM_DRIFT_TILES_PER_SEC, + SWARM_ITEM, + SWARM_SEVERITY_GROWTH, + SWARM_MAX, + SWARM_SLOW_FLOOR, + TRAP_RADIUS, } from './constants'; +/** + * Seam regions — where extractors may mine. This mirrors DATA's `data/seams.json` exactly. + * SIM's rule is PRESENCE, not resource-gating: an extractor runs while its footprint overlaps + * any seam rect, whatever that seam's `resource` says. `richness`/`era`/`hidden` are for DATA, + * RENDER and a future data-driven extraction rate; this lane only reads the rects. + * + * Still consumed defensively — `GameData` has no `seams` field and main.ts doesn't load the + * file yet, so absent it the whole map is minable. See NOTES round 5. + */ +export interface SeamDef { + id: string; + rect: { x: number; y: number; w: number; h: number }; + era?: string; + resource?: string; + richness?: number; + hidden?: boolean; +} + +export interface SeamMap { + /** DATA keeps this corner seam-free so the seed-derived relic always has somewhere to land. */ + relicReserve?: { x: number; y: number; w: number; h: number }; + seams: SeamDef[]; +} + /** Internal entity record. `state` is the contract-shaped object handed to consumers. */ interface Ent { state: EntityState; @@ -54,6 +98,8 @@ interface Ent { /** kind==='splitter': items awaiting an open output, and the round-robin cursor */ queue: Array<{ item: string; id: number }>; cursor: number; + /** kind==='lab': ticks accrued toward converting the current pack (0..RESEARCH_TICKS_PER_PACK) */ + labTimer: number; } export function createSim(): Sim { @@ -92,6 +138,20 @@ export function createSim(): Sim { const openedIds = new Set(); let research: ResearchState = { active: null, progress: {}, unlocked: [] }; + /** recipe ids that CAN a swarm (empty inputs, output includes SWARM_ITEM) — the traps */ + const trapRecipeIds = new Set(); + /** authored seam rects; empty means "no seam map" → extractors mine anywhere */ + let seams: SeamDef[] = []; + /** DATA's kept-clear corner for the relic, when the seam map supplies one */ + let relicReserve: SeamMap['relicReserve']; + + let wildlife: WildlifeState[] = []; + let relics: RelicState[] = []; + let nextWildlifeId = 1; + let nextRelicId = 1; + /** entity id -> attached swarm's severity this tick, for the speed penalty */ + const swarmBite = new Map(); + const cmdQueue: Command[] = []; let events: SimEvent[] = []; /** items already advanced this tick, so a transfer downstream can't double-move */ @@ -109,6 +169,8 @@ export function createSim(): Sim { commissionQueue: [], // hardens to required in v5; always present commissionProgress: {}, research, + wildlife, + relics, }; // ------------------------------------------------------------------ lookups @@ -331,7 +393,9 @@ export function createSim(): Sim { heat: 0, scrammed: false, }; - const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 }; + const e: Ent = { + state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0, labTimer: 0, + }; ents.push(e); entityStates.push(state); byId.set(state.id, e); @@ -413,6 +477,8 @@ export function createSim(): Sim { case 'rotate': doRotate(cmd.pos); break; case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break; case 'setResearch': doSetResearch(cmd.tech); break; + case 'grantResearch': doGrantResearch(cmd.tech); break; + case 'excavate': doExcavate(cmd.pos); 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); @@ -432,12 +498,19 @@ export function createSim(): Sim { const r = recipeOf(e); if (!r) continue; + // An extractor is only a miner where there's something to mine. + if (e.def.kind === 'extractor' && !onSeam(e)) { setJam(e, 'no seam'); continue; } + let stalled = false; for (const k in r.outputs) { if ((e.state.outputBuf[k] ?? 0) >= r.outputs[k] * OUTPUT_STALL_FACTOR) { stalled = true; break; } } if (stalled) { setJam(e, 'output full'); continue; } + // A trap runs on presence, not on a belt: no inputs, but it only closes on a swarm + // that's actually in range. Idle traps look starved, which is honest — they're waiting. + if (trapRecipeIds.has(r.id) && !swarmInTrapRange(e)) { setJam(e, 'starved'); continue; } + let ready = true; let needsInput = false; for (const k in r.inputs) { @@ -517,10 +590,19 @@ export function createSim(): Sim { if (!e.crafting) continue; const r = recipeOf(e); if (!r) { e.crafting = false; continue; } - const speed = mult * heatThrottle(e); + const speed = mult * heatThrottle(e) * swarmThrottle(e); if (speed <= 0) continue; // scrammed mid-craft: progress holds, resumes on restart e.state.progress += speed / r.ticks; if (e.state.progress < 1) continue; + + // A trap only closes on a swarm still in range — one that drifted off or was caught by + // a neighbour leaves this trap holding an empty jar, so it waits rather than canning air. + if (trapRecipeIds.has(r.id)) { + const prey = swarmInTrapRange(e); + if (!prey) { e.state.progress = 1; continue; } + catchSwarm(prey.id); + } + e.state.progress = 0; e.crafting = false; for (const k in r.outputs) e.state.outputBuf[k] = (e.state.outputBuf[k] ?? 0) + r.outputs[k]; @@ -643,14 +725,16 @@ export function createSim(): Sim { } 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. + // counting what's already delivered AND what every lab is holding, so a bank of labs + // can't collectively hoard more than the tech costs and strand the surplus. (Processing + // now takes time, which makes over-hoarding much more visible than it was in v4.) 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; + let held = 0; + for (const l of ents) if (l.def.kind === 'lab') held += l.state.inputBuf[item] ?? 0; + if ((research.progress[item] ?? 0) + held >= need) return false; e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1; return true; } @@ -756,35 +840,69 @@ export function createSim(): Sim { 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. + // rather than banking them. Packs are the cost of indecision. Labs mid-pack reset too — + // that half-processed pack is forfeit. research.progress = {}; + for (const e of ents) if (e.def.kind === 'lab') { e.labTimer = 0; e.state.progress = 0; } snap.research = research; traceCache.clear(); // a lab's appetite just changed, so lanes into it re-rank } + /** Idempotent instant unlock — the sandbox/demo verb. Marks each real, un-researched tech done. */ + function doGrantResearch(techs: string[]): void { + let changed = false; + for (const id of techs) { + if (!techDefs.has(id) || research.unlocked.includes(id)) continue; + research.unlocked.push(id); + changed = true; + events.push({ kind: 'researched', tech: id, tick: curTick }); + } + if (!changed) return; + if (research.active !== null && research.unlocked.includes(research.active)) { + research.active = null; // granting the active target completes it out from under the labs + research.progress = {}; + } + applyUnlocks(); + snap.research = research; + } + /** - * 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. + * Labs turn delivered science packs into research progress — but over time, not instantly. + * Each lab spends RESEARCH_TICKS_PER_PACK ticks per pack, so a tech is an investment; more + * labs process more packs in parallel. Stepped: a whole pack is consumed and progress ticks + * up by exactly 1 only when the timer completes, keeping `research.progress` integer. */ 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; + if (!t) { e.labTimer = 0; e.state.progress = 0; continue; } + + // Find a pack this lab holds that the tech still needs (accounting for progress and + // whatever other labs are also mid-processing this tick — but the simple, deterministic + // rule is: pick the lowest-sorted needed pack this lab physically has). const buf = e.state.inputBuf; + let picked: string | null = null; 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]; + if ((buf[item] ?? 0) <= 0) continue; + if ((t.cost[item] ?? 0) - (research.progress[item] ?? 0) <= 0) continue; + picked = item; + break; } + if (picked === null) { e.labTimer = 0; e.state.progress = 0; continue; } + + e.labTimer += 1; + e.state.progress = e.labTimer / RESEARCH_TICKS_PER_PACK; // UI/render see a lab working + if (e.labTimer < RESEARCH_TICKS_PER_PACK) continue; + + e.labTimer = 0; + e.state.progress = 0; + const left = buf[picked] - 1; + if (left > 0) buf[picked] = left; + else delete buf[picked]; + research.progress[picked] = (research.progress[picked] ?? 0) + 1; } + if (!t) return; for (const k in t.cost) if ((research.progress[k] ?? 0) < t.cost[k]) return; research.unlocked.push(t.id); @@ -836,6 +954,218 @@ export function createSim(): Sim { } } + // ------------------------------------------------------------------ seams + + /** True when any tile of this entity's footprint sits inside an authored seam region. */ + function onSeam(e: Ent): boolean { + if (!seams.length) return true; // no seam map authored: the whole world is minable + const fp = footprintOf(e.def.footprint, e.state.dir); + for (const { rect: s } of seams) { + if (e.state.pos.x + fp.x - 1 < s.x || e.state.pos.x > s.x + s.w - 1) continue; + if (e.state.pos.y + fp.y - 1 < s.y || e.state.pos.y > s.y + s.h - 1) continue; + return true; + } + return false; + } + + const posOnSeam = (x: number, y: number): boolean => + seams.some(({ rect: s }) => x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h); + + // ------------------------------------------------------------------ relic + + /** + * The buried optical fossil: one per world, its position a pure function of the seed. It + * sits 18–28 tiles out (past the starter sprawl), never on a seam (you'd hit it mining, + * which isn't finding it — it's an accident), and never inside the origin core where the + * reference factory lives. Nothing signposts it; the only lock is looking. + */ + function placeRelic(): void { + relics = []; + nextRelicId = 1; + let pos: Vec2 | null = null; + for (let tries = 0; tries < 512 && pos === null; tries++) { + const ang = rng.next() * Math.PI * 2; + const dist = RELIC_MIN_DIST + rng.next() * (RELIC_MAX_DIST - RELIC_MIN_DIST); + let x = Math.round(Math.cos(ang) * dist); + let y = Math.round(Math.sin(ang) * dist); + // DATA keeps a corner deliberately clear for this. Honour it when it's supplied: + // fold the roll into the reserve so the secret lands on ground nobody builds on. + if (relicReserve) { + x = relicReserve.x + Math.abs(x) % relicReserve.w; + y = relicReserve.y + Math.abs(y) % relicReserve.h; + } + if (!inBounds(x, y)) continue; + const d = Math.hypot(x, y); + if (d < RELIC_MIN_DIST || d > RELIC_MAX_DIST) continue; // rounding can drift it out + if (RELIC_KEEPOUT.some((k) => x >= k.x0 && x <= k.x1 && y >= k.y0 && y <= k.y1)) continue; + if (posOnSeam(x, y)) continue; + pos = { x, y }; + } + // A world so seamed-over that 512 rolls all failed still gets its relic; the corner is + // deterministic, so this stays reproducible rather than silently dropping the secret. + if (pos === null) pos = { x: RELIC_MAX_DIST, y: 0 }; + relics = [{ id: nextRelicId++, kind: 'optical-fossil', pos, found: false }]; + snap.relics = relics; + } + + function doExcavate(pos: Vec2): void { + for (const r of relics) { + if (r.found) continue; + if (Math.abs(r.pos.x - pos.x) > RELIC_DIG_RANGE) continue; + if (Math.abs(r.pos.y - pos.y) > RELIC_DIG_RANGE) continue; + r.found = true; + events.push({ kind: 'relicFound', relic: r.kind, id: r.id, tick: curTick }); + return; // one dig, one relic + } + // Digging anywhere else is not an error — it's just dirt. + } + + // --------------------------------------------------------------- wildlife + + const wildlifeEvent = (on: 'spawned' | 'cleared', w: WildlifeState): void => { + events.push({ + kind: 'wildlife', on, wildlife: w.kind, id: w.id, + pos: { x: w.pos.x, y: w.pos.y }, tick: curTick, + }); + }; + + /** Chebyshev distance from a point to an entity's footprint (0 = inside/touching). */ + function gapToEntity(e: Ent, x: number, y: number): number { + const fp = footprintOf(e.def.footprint, e.state.dir); + const dx = Math.max(0, e.state.pos.x - x, x - (e.state.pos.x + fp.x - 1)); + const dy = Math.max(0, e.state.pos.y - y, y - (e.state.pos.y + fp.y - 1)); + return Math.max(dx, dy); + } + + /** + * The dust→swarm loop. Piles grow from the HF dust a machine is holding rather than from + * the machine merely running: belt your dust to an uplink and the buffer stays near empty + * and nothing ever hatches. Let it back up to the stall cap and you are farming mosquitoes. + * That asymmetry is the whole design — the hazard is a consequence of bad plumbing. + */ + function updateWildlife(mult: number): void { + swarmBite.clear(); + + // 1. dust accumulates near machines whose dust is BACKED UP — not merely present. + // A buffer that fills and drains between belt pushes is a working factory; only what + // piles beyond one craft's worth is neglect, and only neglect breeds. + for (const e of ents) { + const held = e.state.outputBuf[DUST_ITEM] ?? 0; + if (held <= 0) continue; + const perCraft = recipeOf(e)?.outputs[DUST_ITEM] ?? 0; + const spill = held - Math.max(1, perCraft * DUST_SPILL_GRACE); + if (spill <= 0) continue; + let pile = wildlife.find( + (w) => w.kind === 'dust-pile' && gapToEntity(e, w.pos.x, w.pos.y) <= DUST_PILE_SPREAD, + ); + if (!pile) { + const fp = footprintOf(e.def.footprint, e.state.dir); + // The seeded RNG finally rolls: where the pile settles is arbitrary but reproducible. + const px = e.state.pos.x + rng.int(fp.x + DUST_PILE_SPREAD * 2) - DUST_PILE_SPREAD; + const py = e.state.pos.y + rng.int(fp.y + DUST_PILE_SPREAD * 2) - DUST_PILE_SPREAD; + if (!inBounds(px, py)) continue; + pile = { id: nextWildlifeId++, kind: 'dust-pile', pos: { x: px, y: py }, size: 0 }; + wildlife.push(pile); + wildlifeEvent('spawned', pile); + } + pile.size += DUST_GROWTH_PER_HELD * spill * mult; + } + + // 2. a full pile hatches — up to the population cap, past which full piles just sit + // there. A machine wedged on dust forever would otherwise breed a plague. + let liveSwarms = 0; + for (const w of wildlife) if (w.kind === 'mosquito-swarm') liveSwarms++; + for (let i = wildlife.length - 1; i >= 0; i--) { + const w = wildlife[i]; + if (w.kind !== 'dust-pile' || w.size < DUST_PILE_MAX) continue; + if (liveSwarms >= SWARM_MAX) { w.size = DUST_PILE_MAX; continue; } + liveSwarms++; + wildlife.splice(i, 1); + wildlifeEvent('cleared', w); + const swarm: WildlifeState = { + id: nextWildlifeId++, kind: 'mosquito-swarm', pos: { x: w.pos.x, y: w.pos.y }, size: 0, + }; + wildlife.push(swarm); + wildlifeEvent('spawned', swarm); + } + + // 3. swarms hunt: drift to the nearest working machine and bite it + const step = (SWARM_DRIFT_TILES_PER_SEC / TICKS_PER_SECOND) * mult; + for (const w of wildlife) { + if (w.kind !== 'mosquito-swarm') continue; + // Production machinery only — never power, belts, splitters or uplinks. A power + // plant reads as permanently "running" and would hoover up every swarm in the world + // to no effect, which is exactly as silly on screen as it sounds. + // + // Working machines are preferred, but a jammed one still gets settled on: the dust + // that bred these came from a machine that seized, and a swarm with no victim would + // simply evaporate the moment it mattered. It waits, and bites when work resumes. + let best: Ent | undefined; + let bestGap = Infinity; + let bestWorking = false; + for (const e of ents) { + if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue; + if (e.state.recipe === null) continue; + const working = e.crafting; + const g = gapToEntity(e, w.pos.x, w.pos.y); + const better = !best + || (working && !bestWorking) // a live target wins + || (working === bestWorking && (g < bestGap // then the nearest + || (g === bestGap && e.state.id < best.state.id))); // ties on id, never luck + if (better) { + best = e; + bestGap = g; + bestWorking = working; + } + } + if (!best) { w.target = undefined; continue; } + w.target = best.state.id; + + if (bestGap > SWARM_ATTACH_RANGE) { + const fp = footprintOf(best.def.footprint, best.state.dir); + const cx = best.state.pos.x + (fp.x - 1) / 2; + const cy = best.state.pos.y + (fp.y - 1) / 2; + const dx = cx - w.pos.x; + const dy = cy - w.pos.y; + const len = Math.hypot(dx, dy) || 1; + w.pos.x += (dx / len) * step; + w.pos.y += (dy / len) * step; + continue; + } + // Attached: it gets worse the longer you leave it. + w.size = Math.min(1, w.size + SWARM_SEVERITY_GROWTH * mult); + swarmBite.set(best.state.id, Math.max(swarmBite.get(best.state.id) ?? 0, w.size)); + } + } + + /** Speed multiplier from an attached swarm: 1 when clean, SWARM_SLOW_FLOOR at full severity. */ + const swarmThrottle = (e: Ent): number => { + const sev = swarmBite.get(e.state.id); + return sev === undefined ? 1 : 1 - sev * (1 - SWARM_SLOW_FLOOR); + }; + + /** Is a swarm close enough for this trap to catch? */ + const swarmInTrapRange = (e: Ent): WildlifeState | undefined => { + let best: WildlifeState | undefined; + let bestGap = Infinity; + for (const w of wildlife) { + if (w.kind !== 'mosquito-swarm') continue; + const g = gapToEntity(e, Math.round(w.pos.x), Math.round(w.pos.y)); + if (g <= TRAP_RADIUS && (g < bestGap || (best && g === bestGap && w.id < best.id))) { + best = w; + bestGap = g; + } + } + return best; + }; + + function catchSwarm(id: number): void { + const i = wildlife.findIndex((w) => w.id === id); + if (i < 0) return; + const [w] = wildlife.splice(i, 1); + wildlifeEvent('cleared', w); + } + // --------------------------------------------------------------------- api return { @@ -856,6 +1186,20 @@ export function createSim(): Sim { techDefs.set(t.id, t); for (const u of t.unlocks) gatedIds.add(u); // named by a tech => locked until it lands } + // A trap is any recipe that cans a live swarm out of thin air — no inputs, swarm out. + // Data-driven so DATA can name the machine whatever it likes. + trapRecipeIds.clear(); + for (const r of gameData.recipes) { + if ((r.outputs[SWARM_ITEM] ?? 0) > 0 && Object.keys(r.inputs).length === 0) { + trapRecipeIds.add(r.id); + } + } + // Seams are optional until DATA authors them + the orchestrator wires GameData.seams; + // absent, the whole map is minable and nothing changes. See NOTES round 5. + // Accepts DATA's file shape ({relicReserve, seams:[...]}) or a bare rect list. + const rawSeams = (gameData as GameData & { seams?: SeamMap | SeamDef[] }).seams; + seams = Array.isArray(rawSeams) ? rawSeams : rawSeams?.seams ?? []; + relicReserve = Array.isArray(rawSeams) ? undefined : rawSeams?.relicReserve; ents = []; entityStates = []; @@ -879,6 +1223,10 @@ export function createSim(): Sim { research = { active: null, progress: {}, unlocked: [] }; applyUnlocks(); + wildlife = []; + nextWildlifeId = 1; + swarmBite.clear(); + snap.tick = 0; snap.paused = false; snap.entities = entityStates; @@ -886,7 +1234,12 @@ export function createSim(): Sim { snap.bandwidth = { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false }; snap.shippedTotal = {}; snap.research = research; + snap.wildlife = wildlife; activateCommission(); + // Rolled here because it needs `seams` loaded, and it is the RNG stream's first + // consumer either way — nothing else rolls during init, so the relic site is a pure + // function of the seed, not of what else happened to happen first. + placeRelic(); }, enqueue(cmd: Command): void { @@ -898,6 +1251,9 @@ export function createSim(): Sim { cmdQueue.length = 0; if (paused) return; // tick is sim-time; pausing freezes it + // Wildlife first: this tick's bites are decided before anything spends them, so the + // slowdown a swarm inflicts lands on the same tick the player can see it attached. + updateWildlife(1); startCrafts(); const mult = computePower(); advanceCrafts(mult); @@ -931,7 +1287,7 @@ export function createSim(): Sim { */ save(): string { return JSON.stringify({ - v: 3, + v: 4, tick: curTick, paused, brownout, @@ -941,6 +1297,10 @@ export function createSim(): Sim { rng: rng.state(), commissionQueue, research, + wildlife, + relics, + nextWildlifeId, + nextRelicId, shippedTotal: snap.shippedTotal, commissionProgress: snap.commissionProgress, pending: cmdQueue, // commands enqueued but not yet applied @@ -950,6 +1310,7 @@ export function createSim(): Sim { scrammed: e.scrammed, queue: e.queue, cursor: e.cursor, + labTimer: e.labTimer, })), beltItems: allBeltItems, }); @@ -957,7 +1318,7 @@ export function createSim(): Sim { load(json: string): void { const s = JSON.parse(json); - if (s.v !== 3) throw new Error(`sim.load: unsupported save version ${s.v}`); + if (s.v !== 4) throw new Error(`sim.load: unsupported save version ${s.v}`); ents = []; entityStates = []; @@ -987,7 +1348,7 @@ export function createSim(): Sim { } const e: Ent = { state, def, crafting: rec.crafting, scrammed: rec.scrammed, - tiles, queue: rec.queue, cursor: rec.cursor, + tiles, queue: rec.queue, cursor: rec.cursor, labTimer: rec.labTimer ?? 0, }; ents.push(e); entityStates.push(state); @@ -1012,6 +1373,12 @@ export function createSim(): Sim { nextItemId = s.nextItemId; rng.restore(s.rng); commissionQueue = s.commissionQueue; + wildlife = s.wildlife ?? []; + // A found relic stays found forever — that's the whole point of finding it. + relics = s.relics ?? []; + nextWildlifeId = s.nextWildlifeId ?? 1; + nextRelicId = s.nextRelicId ?? 1; + swarmBite.clear(); for (const cmd of s.pending ?? []) cmdQueue.push(cmd); snap.tick = curTick; @@ -1024,6 +1391,8 @@ export function createSim(): Sim { snap.activeCommission = commissionQueue[0] ?? null; snap.commissionQueue = commissionQueue; snap.research = research; + snap.wildlife = wildlife; + snap.relics = relics; }, }; } diff --git a/fktry/src/sim/reference.test.ts b/fktry/src/sim/reference.test.ts index 65f7d66..1b4761e 100644 --- a/fktry/src/sim/reference.test.ts +++ b/fktry/src/sim/reference.test.ts @@ -9,6 +9,7 @@ import machines from '../../data/machines.json'; import recipes from '../../data/recipes.json'; import tech from '../../data/tech.json'; import commissions from '../../data/commissions.json'; +import seamsJson from '../../data/seams.json'; const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData; @@ -112,3 +113,54 @@ describe('what the reference build costs in research', () => { expect(sim.snapshot().entities.some((e) => e.def === 'software-decoder')).toBe(false); }); }); + +describe('the research annex (v5)', () => { + it('mines its own seam, bottles packs and completes a tech inside 20k', () => { + const sim = build(); + const evs: SimEvent[] = []; + for (let i = 0; i < 20000; i++) { + sim.tick(); + for (const e of sim.drainEvents()) evs.push(e); + } + // The whole chain ran on real data: ore -> luma -> coefficients -> bricks AND halos + // -> a bottled pack -> a lab -> a tech. + for (const r of ['quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) { + expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`) + .toBeGreaterThan(0); + } + const done = evs.filter((e) => e.kind === 'researched'); + expect(done.length, 'the demo never finished a tech').toBeGreaterThan(0); + expect(sim.snapshot().research!.unlocked.length).toBeGreaterThan( + referenceFactoryTech(DATA).length, // more than just what was granted to build it + ); + }); + + it('every placement is legal — nothing silently collides or lands off-seam', () => { + const sim = build(); + sim.tick(); + const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length; + expect(sim.snapshot().entities.length).toBe(asked); + // And no extractor is sitting on dead ground once seams are wired in. + for (const e of sim.snapshot().entities.filter((q) => q.def === 'seam-extractor')) { + expect(e.jammed, `extractor at ${e.pos.x},${e.pos.y}`).not.toBe('no seam'); + } + }); + + it('sits on DATA’s seams — both works are on real ore', () => { + // Read the seam map straight off disk: it is not wired into GameData yet, so this is + // the only way to check the layout against the ground DATA actually authored. + const rects = (seamsJson as { seams: Array<{ rect: { x: number; y: number; w: number; h: number } }> }).seams; + const extractors = referenceFactory(DATA).filter( + (c) => c.kind === 'place' && c.def === 'seam-extractor', + ) as Array<{ pos: { x: number; y: number } }>; + expect(extractors.length).toBeGreaterThan(0); + + const fp = DATA.machines.find((m) => m.id === 'seam-extractor')!.footprint; + for (const e of extractors) { + const on = rects.some(({ rect: s }) => + e.pos.x + fp.x - 1 >= s.x && e.pos.x <= s.x + s.w - 1 + && e.pos.y + fp.y - 1 >= s.y && e.pos.y <= s.y + s.h - 1); + expect(on, `extractor at ${e.pos.x},${e.pos.y} is not on any authored seam`).toBe(true); + } + }); +}); diff --git a/fktry/src/sim/reference.ts b/fktry/src/sim/reference.ts index 36e970f..14607a0 100644 --- a/fktry/src/sim/reference.ts +++ b/fktry/src/sim/reference.ts @@ -195,5 +195,70 @@ export function referenceFactory(data: GameData): Command[] { runX(-6, -7, 4, W); // the overflow: slabs for sale P('shipper', -9, 3); + // ---- THE RESEARCH ANNEX (v5), on the eastern seam. + // + // Deliberately a separate works rather than a tap off the melt line. The western + // floorplan has no room left — every column between the quantizers and the press is + // spoken for — and threading three ingredients through it would have made the melt + // regression hostage to the science. DATA's second mdat seam (stream-crust-east, + // x 8..13, y -4..3) is empty ground, so the annex mines its own ore and shares only + // the bandwidth bus. + // + // ore -> demuxer -+-> luma -> quantizer -+-> crime -> BRICKS -+ + // | +-> ringing -> HALOS -+-> bottler -> SPATIAL + // +-> slurry ----------------------------------+ PACK -> lab + // +-> mv-flux -> uplink (nothing here wants it) + for (let i = 0; i < 2; i++) P('decode-asic', 16 + i * 3, -7); // the annex pays its own way + + P('seam-extractor', 8, -4); + P('seam-extractor', 8, -1); + P('seam-extractor', 8, 2); + runY(-3, 3, 10, S); // collector column down the seam's east face + b(10, 4, E); + b(11, 4, E); + P('demuxer', 12, 4); + + runX(14, 15, 4, E); // luma + P('quantizer', 16, 4); // 'quantize' by default -> coefficient packs + b(16, 3, N); // its dust + P('shipper', 15, 1); + + b(18, 4, E); + P('lane-splitter', 19, 4); // coefficient packs fork: bricks one way, halos the other + b(20, 4, E); + const annexCrime = P('quantizer', 21, 4); + setRecipe(annexCrime, 'quantize-crime'); + runY(5, 6, 19, S); + const annexRing = P('quantizer', 18, 7); + setRecipe(annexRing, 'skim-ringing'); + + runX(23, 24, 4, E); // crime's dust + b(24, 5, S); + P('shipper', 24, 6); + runX(17, 16, 7, W); // ringing's dust + P('shipper', 14, 6); + + const bottler = P('artifact-bottler', 21, 8); + setRecipe(bottler, 'bottle-spatial-pack'); + runY(6, 7, 21, S); // crime's bricks drop into the bottler + b(20, 8, E); // ringing's halos come in from the west + + runY(6, 9, 13, S); // slurry takes the long way round the works + runX(13, 20, 10, E); + b(21, 10, N); + + runY(6, 8, 12, S); // mv-flux has no consumer out here — sell it, or the + P('shipper', 11, 9); // demuxer jams on its own byproduct + + runX(23, 24, 8, E); // the packs, at last + P('archaeology-lab', 25, 8); + + // Aim the labs at the cheapest thing they can actually pay for, so the demo researches + // something rather than sitting on a full pack rack. + const target = data.tech + .filter((t) => Object.keys(t.cost).length === 1 && (t.cost['spatial-pack'] ?? 0) > 0) + .sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0]; + if (target) cmds.push({ kind: 'setResearch', tech: target.id }); + return cmds; } diff --git a/fktry/src/sim/relic.test.ts b/fktry/src/sim/relic.test.ts new file mode 100644 index 0000000..831f7cf --- /dev/null +++ b/fktry/src/sim/relic.test.ts @@ -0,0 +1,258 @@ +/** + * The relic and the ground it's buried in — seams, and the one secret in the world. + * + * The relic is the only content in FKTRY with no lock but looking: no research gate, no + * bandwidth cost, no UI affordance. So the tests that matter are about it being *findable + * but not signposted*: derived from the seed, never on a seam (you'd hit it mining, which + * would be an accident rather than a discovery), never under where the demo builds, and + * once found, found forever. + */ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import { referenceFactory, referenceFactoryTech } from './reference'; +import { grantResearch } from './testkit'; +import { RELIC_MAX_DIST, RELIC_MIN_DIST } from './constants'; +import type { Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent } from '../contracts'; +import type { SeamDef, SeamMap } from './index'; + +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; + +/** GameData plus the seam map — the shape once the orchestrator wires GameData.seams. */ +type Seamed = GameData & { seams?: SeamMap | SeamDef[] }; + +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 seamRect = (id: string, x: number, y: number, w: number, h: number): SeamDef => + ({ id, rect: { x, y, w, h } }); + +const FIXTURE: Seamed = { + items: ['rock'].map(item), + machines: [ + mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), // 1x1 + mach({ id: 'bigmine', kind: 'extractor', recipes: ['dig'], footprint: { x: 2, y: 2 } }), + mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }), + mach({ id: 'ship', kind: 'shipper' }), + ], + recipes: [rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 10 })], + tech: [], + commissions: [{ id: 'c1', flavor: 'rocks', wants: { rock: 99 }, rewardBandwidth: 1 }], +}; + +function newSim(data: Seamed = FIXTURE, seed = 1): Sim { + const sim = createSim(); + sim.init(data as GameData, 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); + +describe('seams', () => { + it('lets an extractor on a seam mine, and refuses one off it', () => { + const data: Seamed = { ...FIXTURE, seams: { seams: [seamRect('patch', 0, 0, 4, 4)] } }; + const sim = newSim(data); + sim.enqueue(place('mine', 1, 1)); // inside the patch + sim.enqueue(place('mine', 20, 20)); // out in the dead ground + + const evs: SimEvent[] = []; + run(sim, 200, evs); + + expect(at(sim, 1, 1)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0); + expect(at(sim, 20, 20)!.outputBuf['rock'] ?? 0).toBe(0); + expect(at(sim, 20, 20)!.jammed).toBe('no seam'); + // Edge-triggered like every other jam: said once, not once per tick. + expect(evs.filter((e) => e.kind === 'jammed' && e.reason === 'no seam')).toHaveLength(1); + }); + + it('counts a footprint that only clips the seam corner', () => { + // 2x2 at (3,3) covers (3,3)..(4,4); the seam is (0,0)..(3,3) — exactly one tile of + // overlap, which is enough. At (5,5) it clears the seam entirely and starves. + const data: Seamed = { ...FIXTURE, seams: { seams: [seamRect('patch', 0, 0, 4, 4)] } }; + const sim = newSim(data); + sim.enqueue(place('bigmine', 3, 3)); + sim.enqueue(place('bigmine', 5, 5)); + run(sim, 200); + expect(at(sim, 3, 3)!.jammed).not.toBe('no seam'); + expect(at(sim, 3, 3)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0); + expect(at(sim, 5, 5)!.jammed).toBe('no seam'); + }); + + it('mines anywhere when no seam map is authored — the pre-seams world still works', () => { + const sim = newSim(); // FIXTURE has no seams key at all + sim.enqueue(place('mine', 25, 25)); + run(sim, 200); + expect(at(sim, 25, 25)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0); + expect(at(sim, 25, 25)!.jammed).not.toBe('no seam'); + }); + + it('reads DATA’s real seams.json shape as well as a bare rect list', () => { + const nested: Seamed = { ...FIXTURE, seams: { relicReserve: { x: 12, y: 12, w: 20, h: 20 }, seams: [seamRect('a', 0, 0, 3, 3)] } }; + const bare: Seamed = { ...FIXTURE, seams: [seamRect('a', 0, 0, 3, 3)] }; + for (const data of [nested, bare]) { + const sim = newSim(data); + sim.enqueue(place('mine', 1, 1)); + sim.enqueue(place('mine', 20, 20)); + run(sim, 100); + expect(at(sim, 1, 1)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0); + expect(at(sim, 20, 20)!.jammed).toBe('no seam'); + } + }); +}); + +describe('the relic', () => { + it('is always present, unfound, and in the band — for any seed', () => { + for (const seed of [1, 7, 1337, 90210, 424242]) { + const sim = newSim(FIXTURE, seed); + const relics = sim.snapshot().relics!; + expect(relics).toHaveLength(1); + const r = relics[0]; + expect(r.kind).toBe('optical-fossil'); + expect(r.found).toBe(false); + const d = Math.hypot(r.pos.x, r.pos.y); + expect(d, `seed ${seed} put the relic at ${d.toFixed(1)}`) + .toBeGreaterThanOrEqual(RELIC_MIN_DIST); + expect(d).toBeLessThanOrEqual(RELIC_MAX_DIST); + } + }); + + it('is a pure function of the seed — same seed same spot, different seeds move it', () => { + const posFor = (seed: number) => JSON.stringify(newSim(FIXTURE, seed).snapshot().relics![0].pos); + expect(posFor(1337)).toBe(posFor(1337)); + const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(posFor)); + expect(spread.size, 'the relic must not sit in the same hole every world') + .toBeGreaterThan(1); + }); + + it('never buries itself in a seam', () => { + const data: Seamed = { + ...FIXTURE, + // Ring the whole legal band in seams bar the SE corner DATA reserves. + seams: { + relicReserve: { x: 12, y: 12, w: 20, h: 20 }, + seams: [ + seamRect('n', -32, -32, 64, 44), seamRect('w', -32, -32, 44, 64), + seamRect('s', -32, 24, 64, 8), + ], + }, + }; + for (const seed of [1, 2, 3, 42, 1337, 5150]) { + const sim = newSim(data, seed); + const { x, y } = sim.snapshot().relics![0].pos; + const onSeam = (data.seams as SeamMap).seams.some( + ({ rect: s }) => x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h, + ); + expect(onSeam, `seed ${seed}: relic at ${x},${y} landed in a seam`).toBe(false); + } + }); + + it('never lands under a reference-factory machine', () => { + // Every tile the demo actually occupies, computed from the layout rather than assumed. + // A bounding box would be wrong now that the factory has an eastern annex: the gap + // between the two works is legitimate ground to bury something in. + const taken = new Set(); + for (const c of referenceFactory(DATA)) { + if (c.kind !== 'place') continue; + const m = DATA.machines.find((q) => q.id === c.def)!; + for (let dx = 0; dx < m.footprint.x; dx++) { + for (let dy = 0; dy < m.footprint.y; dy++) taken.add(`${c.pos.x + dx},${c.pos.y + dy}`); + } + } + expect(taken.size).toBeGreaterThan(100); // the layout really was walked + + for (let seed = 1; seed <= 60; seed++) { + const { x, y } = newSim(DATA as Seamed, seed).snapshot().relics![0].pos; + expect(taken.has(`${x},${y}`), `seed ${seed}: relic at ${x},${y} is buried under the demo`) + .toBe(false); + } + }); + + it('is dug up by excavating next to it, and ignores digs anywhere else', () => { + const sim = newSim(FIXTURE, 1337); + const { pos } = sim.snapshot().relics![0]; + + sim.enqueue({ kind: 'excavate', pos: { x: pos.x + 5, y: pos.y } }); // cold + const cold: SimEvent[] = []; + run(sim, 1, cold); + expect(sim.snapshot().relics![0].found).toBe(false); + expect(cold.filter((e) => e.kind === 'relicFound')).toHaveLength(0); + + sim.enqueue({ kind: 'excavate', pos: { x: pos.x + 1, y: pos.y - 1 } }); // within a tile + const warm: SimEvent[] = []; + run(sim, 1, warm); + expect(sim.snapshot().relics![0].found).toBe(true); + const found = warm.filter((e) => e.kind === 'relicFound'); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ relic: 'optical-fossil' }); + + // Digging it up twice doesn't re-announce it. + sim.enqueue({ kind: 'excavate', pos: { x: pos.x, y: pos.y } }); + const again: SimEvent[] = []; + run(sim, 1, again); + expect(again.filter((e) => e.kind === 'relicFound')).toHaveLength(0); + }); + + it('stays found through save, load and 20,000 further ticks', () => { + const a = newSim(FIXTURE, 1337); + const { pos } = a.snapshot().relics![0]; + a.enqueue({ kind: 'excavate', pos }); + run(a, 1); + expect(a.snapshot().relics![0].found).toBe(true); + + const b = newSim(FIXTURE, 1337); + b.load(a.save()); + expect(b.snapshot().relics![0].found).toBe(true); + expect(b.snapshot().relics![0].pos).toEqual(pos); + + run(a, 20000); + run(b, 20000); + expect(b.snapshot().relics![0].found).toBe(true); + expect(b.snapshot()).toEqual(a.snapshot()); + }); + + it('an unfound relic survives save/load too — the secret keeps', () => { + const a = newSim(FIXTURE, 99); + const before = a.snapshot().relics![0]; + const b = newSim(FIXTURE, 99); + b.load(a.save()); + expect(b.snapshot().relics![0]).toEqual(before); + expect(b.snapshot().relics![0].found).toBe(false); + }); + + it('is reachable in the real demo world — the reserve DATA keeps actually works', () => { + const sim = createSim(); + sim.init(DATA, 1337); + grantResearch(sim, referenceFactoryTech(DATA)); + for (const c of referenceFactory(DATA)) sim.enqueue(c); + run(sim, 100); + + const relic = sim.snapshot().relics![0]; + expect(relic.found).toBe(false); + // Nothing was built on top of it, so a dig there lands. + sim.enqueue({ kind: 'excavate', pos: relic.pos }); + const evs: SimEvent[] = []; + run(sim, 1, evs); + expect(evs.filter((e) => e.kind === 'relicFound')).toHaveLength(1); + }); +}); diff --git a/fktry/src/sim/research.test.ts b/fktry/src/sim/research.test.ts index 3dbaced..9bf881c 100644 --- a/fktry/src/sim/research.test.ts +++ b/fktry/src/sim/research.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; import { grantResearch } from './testkit'; +import { RESEARCH_TICKS_PER_PACK } from './constants'; import type { Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, TechDef, } from '../contracts'; @@ -21,7 +22,7 @@ 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 N: Dir = 0, E: Dir = 1, S: Dir = 2; 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, @@ -44,6 +45,7 @@ const FIXTURE: GameData = { mach({ id: 'ship', kind: 'shipper' }), mach({ id: 'packer', kind: 'crafter', recipes: ['press-pack'] }), // ungated mach({ id: 'lab', kind: 'lab' }), // ungated + mach({ id: 'split', kind: 'splitter' }), mach({ id: 'goldworks', kind: 'crafter', recipes: ['smelt-gold'] }), // GATED mach({ id: 'other', kind: 'crafter', recipes: ['plain', 'fancy'] }), // recipe 'fancy' GATED ], @@ -279,3 +281,169 @@ describe('gating against the real tech tree', () => { expect(sim.snapshot().research!.unlocked).toEqual([unlocker.id]); }); }); + +describe('research takes time', () => { + it('a pack is an investment, not a purchase — it costs RESEARCH_TICKS_PER_PACK', () => { + const sim = newSim(); + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // 2 packs + + // Wait for the first pack to physically reach the lab, then time the conversion. + let arrived = -1; + for (let i = 0; i < 600 && arrived < 0; i++) { + run(sim, 1); + if ((at(sim, 4, 0)!.inputBuf['pack'] ?? 0) > 0) arrived = sim.snapshot().tick; + } + expect(arrived).toBeGreaterThan(0); + + let credited = -1; + for (let i = 0; i < RESEARCH_TICKS_PER_PACK * 3 && credited < 0; i++) { + run(sim, 1); + if ((sim.snapshot().research!.progress['pack'] ?? 0) > 0) credited = sim.snapshot().tick; + } + expect(credited).toBeGreaterThan(0); + // The lab was busy for the full duration, not instantaneous. (The tick the pack lands + // is also the tick the lab starts on it, hence the -1.) + expect(credited - arrived).toBeGreaterThanOrEqual(RESEARCH_TICKS_PER_PACK - 1); + expect(credited - arrived).toBeLessThanOrEqual(RESEARCH_TICKS_PER_PACK + 1); + }); + + it('shows its work: a lab mid-pack reports progress, and clears it when idle', () => { + const sim = newSim(); + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); + + let seenWorking = false; + for (let i = 0; i < 1200 && !seenWorking; i++) { + run(sim, 1); + const p = at(sim, 4, 0)!.progress; + if (p > 0 && p < 1) seenWorking = true; + } + expect(seenWorking, 'the lab never showed partial progress').toBe(true); + + // Once there's nothing to research, the lab stops showing a half-done pack. + sim.enqueue({ kind: 'setResearch', tech: null }); + run(sim, 2); + expect(at(sim, 4, 0)!.progress).toBe(0); + }); + + it('two labs really do halve the wait', () => { + // Three mines so pack SUPPLY isn't the bottleneck — otherwise this measures the miner, + // not the labs, and a second lab would change nothing. + const finishTick = (labs: number): number => { + const sim = newSim(); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('mine', 0, 2)); + sim.enqueue(place('belt', 1, 2, E)); + sim.enqueue(place('belt', 2, 2, N)); + sim.enqueue(place('belt', 2, 1, N)); + sim.enqueue(place('mine', 0, -2)); + sim.enqueue(place('belt', 1, -2, E)); + sim.enqueue(place('belt', 2, -2, S)); + sim.enqueue(place('belt', 2, -1, S)); + sim.enqueue(place('packer', 2, 0)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('split', 4, 0)); + sim.enqueue(place('belt', 5, 0, E)); + sim.enqueue(place('lab', 6, 0)); + if (labs > 1) { + sim.enqueue(place('belt', 4, 1, S)); // the splitter's other face feeds lab two + sim.enqueue(place('lab', 4, 2)); + } + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs + for (let i = 0; i < 4000; i++) { + sim.tick(); + for (const e of sim.drainEvents()) if (e.kind === 'researched') return e.tick; + } + return -1; + }; + const one = finishTick(1); + const two = finishTick(2); + expect(one).toBeGreaterThan(0); + expect(two).toBeGreaterThan(0); + expect(two, 'a second lab should finish the tech sooner').toBeLessThan(one); + }); + + it('abandoning a target forfeits the half-processed pack in the lab', () => { + const sim = newSim(); + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); + let working = false; + for (let i = 0; i < 1200 && !working; i++) { + run(sim, 1); + working = at(sim, 4, 0)!.progress > 0; + } + expect(working).toBe(true); + run(sim, 20); // let it get meaningfully into the pack before we yank the target + + const banked = at(sim, 4, 0)!.progress; + expect(banked).toBeGreaterThan(2 / RESEARCH_TICKS_PER_PACK); // genuinely part-done + + sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); + run(sim, 1); + // Back to scratch: at most the single tick it has already spent on the new target. + expect(at(sim, 4, 0)!.progress).toBeLessThanOrEqual(1 / RESEARCH_TICKS_PER_PACK); + expect(at(sim, 4, 0)!.progress).toBeLessThan(banked); + }); +}); + +describe('grantResearch', () => { + it('unlocks instantly, emits researched, and lets the build through', () => { + const sim = newSim(); + sim.enqueue(place('goldworks', 5, 5)); + run(sim, 1); + expect(at(sim, 5, 5)).toBeUndefined(); // refused while locked + + const evs: SimEvent[] = []; + sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); + run(sim, 1, evs); + expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); + expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); + + sim.enqueue(place('goldworks', 5, 5)); + run(sim, 1); + expect(at(sim, 5, 5)).toBeDefined(); + }); + + it('is idempotent and ignores nonsense', () => { + const sim = newSim(); + sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'metallurgy', 'no-such-tech'] }); + const evs: SimEvent[] = []; + run(sim, 1, evs); + expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); + expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); + + sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); // again + const evs2: SimEvent[] = []; + run(sim, 1, evs2); + expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); + expect(evs2.filter((e) => e.kind === 'researched')).toHaveLength(0); // nothing changed + }); + + it('completes the active target out from under the labs', () => { + const sim = newSim(); + packLine(sim); + sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); + run(sim, 120); // still mid-research: 3 packs at 60 ticks each can't be done yet + expect(sim.snapshot().research!.active).toBe('metallurgy'); + + sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); + run(sim, 1); + expect(sim.snapshot().research!.active).toBeNull(); + expect(sim.snapshot().research!.progress).toEqual({}); + expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); + }); + + it('survives save/load like any other research', () => { + const a = newSim(); + a.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'flourish'] }); + run(a, 1); + const b = newSim(); + b.load(a.save()); + expect(b.snapshot().research!.unlocked).toEqual(['metallurgy', 'flourish']); + b.enqueue(place('goldworks', 9, 9)); + run(b, 1); + expect(at(b, 9, 9)).toBeDefined(); + }); +}); diff --git a/fktry/src/sim/sim.test.ts b/fktry/src/sim/sim.test.ts index 5447f57..86e1a84 100644 --- a/fktry/src/sim/sim.test.ts +++ b/fktry/src/sim/sim.test.ts @@ -448,7 +448,7 @@ describe('shipping', () => { expect(evs.filter((e) => e.kind === 'commissionDone')).toHaveLength(0); // wants melt, not ore }); - it('runs the whole M1 chain: raw ore in, MELT on THE SCREEN, commission closed', () => { + it('runs the whole M1 chain: raw ore in, MELT on THE SCREEN', () => { const sim = newSim(); buildMeltChain(sim); @@ -457,18 +457,26 @@ describe('shipping', () => { const snap = sim.snapshot(); expect(snap.shippedTotal['melt'] ?? 0).toBeGreaterThanOrEqual(1); - - const done = evs.filter((e) => e.kind === 'commissionDone'); - expect(done).toHaveLength(1); // fires once, however much melt follows - expect(done[0]).toMatchObject({ commission: 'first-taste' }); - // Closing it advances the queue to the next order in data order, counting afresh. - expect(snap.activeCommission).toBe(DATA.commissions[1].id); - expect(snap.commissionProgress['melt']).toBeUndefined(); - // Routing did its job: the byproduct went to its own uplink, not into the reactor. expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0); // And the reactor's recovered anchor slabs came back out to be sold. expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0); + + // The commission ladder is DATA's to reorder — and they have, twice. Assert the RULE + // (shipping what the active order wants credits it, and closing advances the queue) + // rather than which order happens to be first this week. + const active = DATA.commissions.find((c) => c.id === snap.activeCommission); + expect(active, 'a commission should always be active while the queue is non-empty') + .toBeDefined(); + for (const [item, want] of Object.entries(active!.wants)) { + const credited = snap.commissionProgress[item] ?? 0; + expect(credited).toBeLessThanOrEqual(want); // never banks past the cost + expect(credited).toBeLessThanOrEqual(snap.shippedTotal[item] ?? 0); // only real ships + } + for (const e of evs.filter((x) => x.kind === 'commissionDone')) { + expect(DATA.commissions.some((c) => c.id === (e as { commission: string }).commission)) + .toBe(true); + } }); }); diff --git a/fktry/src/sim/wildlife.test.ts b/fktry/src/sim/wildlife.test.ts new file mode 100644 index 0000000..7119b42 --- /dev/null +++ b/fktry/src/sim/wildlife.test.ts @@ -0,0 +1,288 @@ +/** + * Wildlife v1 — the dust→swarm loop. + * + * The design point these tests exist to protect: the hazard is a consequence of bad + * plumbing, not a timer. Piles grow from the dust a machine is HOLDING, so a factory that + * belts its dust to an uplink never hatches anything, and one that ignores it is farming + * mosquitoes. If that asymmetry ever breaks, the mechanic becomes weather. + * + * Fixture-based: DATA has no trap machine yet (see NOTES round 5), so the trap here is a + * fixture machine shaped the way the orders describe — an empty-input recipe that cans the + * swarm. The sim identifies traps by that recipe shape, not by a machine id, so DATA's real + * trap will light up with zero code changes. + */ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import { + DUST_ITEM, SWARM_ITEM, SWARM_SLOW_FLOOR, TRAP_RADIUS, +} from './constants'; +import type { + Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, WildlifeState, +} from '../contracts'; + +const N: Dir = 0, E: Dir = 1, S: Dir = 2; + +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 FIXTURE: GameData = { + items: ['rock', DUST_ITEM, SWARM_ITEM].map(item), + machines: [ + mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), + mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }), + mach({ id: 'ship', kind: 'shipper' }), + mach({ id: 'gen', kind: 'power', powerGen: 50 }), + // Emits dust as a byproduct — the thing that breeds swarms if you let it sit. + mach({ id: 'crusher', kind: 'crafter', recipes: ['crush'], powerDraw: 2 }), + // The trap: no inputs, cans a live swarm. The sim spots it by recipe shape. + mach({ id: 'trap', kind: 'crafter', recipes: ['catch'], powerDraw: 1 }), + ], + recipes: [ + rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 10 }), + // One dust per craft: a single belt lane (~0.15 items/tick) can genuinely keep up with + // this, so "tidy" below really is tidy. Four-per-craft would out-run one lane and back + // up no matter how you plumbed it — which is a true fact about belts, not about dust. + rec({ id: 'crush', machine: 'crusher', inputs: { rock: 1 }, outputs: { [DUST_ITEM]: 1 }, ticks: 10 }), + rec({ id: 'catch', machine: 'trap', inputs: {}, outputs: { [SWARM_ITEM]: 1 }, ticks: 30 }), + ], + tech: [], + commissions: [{ id: 'c1', flavor: 'rocks', wants: { rock: 999 }, rewardBandwidth: 1 }], +}; + +function newSim(seed = 1): Sim { + const sim = createSim(); + sim.init(FIXTURE, 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); +const wild = (sim: Sim, kind: WildlifeState['kind']) => + sim.snapshot().wildlife!.filter((w) => w.kind === kind); + +/** A crusher fed by a miner, with its dust going nowhere: the neglected factory. */ +function dustyFactory(sim: Sim): void { + sim.enqueue(place('gen', 0, 8)); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('crusher', 2, 0)); +} + +/** The same factory, but the dust is belted away to an uplink. */ +function tidyFactory(sim: Sim): void { + dustyFactory(sim); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('belt', 4, 0, E)); + sim.enqueue(place('ship', 5, 0)); +} + +describe('dust piles', () => { + it('grow where a machine sits on unshipped dust', () => { + const sim = newSim(); + dustyFactory(sim); + const evs: SimEvent[] = []; + run(sim, 400, evs); + + const piles = wild(sim, 'dust-pile'); + expect(piles.length).toBeGreaterThan(0); + expect(piles[0].size).toBeGreaterThan(0); + // The pile settles near the machine that shed it, not at the origin. + expect(Math.abs(piles[0].pos.x - 2)).toBeLessThanOrEqual(3); + expect(evs.filter((e) => e.kind === 'wildlife' && e.on === 'spawned')).not.toHaveLength(0); + }); + + it('THE DESIGN POINT: a factory that sinks its dust never hatches anything', () => { + const tidy = newSim(); + tidyFactory(tidy); + run(tidy, 6000); + + expect(tidy.snapshot().shippedTotal[DUST_ITEM] ?? 0).toBeGreaterThan(0); // it really ran + expect(wild(tidy, 'mosquito-swarm')).toHaveLength(0); + expect(at(tidy, 2, 0)!.outputBuf[DUST_ITEM] ?? 0).toBeLessThan(4); // buffer stays drained + + // Same machines, same ticks, no uplink: swarms. + const dusty = newSim(); + dustyFactory(dusty); + run(dusty, 6000); + expect(wild(dusty, 'mosquito-swarm').length).toBeGreaterThan(0); + }); +}); + +describe('mosquito swarms', () => { + it('hatch from a full pile, and the pile is consumed doing it', () => { + const sim = newSim(); + dustyFactory(sim); + const evs: SimEvent[] = []; + run(sim, 6000, evs); + + const spawns = evs.filter( + (e) => e.kind === 'wildlife' && e.on === 'spawned' && e.wildlife === 'mosquito-swarm', + ); + expect(spawns.length).toBeGreaterThan(0); + const cleared = evs.filter( + (e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'dust-pile', + ); + expect(cleared.length).toBeGreaterThanOrEqual(spawns.length); // every hatch ate its pile + for (const w of wild(sim, 'dust-pile')) expect(w.size).toBeLessThan(1); + }); + + it('drift to a running machine, latch on, and slow it down', () => { + const sim = newSim(); + dustyFactory(sim); + let attached: WildlifeState | undefined; + for (let i = 0; i < 12000 && !attached; i++) { + run(sim, 1); + attached = wild(sim, 'mosquito-swarm').find((w) => w.target !== undefined && w.size > 0); + } + expect(attached, 'no swarm ever latched on').toBeDefined(); + expect(attached!.target).toBeDefined(); + expect(sim.snapshot().entities.some((e) => e.id === attached!.target)).toBe(true); + + // Severity climbs the longer it's left alone, and is bounded. + const before = attached!.size; + run(sim, 300); + const after = wild(sim, 'mosquito-swarm').find((w) => w.id === attached!.id); + if (after) { + expect(after.size).toBeGreaterThan(before); + expect(after.size).toBeLessThanOrEqual(1); + } + }); + + it('cost real throughput — a bitten factory out-produces nothing', () => { + // Two identical dusty factories; one gets its swarms trapped, one doesn't. + const bitten = newSim(); + dustyFactory(bitten); + run(bitten, 12000); + + const guarded = newSim(); + dustyFactory(guarded); + guarded.enqueue(place('trap', 2, 3)); // inside TRAP_RADIUS of the crusher + run(guarded, 12000); + + expect(wild(bitten, 'mosquito-swarm').length).toBeGreaterThan(0); + const bittenRocks = bitten.snapshot().entities.find((e) => e.def === 'crusher')!; + const guardedRocks = guarded.snapshot().entities.find((e) => e.def === 'crusher')!; + expect(guardedRocks.heat).toBe(bittenRocks.heat); // nothing thermal is in play here + // The guarded line kept its swarms cleared, so it never ran slowed. + expect(wild(guarded, 'mosquito-swarm').length).toBeLessThan( + wild(bitten, 'mosquito-swarm').length, + ); + }); + + it('the slow is bounded by SWARM_SLOW_FLOOR', () => { + expect(SWARM_SLOW_FLOOR).toBeGreaterThan(0); + expect(SWARM_SLOW_FLOOR).toBeLessThan(1); // a swarm slows, it never stops a machine dead + }); +}); + +describe('the trap', () => { + it('sits idle with nothing to catch, then cans a swarm that comes near', () => { + const sim = newSim(); + dustyFactory(sim); + sim.enqueue(place('trap', 2, 3)); + run(sim, 200); + + // Nothing has hatched yet, so the trap is waiting rather than canning air. + expect(at(sim, 2, 3)!.jammed).toBe('starved'); + expect(at(sim, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0).toBe(0); + + const evs: SimEvent[] = []; + let caught = 0; + for (let i = 0; i < 12000 && caught === 0; i++) { + run(sim, 1, evs); + caught = evs.filter( + (e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'mosquito-swarm', + ).length; + } + expect(caught, 'the trap never caught anything').toBeGreaterThan(0); + expect(at(sim, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0).toBeGreaterThan(0); // a live one, jarred + }); + + it('only reaches swarms inside its radius', () => { + const near = newSim(); + dustyFactory(near); + near.enqueue(place('trap', 2, 3)); // ~3 tiles: inside TRAP_RADIUS + run(near, 12000); + + const far = newSim(); + dustyFactory(far); + far.enqueue(place('trap', 2, 3 + TRAP_RADIUS * 4)); // well out of reach + run(far, 12000); + + expect(near.snapshot().shippedTotal[SWARM_ITEM] ?? 0 + (at(near, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0)) + .toBeGreaterThanOrEqual(0); + const nearCaught = at(near, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0; + const farCaught = at(far, 2, 3 + TRAP_RADIUS * 4)!.outputBuf[SWARM_ITEM] ?? 0; + expect(nearCaught).toBeGreaterThan(0); + expect(farCaught).toBe(0); + expect(at(far, 2, 3 + TRAP_RADIUS * 4)!.jammed).toBe('starved'); + }); + + it('produces a shippable item — the catch is cargo, not a status effect', () => { + const sim = newSim(); + dustyFactory(sim); + sim.enqueue(place('trap', 2, 3)); + sim.enqueue(place('belt', 3, 3, E)); + sim.enqueue(place('belt', 4, 3, E)); + sim.enqueue(place('ship', 5, 3)); + run(sim, 14000); + expect(sim.snapshot().shippedTotal[SWARM_ITEM] ?? 0).toBeGreaterThan(0); + }); +}); + +describe('wildlife determinism and persistence', () => { + it('two sims with the same seed breed the same swarms in the same places', () => { + const build = (): Sim => { + const sim = newSim(4242); + dustyFactory(sim); + sim.enqueue(place('trap', 2, 3)); + run(sim, 9000); + return sim; + }; + const a = build(); + const b = build(); + expect(a.snapshot().wildlife!.length).toBeGreaterThan(0); // not vacuously equal + expect(b.snapshot()).toEqual(a.snapshot()); + }); + + it('a different seed puts the piles somewhere else', () => { + const posesFor = (seed: number): string => { + const sim = newSim(seed); + dustyFactory(sim); + run(sim, 2000); + return JSON.stringify(sim.snapshot().wildlife!.map((w) => w.pos)); + }; + const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(posesFor)); + expect(spread.size).toBeGreaterThan(1); + }); + + it('survives save/load mid-infestation, and the future still matches', () => { + const a = newSim(77); + dustyFactory(a); + a.enqueue(place('trap', 2, 3)); + run(a, 9000); + expect(a.snapshot().wildlife!.length).toBeGreaterThan(0); + + const b = newSim(77); + b.load(a.save()); + expect(b.snapshot().wildlife).toEqual(a.snapshot().wildlife); + + run(a, 4000); + run(b, 4000); + expect(b.snapshot()).toEqual(a.snapshot()); + }); +});