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()); + }); +});