[sim] research + gating, spatial cooling, exported constants, reference v4

Round 4 per lanes/LANE-SIM.md.

- research v1: kind:'lab', setResearch, snapshot.research, 'researched'.
  Gating: any id a tech unlocks is refused until that tech lands; ids no
  tech names are free forever. Labs take only what the active tech still
  needs; several labs feed one total; research survives save/load as a
  RULE, so a restored save can still build what it unlocked
- realtech.test.ts: a cold-start research run on DATA's own tree -- ore ->
  packs -> archaeology lab -> tech -> a refused recipe now accepted. Also
  the tripwire if the tech bootstrap ever closes over again; it was
  circular and unenterable at the start of this round
- spatial cooling: coolRadius, chebyshev from the footprint, auras sum,
  total capped at HEAT_COOL_MAX (0.05 -- past that a stack buys nothing
  but still costs bandwidth and floor). Proven by measuring shed rate
- src/sim/constants.ts: 12 constants so DATA imports truth instead of
  hand-copying numbers that go stale in silence
- reference v4: anchor-slab overflow tap, so the demo ships slabs (18 per
  20k). Needed TWO melt lanes: with one, melt spills onto the slab lane
  and the splitter phase-locks with the reactor's push order -- slab to
  the assembler, melt to the uplink, forever, and no slab ever sold. It
  passed 20k ticks looking perfect. referenceFactoryTech() derives the
  techs the layout needs, from the commands
- also wired: bandwidth.capacity, EntityState.scrammed, commissionQueue

65 sim tests, 318 across the repo. Melt still tick 1248, zero brownouts
over 20k.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LANE-SIM 2026-07-17 20:20:32 +10:00
parent a48d45b4df
commit 039eff15fa
11 changed files with 899 additions and 35 deletions

View File

@ -19,6 +19,7 @@
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantAllResearch } from './testkit';
import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -33,6 +34,7 @@ const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
function newSim(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the bloom duplicator is gated behind stream-bloom-loops
return sim;
}
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {

View File

@ -0,0 +1,57 @@
/**
* LANE-SIM's tuning constants the single source of truth.
*
* These were private to index.ts until round 4. They're exported because other lanes'
* tests were hand-copying the numbers and going stale in silence: a copy of 0.7 doesn't
* fail when the sim moves to 0.65, it just quietly asserts the wrong thing forever.
* Import these rather than retyping them.
*
* Anything DATA can already set per-machine (heatPerTick, coolPerTick, bufferCap,
* beltSpeed, coolRadius) lives in data/machines.json, not here. These are the sim-wide
* rules that no machine overrides.
*/
// ------------------------------------------------------------------ belts
/** Minimum gap between two items, in belt-tiles. Enforced across the tile seam too. */
export const BELT_SPACING = 0.45;
/** Items a single belt tile may hold. */
export const BELT_CAPACITY = 2;
/** Items a splitter holds while it waits for an output to open. */
export const SPLITTER_CAPACITY = 2;
// -------------------------------------------------------------- machines
/** A machine stalls ('output full') once any output piles to this multiple of its yield. */
export const OUTPUT_STALL_FACTOR = 4;
/** A machine accepts an input up to this multiple of the recipe requirement. */
export const INPUT_BUFFER_FACTOR = 2;
// ------------------------------------------------------------------ heat
//
// heatPerTick is GROSS (contracts v4): net climb = heatPerTick - coolPerTick, so a
// machine whose heatPerTick never clears its cooling can never overheat.
/** Below this heat, throttling is purely cosmetic. */
export const HEAT_THROTTLE_START = 0.7;
/** Speed multiplier at heat 1.0 — the codex's "tick rate visibly halves". */
export const HEAT_THROTTLE_FLOOR = 0.5;
/** Heat at which a machine scrams. */
export const HEAT_SCRAM = 1;
/** Cool back below this and a scrammed machine restarts. */
export const HEAT_RESTART = 0.5;
/** Per-tick cooling when a machine's def doesn't specify `coolPerTick`. */
export const HEAT_COOL_DEFAULT = 0.004;
/**
* Ceiling on a machine's total cooling once auras stack (own rate + every cooler in
* range). At 0.05 a machine sheds a full 1.0 of heat in 20 ticks (~0.67s), which is
* already effectively instant past this, extra coolers would buy nothing but would
* still cost bandwidth and floor space, and stacking them would look broken rather than
* generous. See NOTES round 4.
*/
export const HEAT_COOL_MAX = 0.05;
// ---------------------------------------------------------------- tracing
/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */
export const MAX_TRACE = 512;

View File

@ -15,35 +15,31 @@ import {
type GameData,
type MachineDef,
type RecipeDef,
type ResearchState,
type Sim,
type SimEvent,
type SimSnapshot,
type TechDef,
} from '../contracts';
import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom';
import { makeRng, type Rng } from './rng';
/** Minimum gap between two items measured in belt-tiles. */
const BELT_SPACING = 0.45;
/** Items a single belt tile may hold. */
const BELT_CAPACITY = 2;
/** Items a splitter may hold while it waits for an output to open. */
const SPLITTER_CAPACITY = 2;
/** A machine stalls once any output has piled to this multiple of the recipe yield. */
const OUTPUT_STALL_FACTOR = 4;
/** A machine accepts an input up to this multiple of the recipe requirement. */
const INPUT_BUFFER_FACTOR = 2;
/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */
const MAX_TRACE = 512;
// Heat v1. Curve is LANE-SIM's call — see NOTES round 2.
/** Below this, heat is cosmetic. */
const HEAT_THROTTLE_START = 0.7;
/** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */
const HEAT_THROTTLE_FLOOR = 0.5;
/** Heat shed per tick, always, active or not. Overridden per machine by `coolPerTick`. */
const HEAT_COOL_DEFAULT = 0.004;
/** Cool back below this and a scrammed machine comes back up. */
const HEAT_RESTART = 0.5;
// The tuning constants live in ./constants so other lanes' tests can import truth
// instead of hand-copying numbers that go stale in silence.
import {
BELT_CAPACITY,
BELT_SPACING,
HEAT_COOL_DEFAULT,
HEAT_COOL_MAX,
HEAT_RESTART,
HEAT_SCRAM,
HEAT_THROTTLE_FLOOR,
HEAT_THROTTLE_START,
INPUT_BUFFER_FACTOR,
MAX_TRACE,
OUTPUT_STALL_FACTOR,
SPLITTER_CAPACITY,
} from './constants';
/** Internal entity record. `state` is the contract-shaped object handed to consumers. */
interface Ent {
@ -67,6 +63,7 @@ export function createSim(): Sim {
const defs = new Map<string, MachineDef>();
const recipeDefs = new Map<string, RecipeDef>();
const commissionDefs = new Map<string, CommissionDef>();
const techDefs = new Map<string, TechDef>();
// ents and entityStates are parallel and always in ascending-id order — the tick
// loop walks them in that order, which is what makes the whole sim deterministic.
@ -78,6 +75,8 @@ export function createSim(): Sim {
let allBeltItems: BeltItem[] = [];
/** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */
const traceCache = new Map<number, number[]>();
/** entity id -> total cooling per tick (own rate + every aura reaching it), capped. */
const coolCache = new Map<number, number>();
let nextId = 1;
let nextItemId = 1;
@ -87,6 +86,12 @@ export function createSim(): Sim {
let stored = 0;
let commissionQueue: string[] = [];
/** every id any tech gates — locked until the tech that names it completes */
const gatedIds = new Set<string>();
/** ids the completed techs have opened up */
const openedIds = new Set<string>();
let research: ResearchState = { active: null, progress: {}, unlocked: [] };
const cmdQueue: Command[] = [];
let events: SimEvent[] = [];
/** items already advanced this tick, so a transfer downstream can't double-move */
@ -98,10 +103,12 @@ export function createSim(): Sim {
paused: false,
entities: entityStates,
beltItems: allBeltItems,
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
bandwidth: { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false },
shippedTotal: {},
activeCommission: null,
commissionQueue: [], // hardens to required in v5; always present
commissionProgress: {},
research,
};
// ------------------------------------------------------------------ lookups
@ -123,6 +130,12 @@ export function createSim(): Sim {
/** Static appetite: does this machine's recipe ever call for this item? */
const machineWants = (e: Ent, item: string): boolean => {
if (e.def.kind === 'shipper') return true;
if (e.def.kind === 'lab') {
// A lab's appetite is whatever the active tech costs — so it moves when the player
// picks a different target, and setResearch has to drop the route cache.
const t = research.active === null ? undefined : techDefs.get(research.active);
return !!t && (t.cost[item] ?? 0) > 0;
}
const r = recipeOf(e);
return !!r && (r.inputs[item] ?? 0) > 0;
};
@ -237,6 +250,49 @@ export function createSim(): Sim {
e.state.inputBuf = buf;
}
/**
* v4 gating: an id named by ANY tech's `unlocks` is locked until that tech completes.
* Ids no tech mentions are free forever absence from the tech tree means available,
* not forbidden, so a data file with no tech at all gates nothing.
*/
const isAvailable = (id: string): boolean => !gatedIds.has(id) || openedIds.has(id);
/** The first recipe a machine can legally run — its listed order is its preference. */
const defaultRecipe = (def: MachineDef): string | null =>
def.recipes.find((r) => isAvailable(r)) ?? null;
/** Chebyshev gap between two footprints, in tiles. 0 = touching or overlapping. */
function rectGap(a: Ent, b: Ent): number {
const af = footprintOf(a.def.footprint, a.state.dir);
const bf = footprintOf(b.def.footprint, b.state.dir);
const ax1 = a.state.pos.x + af.x - 1, ay1 = a.state.pos.y + af.y - 1;
const bx1 = b.state.pos.x + bf.x - 1, by1 = b.state.pos.y + bf.y - 1;
const dx = Math.max(0, a.state.pos.x - bx1, b.state.pos.x - ax1);
const dy = Math.max(0, a.state.pos.y - by1, b.state.pos.y - ay1);
return Math.max(dx, dy);
}
/**
* v4 spatial cooling: a machine sheds its own `coolPerTick`, plus the `coolPerTick` of
* every machine whose `coolRadius` aura reaches it. Auras stack by sum and the total is
* capped (HEAT_COOL_MAX) so a wall of coolers can't make heat meaningless. A cooler does
* not cool itself twice its own rate is already counted. Cached; topology changes clear it.
*/
function coolingOf(e: Ent): number {
const cached = coolCache.get(e.state.id);
if (cached !== undefined) return cached;
let total = e.def.coolPerTick ?? HEAT_COOL_DEFAULT;
for (const c of ents) {
if (c === e) continue;
const radius = c.def.coolRadius ?? 0;
if (radius <= 0 || !c.def.coolPerTick) continue;
if (rectGap(c, e) <= radius) total += c.def.coolPerTick;
}
if (total > HEAT_COOL_MAX) total = HEAT_COOL_MAX;
coolCache.set(e.state.id, total);
return total;
}
/** Scrammed and idle machines contribute nothing; hot ones work at reduced speed. */
function heatThrottle(e: Ent): number {
if (e.scrammed) return 0;
@ -249,6 +305,7 @@ export function createSim(): Sim {
// ----------------------------------------------------------------- commands
function doPlace(def: MachineDef, pos: { x: number; y: number }, dir: Dir): void {
if (!isAvailable(def.id)) return; // locked behind research: silently dropped
const fp = footprintOf(def.footprint, dir);
const tiles: number[] = [];
for (let dx = 0; dx < fp.x; dx++) {
@ -266,7 +323,7 @@ export function createSim(): Sim {
def: def.id,
pos: { x: pos.x, y: pos.y },
dir,
recipe: def.recipes.length ? def.recipes[0] : null,
recipe: defaultRecipe(def), // first recipe research has actually opened
progress: 0,
inputBuf: {},
outputBuf: {},
@ -281,6 +338,7 @@ export function createSim(): Sim {
for (const k of tiles) occ.set(k, state.id);
if (def.kind === 'belt') beltContents.set(state.id, []);
traceCache.clear();
coolCache.clear(); // auras are geometric: any move re-evaluates them
events.push({ kind: 'placed', entity: state.id, def: def.id, tick: curTick });
}
@ -299,6 +357,7 @@ export function createSim(): Sim {
beltContents.delete(id);
}
traceCache.clear();
coolCache.clear(); // auras are geometric: any move re-evaluates them
events.push({ kind: 'removed', entity: id, def: e.def.id, tick: curTick });
}
@ -324,6 +383,7 @@ export function createSim(): Sim {
e.tiles = tiles;
e.state.dir = dir;
traceCache.clear();
coolCache.clear(); // auras are geometric: any move re-evaluates them
}
function doSetRecipe(entity: number, recipe: string | null): void {
@ -334,6 +394,7 @@ export function createSim(): Sim {
function setRecipeOn(e: Ent, recipe: string | null): void {
if (recipe !== null && !e.def.recipes.includes(recipe)) return;
if (recipe !== null && !isAvailable(recipe)) return; // locked behind research
e.state.recipe = recipe;
e.state.progress = 0;
e.crafting = false;
@ -351,6 +412,7 @@ export function createSim(): Sim {
case 'remove': doRemove(cmd.pos); break;
case 'rotate': doRotate(cmd.pos); break;
case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break;
case 'setResearch': doSetResearch(cmd.tech); break;
case 'setRecipeAt': {
const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works
if (e) setRecipeOn(e, cmd.recipe);
@ -445,6 +507,7 @@ export function createSim(): Sim {
snap.bandwidth.gen = gen;
snap.bandwidth.draw = draw;
snap.bandwidth.stored = stored;
snap.bandwidth.capacity = capacity;
snap.bandwidth.brownout = on;
return on ? (draw > 0 ? supplied / draw : 1) : 1;
}
@ -480,14 +543,14 @@ export function createSim(): Sim {
// Most of a big factory is belts, and a belt can never be anything but stone cold.
// Skipping them here is the difference between 59x and 70x realtime at 3,500 entities.
if (hpt === 0 && e.state.heat === 0 && !e.scrammed) continue;
const cool = e.def.coolPerTick ?? HEAT_COOL_DEFAULT;
const cool = coolingOf(e);
const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
let h = e.state.heat + (active ? hpt : 0) - cool;
if (h < 0) h = 0;
else if (h > 1) h = 1;
e.state.heat = h;
if (!e.scrammed && h >= 1) {
if (!e.scrammed && h >= HEAT_SCRAM) {
e.scrammed = true;
e.state.scrammed = true;
setJam(e, null); // scram is reported by its own event, not as a flow jam
@ -578,6 +641,19 @@ export function createSim(): Sim {
syncSplitter(e);
return true;
}
if (e.def.kind === 'lab') {
// Only take packs the active tech still needs, and only as many as it still needs —
// a lab is not a warehouse, and hoarding packs for a tech nobody picked would strand
// them where no other lab or uplink can reach.
const t = research.active === null ? undefined : techDefs.get(research.active);
if (!t) return false;
const need = t.cost[item] ?? 0;
if (need <= 0) return false;
const pledged = (research.progress[item] ?? 0) + (e.state.inputBuf[item] ?? 0);
if (pledged >= need) return false;
e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1;
return true;
}
const r = recipeOf(e);
if (!r) return false;
const need = r.inputs[item] ?? 0;
@ -662,6 +738,62 @@ export function createSim(): Sim {
}
}
/** Rebuilds the opened-id set from the completed tech list. */
function applyUnlocks(): void {
openedIds.clear();
for (const id of research.unlocked) {
const t = techDefs.get(id);
if (t) for (const u of t.unlocks) openedIds.add(u);
}
traceCache.clear(); // what a machine will accept can change when a recipe opens up
}
function doSetResearch(tech: string | null): void {
if (tech !== null) {
if (!techDefs.has(tech)) return; // unknown
if (research.unlocked.includes(tech)) return; // already done
}
if (research.active === tech) return;
research.active = tech;
// Progress is per-active-tech: switching targets abandons the packs already delivered
// rather than banking them. Packs are the cost of indecision.
research.progress = {};
snap.research = research;
traceCache.clear(); // a lab's appetite just changed, so lanes into it re-rank
}
/**
* Labs pull science packs out of their input into the active tech's progress. There's no
* research "craft time" a pack counts the moment it lands, so several labs simply feed
* the same total faster. That's what "multiple labs stack" means here.
*/
function drainLabs(): void {
const t = research.active === null ? undefined : techDefs.get(research.active);
if (!t) return;
for (const e of ents) {
if (e.def.kind !== 'lab') continue;
const buf = e.state.inputBuf;
for (const item of Object.keys(buf).sort()) {
const need = t.cost[item] ?? 0;
if (need <= 0) continue;
const have = research.progress[item] ?? 0;
const take = Math.min(buf[item], need - have);
if (take <= 0) continue;
research.progress[item] = have + take;
const left = buf[item] - take;
if (left > 0) buf[item] = left;
else delete buf[item];
}
}
for (const k in t.cost) if ((research.progress[k] ?? 0) < t.cost[k]) return;
research.unlocked.push(t.id);
research.active = null;
research.progress = {};
applyUnlocks();
events.push({ kind: 'researched', tech: t.id, tick: curTick });
}
/** Activates the commission at the head of the queue and zeroes its progress. */
function activateCommission(): void {
const id = commissionQueue[0] ?? null;
@ -715,9 +847,15 @@ export function createSim(): Sim {
defs.clear();
recipeDefs.clear();
commissionDefs.clear();
techDefs.clear();
gatedIds.clear();
for (const m of gameData.machines) defs.set(m.id, m);
for (const r of gameData.recipes) recipeDefs.set(r.id, r);
for (const c of gameData.commissions) commissionDefs.set(c.id, c);
for (const t of gameData.tech) {
techDefs.set(t.id, t);
for (const u of t.unlocks) gatedIds.add(u); // named by a tech => locked until it lands
}
ents = [];
entityStates = [];
@ -726,6 +864,7 @@ export function createSim(): Sim {
occ.clear();
beltContents.clear();
traceCache.clear();
coolCache.clear();
cmdQueue.length = 0;
events = [];
moved.clear();
@ -737,13 +876,16 @@ export function createSim(): Sim {
brownout = false;
stored = 0;
commissionQueue = data.commissions.map((c) => c.id);
research = { active: null, progress: {}, unlocked: [] };
applyUnlocks();
snap.tick = 0;
snap.paused = false;
snap.entities = entityStates;
snap.beltItems = allBeltItems;
snap.bandwidth = { gen: 0, draw: 0, stored: 0, brownout: false };
snap.bandwidth = { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false };
snap.shippedTotal = {};
snap.research = research;
activateCommission();
},
@ -764,6 +906,7 @@ export function createSim(): Sim {
stepSplitters();
moveBelts(mult);
drainShippers();
drainLabs();
curTick++;
snap.tick = curTick;
@ -797,6 +940,7 @@ export function createSim(): Sim {
nextItemId,
rng: rng.state(),
commissionQueue,
research,
shippedTotal: snap.shippedTotal,
commissionProgress: snap.commissionProgress,
pending: cmdQueue, // commands enqueued but not yet applied
@ -822,10 +966,16 @@ export function createSim(): Sim {
occ.clear();
beltContents.clear();
traceCache.clear();
coolCache.clear();
cmdQueue.length = 0;
events = [];
moved.clear();
// Research first: gating decides what a placement is even allowed to be, and the
// entities below were placed under the saved rules, not a fresh sim's.
research = s.research ?? { active: null, progress: {}, unlocked: [] };
applyUnlocks();
for (const rec of s.entities) {
const def = defs.get(rec.state.def);
if (!def) throw new Error(`sim.load: this GameData has no machine "${rec.state.def}"`);
@ -868,11 +1018,12 @@ export function createSim(): Sim {
snap.paused = paused;
snap.entities = entityStates;
snap.beltItems = allBeltItems;
snap.bandwidth = { gen: 0, draw: 0, stored, brownout };
snap.bandwidth = { gen: 0, draw: 0, stored, capacity: 0, brownout };
snap.shippedTotal = s.shippedTotal;
snap.commissionProgress = s.commissionProgress;
snap.activeCommission = commissionQueue[0] ?? null;
snap.commissionQueue = commissionQueue;
snap.research = research;
},
};
}

View File

@ -9,6 +9,7 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory } from './reference';
import { grantAllResearch } from './testkit';
import type { GameData, Sim } from '../contracts';
import items from '../../data/items.json';
@ -22,6 +23,7 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD
function fresh(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the reference factory is a post-research build
return sim;
}
function run(sim: Sim, ticks: number): void {

View File

@ -8,6 +8,7 @@
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { HEAT_COOL_DEFAULT, HEAT_COOL_MAX } from './constants';
import type {
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
} from '../contracts';
@ -32,6 +33,9 @@ const FIXTURE: GameData = {
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
// v4 spatial cooling: radiates its coolPerTick to anything within 2 tiles.
mach({ id: 'cooler', kind: 'power', powerGen: 0, coolPerTick: 0.01, coolRadius: 2 }),
mach({ id: 'bigcooler', kind: 'power', powerGen: 0, coolPerTick: 0.04, coolRadius: 1 }),
mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }),
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
@ -269,6 +273,95 @@ describe('heat', () => {
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
});
/** Ticks until the generator at (x,y) first scrams, or -1 if it never does. */
const ticksToScram = (sim: Sim, limit = 1500): number => {
for (let i = 0; i < limit; i++) {
const evs: SimEvent[] = [];
run(sim, 1, evs);
if (evs.some((e) => e.kind === 'scram' && e.on)) return i + 1;
}
return -1;
};
describe('spatial cooling', () => {
it('a cooler in range extends a hot generators run before it scrams', () => {
const bare = newSim();
bare.enqueue(place('hotgen', 0, 0));
const alone = ticksToScram(bare);
expect(alone).toBeGreaterThan(0); // 0.02 gross - 0.004 default cooling
const cooled = newSim();
cooled.enqueue(place('hotgen', 0, 0));
cooled.enqueue(place('cooler', 2, 0)); // 2 tiles away: inside the radius
const withAura = ticksToScram(cooled);
expect(withAura).toBeGreaterThan(0);
// 0.02 - (0.004 + 0.01) = 0.006/tick instead of 0.016: roughly 2.5x the run.
expect(withAura).toBeGreaterThan(alone * 2);
});
it('the aura is bounded — a cooler out of range does nothing', () => {
const bare = newSim();
bare.enqueue(place('hotgen', 0, 0));
const alone = ticksToScram(bare);
const far = newSim();
far.enqueue(place('hotgen', 0, 0));
far.enqueue(place('cooler', 5, 0)); // radius is 2; this is 5 away
expect(ticksToScram(far)).toBe(alone);
expect(at(far, 0, 0)!.heat).toBe(at(bare, 0, 0)!.heat);
});
it('enough cooling in range stops it overheating at all', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
sim.enqueue(place('bigcooler', 1, 0)); // 0.004 + 0.04 = 0.044 > 0.02 gross
const evs: SimEvent[] = [];
run(sim, 2000, evs);
expect(evs.filter((e) => e.kind === 'scram')).toHaveLength(0);
expect(at(sim, 0, 0)!.heat).toBe(0);
expect(sim.snapshot().bandwidth.gen).toBe(10); // never throttled, never dropped
});
it('auras stack by sum, and the total is capped however many you pile on', () => {
// The oven takes 0.3 heat per bake and has no heatPerTick, so between bakes its heat
// moves by exactly -cooling. That makes the shed rate directly measurable.
const shedRate = (coolers: Array<[number, number]>): number => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('oven', 2, 0));
for (const [x, y] of coolers) sim.enqueue(place('bigcooler', x, y));
for (let i = 0; i < 400; i++) {
run(sim, 1);
if (at(sim, 2, 0)!.heat > 0.1) break; // a bake just landed
}
const before = at(sim, 2, 0)!.heat;
run(sim, 1);
return before - at(sim, 2, 0)!.heat;
};
expect(shedRate([])).toBeCloseTo(HEAT_COOL_DEFAULT, 6);
// One in range: own default + the aura, summed.
expect(shedRate([[3, 0]])).toBeCloseTo(HEAT_COOL_DEFAULT + 0.04, 6);
// Four in range would be 0.004 + 0.16 = 0.164 — the cap holds it at HEAT_COOL_MAX.
const piled = shedRate([[3, 0], [2, 1], [3, 1], [2, -1]]);
expect(piled).toBeCloseTo(HEAT_COOL_MAX, 6);
expect(HEAT_COOL_MAX).toBeLessThan(HEAT_COOL_DEFAULT + 4 * 0.04); // the cap really bites
});
it('removing a cooler puts the heat back', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
sim.enqueue(place('bigcooler', 1, 0));
run(sim, 300);
expect(at(sim, 0, 0)!.heat).toBe(0); // held cold by the aura
sim.enqueue({ kind: 'remove', pos: { x: 1, y: 0 } });
run(sim, 200);
expect(at(sim, 0, 0)!.heat).toBeGreaterThan(0); // aura gone, it heats again
});
});
it('leaves cold machines alone', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0)); // no heatPerTick

View File

@ -0,0 +1,171 @@
/**
* A real research run: raw ore in one end, a completed tech out the other, using nothing
* but DATA's own machines, recipes and tech tree. No fixture, no granted state.
*
* This exists because the tree was, earlier this round, impossible to enter: there was no
* lab, and every science pack was made on the artifact-bottler, which was itself locked
* behind a tech costing 10 analog-packs. DATA fixed both mid-round. This test is the proof
* that the fix holds, and the tripwire if the bootstrap ever closes over again.
*
* ore -> demuxer -+-> luma -> quantizer -> coefficient packs -> [split] -+-> crime -> bricks -+
* | +-> ringing -> halos -+
* +-> chroma slurry ------------------------------------------------------------+
* +-> mv-flux -> uplink |
* bricks + halos + slurry -> artifact bottler -> SPATIAL PACK
* -> archaeology lab -> tech
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import type { Command, Dir, GameData, Sim, SimEvent, TechDef } from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import techJson from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
/** The cheapest tech payable in packs the factory below can actually make. */
function cheapestSpatialTech(): TechDef | undefined {
return DATA.tech
.filter((t) => Object.keys(t.cost).length === 1 && t.cost['spatial-pack'] > 0)
.sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0];
}
function packFactory(): Command[] {
const cmds: Command[] = [];
const P = (def: string, x: number, y: number, dir: Dir = N) =>
cmds.push({ kind: 'place', def, pos: { x, y }, dir });
const b = (x: number, y: number, dir: Dir) => P('belt', x, y, dir);
const runX = (x0: number, x1: number, y: number, dir: Dir) => {
const s = x0 <= x1 ? 1 : -1;
for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
};
const runY = (y0: number, y1: number, x: number, dir: Dir) => {
const s = y0 <= y1 ? 1 : -1;
for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
};
const recipeAt = (x: number, y: number, recipe: string) =>
cmds.push({ kind: 'setRecipeAt', pos: { x, y }, recipe });
for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -8);
// ore
P('seam-extractor', -30, 0);
P('seam-extractor', -30, -3);
P('seam-extractor', -30, 3);
runY(-2, -1, -28, S);
runY(3, 1, -28, N);
runX(-28, -27, 0, E);
P('demuxer', -26, 0);
// mv-flux has no consumer here — sell it, or the demuxer jams on its own byproduct
runY(-1, -2, -25, N);
P('shipper', -26, -4);
// luma -> coefficient packs
runX(-24, -23, 0, E);
P('quantizer', -22, 0); // 'quantize' is the default recipe
runY(-1, -2, -22, N); // hf dust -> uplink
P('shipper', -23, -4);
b(-20, 0, E);
P('lane-splitter', -19, 0); // coefficient packs: bricks one way, halos the other
// 1 pack of bricks per craft vs 3 coefficient packs per halo — the splitter's
// skip-when-blocked does the balancing, as ever.
b(-18, 0, E);
P('quantizer', -17, 0);
recipeAt(-17, 0, 'quantize-crime'); // -> macroblock bricks
runY(-1, -2, -17, N);
P('shipper', -18, -4);
runX(-15, -14, 0, E); // bricks -> bottler
runY(1, 2, -19, S);
P('quantizer', -20, 3);
recipeAt(-20, 3, 'skim-ringing'); // -> ringing halos
runX(-21, -22, 3, W); // its hf dust -> uplink
P('shipper', -24, 2);
runX(-18, -15, 3, E); // halos -> bottler
runY(3, 2, -14, N);
b(-14, 1, E);
// chroma slurry -> bottler, the long way round below the works
runY(2, 5, -26, S);
b(-26, 6, E);
runX(-25, -14, 6, E);
b(-13, 6, N);
runY(5, 2, -13, N);
P('artifact-bottler', -13, 0);
recipeAt(-13, 0, 'bottle-spatial-pack');
runX(-11, -10, 0, E);
P('archaeology-lab', -9, 0);
return cmds;
}
describe('a real research run, on DATAs own tree', () => {
it('turns ore into a science pack and a science pack into a tech', () => {
const target = cheapestSpatialTech();
expect(target, 'DATA has no single-cost spatial-pack tech to aim at').toBeDefined();
const sim: Sim = createSim();
sim.init(DATA, 1337);
// Nothing granted: every machine below must be legal from a cold start.
expect(sim.snapshot().research!.unlocked).toEqual([]);
for (const c of packFactory()) sim.enqueue(c);
sim.enqueue({ kind: 'setResearch', tech: target!.id });
const evs: SimEvent[] = [];
let done = -1;
for (let i = 0; i < 12000 && done < 0; i++) {
sim.tick();
for (const e of sim.drainEvents()) {
evs.push(e);
if (e.kind === 'researched') done = e.tick;
}
}
// Every machine the layout asked for was legal from a cold start.
expect(sim.snapshot().entities.find((e) => e.def === 'archaeology-lab')).toBeDefined();
expect(sim.snapshot().entities.find((e) => e.def === 'artifact-bottler')).toBeDefined();
// The ladder ran, rung by rung, on real recipes.
for (const r of ['demux-ore', 'quantize', 'quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) {
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`)
.toBeGreaterThan(0);
}
expect(done, `${target!.id} never completed`).toBeGreaterThan(0);
expect(sim.snapshot().research!.unlocked).toEqual([target!.id]);
expect(sim.snapshot().research!.active).toBeNull();
});
it('opens what the tech promised: a recipe refused before is accepted after', () => {
const target = cheapestSpatialTech()!;
const unlockedRecipe = target.unlocks.find((u) =>
DATA.recipes.some((r) => r.id === u))!;
expect(unlockedRecipe, `${target.id} unlocks no recipe to test with`).toBeDefined();
const owner = DATA.recipes.find((r) => r.id === unlockedRecipe)!.machine;
const sim: Sim = createSim();
sim.init(DATA, 1337);
sim.enqueue({ kind: 'place', def: owner, pos: { x: 0, y: 0 }, dir: N });
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
sim.tick();
const before = sim.snapshot().entities.find((e) => e.pos.x === 0)!;
expect(before.recipe).not.toBe(unlockedRecipe); // silently refused
for (const c of packFactory()) sim.enqueue(c);
sim.enqueue({ kind: 'setResearch', tech: target.id });
for (let i = 0; i < 12000; i++) {
sim.tick();
if (sim.drainEvents().some((e) => e.kind === 'researched')) break;
}
expect(sim.snapshot().research!.unlocked).toEqual([target.id]);
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
sim.tick();
expect(sim.snapshot().entities.find((e) => e.pos.x === 0)!.recipe).toBe(unlockedRecipe);
});
});

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory } from './reference';
import { referenceFactory, referenceFactoryTech } from './reference';
import { grantResearch } from './testkit';
import type { GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -14,6 +15,9 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD
function build(): Sim {
const sim = createSim();
sim.init(DATA, 1337);
// A post-research save: v4 gates two of this layout's ids. Grant exactly what it needs
// and nothing else, so the test would notice if the layout started depending on more.
grantResearch(sim, referenceFactoryTech(DATA));
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
return sim;
}
@ -67,6 +71,9 @@ describe('reference factory', () => {
// Surplus is being turned into product, not silently dropped.
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
// v4: the slab overflow tap is live, so the demo visibly ships anchor slabs without
// anyone having to demolish the i-only assembler to see one.
expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0);
});
it('refuses to build against drifted data instead of deadlocking mysteriously', () => {
@ -74,3 +81,34 @@ describe('reference factory', () => {
expect(() => referenceFactory(stripped)).toThrow(/quantize-crime/);
});
});
describe('what the reference build costs in research', () => {
it('names exactly the techs it needs, and needs at least one', () => {
const needed = referenceFactoryTech(DATA);
expect(needed.length).toBeGreaterThan(0); // v4 gating is live; this is a late-game build
// Every named tech is real, and granting just these is enough to build the whole thing.
for (const id of needed) expect(DATA.tech.some((t) => t.id === id)).toBe(true);
const sim = createSim();
sim.init(DATA, 1337);
grantResearch(sim, needed);
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
sim.tick();
const placed = sim.snapshot().entities.length;
const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length;
expect(placed, 'every placement in the layout must be legal once its techs are open')
.toBe(asked);
});
it('is silently crippled without them — the demo hook must grant first', () => {
const sim = createSim();
sim.init(DATA, 1337); // cold start: nothing researched
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
sim.tick();
const asked = referenceFactory(DATA).filter((c) => c.kind === 'place').length;
// Gated placements are dropped without a word, which is the contract's rule — the
// factory simply comes up missing its power and browns out.
expect(sim.snapshot().entities.length).toBeLessThan(asked);
expect(sim.snapshot().entities.some((e) => e.def === 'software-decoder')).toBe(false);
});
});

View File

@ -32,6 +32,25 @@ import type { Command, Dir, GameData } from '../contracts';
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
/**
* The techs this layout needs before it can be built at all derived from the commands
* themselves, so it stays true if either the layout or DATA's tree moves.
*
* Contracts v4 gates every id a tech names, and this build uses two of them, so it is a
* post-research save rather than a cold start. Anything driving it (a test, the dev demo
* hook) has to open these first or the commands are silently dropped and the factory comes
* up underpowered measured: the reactor's uplink runs at 100 draw against 80 gen and the
* whole sector browns out. See NOTES round 4.
*/
export function referenceFactoryTech(data: GameData): string[] {
const needs = new Set<string>();
for (const c of referenceFactory(data)) {
if (c.kind === 'place') needs.add(c.def);
else if (c.kind === 'setRecipe' && c.recipe !== null) needs.add(c.recipe);
}
return data.tech.filter((t) => t.unlocks.some((u) => needs.has(u))).map((t) => t.id);
}
export function referenceFactory(data: GameData): Command[] {
// Fail loudly and specifically if DATA moves under us — a missing id here would
// otherwise surface as a mystery deadlock hours later.
@ -151,15 +170,30 @@ export function referenceFactory(data: GameData): Command[] {
P('gop-assembler', -10, 0); // assemble-gop (default)
runX(-7, -6, 0, E);
P('mosh-reactor', -5, 0);
runX(-2, -1, 0, E); // melt -> uplink
// TWO melt lanes, and the second one is not decoration. The reactor pushes one melt and
// one slab per tick, so with a single lane melt #2 finds its own belt full, sees the slab
// lane also ends at an uplink (rank 1, same as its own), and takes it. The splitter down
// there then locks into perfect alternation with the reactor's push order — slab to the
// assembler, melt to the uplink, forever — and no slab is ever sold. It survives 20,000
// ticks looking correct. A second lane means melt never wants the slab lane at all.
runX(-2, -1, 0, E);
runX(-2, -1, 1, E);
P('shipper', 0, 0);
// Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
// i-only assembler: 8 slabs -> another GOP crate -> more melt.
runY(3, 4, -5, S);
const iOnly = P('gop-assembler', -6, 5);
// Recovered anchor slabs come back out of the reactor, and they go two places: the
// i-only assembler turns 8 of them into another GOP crate (more melt), and the rest are
// sold. The splitter is what makes it "and" rather than "or" — half the slabs to each,
// and if the assembler backs up, all of them go out the door instead of wedging the
// reactor. Selling them is also the only way the demo ever SHIPS a slab, which is what
// the uplink fiction upstairs is about.
b(-5, 3, S);
P('lane-splitter', -5, 4);
b(-5, 5, S);
const iOnly = P('gop-assembler', -6, 6);
setRecipe(iOnly, 'assemble-gop-i-only');
runY(5, 3, -3, N);
runY(6, 3, -3, N); // its crates rejoin the reactor
runX(-6, -7, 4, W); // the overflow: slabs for sale
P('shipper', -9, 3);
return cmds;
}

View File

@ -0,0 +1,281 @@
/**
* Research v1 and the v4 gating rule.
*
* The mechanics run on a fixture; the gating rule is checked against DATA's real tech.json.
* The fixture exists so these tests pin the RULE rather than DATA's numbers which moved
* twice while this round was being written. For the same path proved end-to-end on the real
* tree, with a real lab and real science packs, see realtech.test.ts.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantResearch } from './testkit';
import type {
Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, TechDef,
} from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import techJson from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const REAL = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1;
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
const tech = (t: TechDef): TechDef => t;
/**
* A tree that can actually be entered: the pack press is ungated, so packs exist before
* any research does. (This is exactly the property DATA's real tree is missing.)
*/
const FIXTURE: GameData = {
items: ['rock', 'pack', 'gold'].map(item),
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'packer', kind: 'crafter', recipes: ['press-pack'] }), // ungated
mach({ id: 'lab', kind: 'lab' }), // ungated
mach({ id: 'goldworks', kind: 'crafter', recipes: ['smelt-gold'] }), // GATED
mach({ id: 'other', kind: 'crafter', recipes: ['plain', 'fancy'] }), // recipe 'fancy' GATED
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }),
rec({ id: 'press-pack', machine: 'packer', inputs: { rock: 1 }, outputs: { pack: 1 }, ticks: 5 }),
rec({ id: 'smelt-gold', machine: 'goldworks', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }),
rec({ id: 'plain', machine: 'other', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }),
rec({ id: 'fancy', machine: 'other', inputs: { rock: 2 }, outputs: { gold: 2 }, ticks: 5 }),
],
tech: [
tech({ id: 'metallurgy', era: 'reel', cost: { pack: 3 }, unlocks: ['goldworks', 'smelt-gold'] }),
tech({ id: 'flourish', era: 'reel', cost: { pack: 2 }, unlocks: ['fancy'] }),
],
commissions: [{ id: 'c1', flavor: 'gold', wants: { gold: 99 }, rewardBandwidth: 1 }],
};
function newSim(data: GameData = FIXTURE, seed = 1): Sim {
const sim = createSim();
sim.init(data, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const at = (sim: Sim, x: number, y: number) =>
sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
/** mine -> packer -> lab, plus somewhere for the packs to be counted. */
function packLine(sim: Sim): void {
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('packer', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('lab', 4, 0));
}
describe('gating', () => {
it('locks every id a tech names and leaves everything else alone', () => {
const sim = newSim();
sim.enqueue(place('goldworks', 5, 5)); // named by metallurgy.unlocks -> dropped
sim.enqueue(place('packer', 7, 5)); // named by nothing -> free
run(sim, 1);
expect(at(sim, 5, 5)).toBeUndefined();
expect(at(sim, 7, 5)).toBeDefined();
});
it('drops setRecipe and setRecipeAt for a locked recipe, silently', () => {
const sim = newSim();
sim.enqueue(place('other', 5, 5));
run(sim, 1);
const id = at(sim, 5, 5)!.id;
expect(at(sim, 5, 5)!.recipe).toBe('plain'); // defaults to the first UNLOCKED recipe
sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'fancy' }); // locked
run(sim, 1);
expect(at(sim, 5, 5)!.recipe).toBe('plain');
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 5, y: 5 }, recipe: 'fancy' }); // locked
run(sim, 1);
expect(at(sim, 5, 5)!.recipe).toBe('plain');
});
it('gates nothing when the data has no tech at all', () => {
const sim = newSim({ ...FIXTURE, tech: [] });
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeDefined(); // absence from the tree means free, not forbidden
});
});
describe('research', () => {
it('runs the whole path: pick a target, feed packs, unlock, build what was refused', () => {
const sim = newSim();
expect(sim.snapshot().research).toEqual({ active: null, progress: {}, unlocked: [] });
// Refused up front.
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeUndefined();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
run(sim, 1);
expect(sim.snapshot().research!.active).toBe('metallurgy');
const evs: SimEvent[] = [];
let done = -1;
for (let i = 0; i < 800 && done < 0; i++) {
run(sim, 1, evs);
const e = evs.find((x) => x.kind === 'researched');
if (e) done = (e as { tick: number }).tick;
}
expect(done).toBeGreaterThan(0);
expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
expect(evs.find((e) => e.kind === 'researched')).toMatchObject({ tech: 'metallurgy' });
const r = sim.snapshot().research!;
expect(r.unlocked).toEqual(['metallurgy']);
expect(r.active).toBeNull(); // completing clears the target rather than guessing a next
expect(r.progress).toEqual({});
// The same command that was silently dropped now works.
sim.enqueue(place('goldworks', 5, 5));
run(sim, 1);
expect(at(sim, 5, 5)).toBeDefined();
expect(at(sim, 5, 5)!.recipe).toBe('smelt-gold'); // its recipe unlocked with it
});
it('tracks progress toward the active tech and stops taking packs it no longer needs', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // costs 2 packs
const evs: SimEvent[] = [];
for (let i = 0; i < 400; i++) {
run(sim, 1, evs);
if (evs.some((e) => e.kind === 'researched')) break;
const p = sim.snapshot().research!.progress['pack'] ?? 0;
expect(p).toBeLessThanOrEqual(2); // never banks past the cost
}
expect(sim.snapshot().research!.unlocked).toEqual(['flourish']);
// With nothing being researched, the lab stops accepting deliveries entirely.
run(sim, 200);
expect(at(sim, 4, 0)!.inputBuf).toEqual({});
});
it('stacks: two labs feed one total', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('packer', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('lab', 4, 0)); // first lab on the line
sim.enqueue(place('belt', 4, 1, N)); // a second lab fed from the other side
sim.enqueue(place('lab', 4, 2));
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
const evs: SimEvent[] = [];
run(sim, 800, evs);
expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
});
it('abandons progress when the target changes, and refuses nonsense targets', () => {
const sim = newSim();
packLine(sim);
sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs
// Catch it mid-research: metallurgy completes on the third pack, and completing would
// zero progress for a different reason than the one under test.
let banked = 0;
for (let i = 0; i < 400 && banked === 0; i++) {
run(sim, 1);
banked = sim.snapshot().research!.progress['pack'] ?? 0;
}
expect(banked).toBeGreaterThan(0);
sim.enqueue({ kind: 'setResearch', tech: 'flourish' });
run(sim, 1);
expect(sim.snapshot().research!.progress).toEqual({}); // packs are the cost of indecision
sim.enqueue({ kind: 'setResearch', tech: 'no-such-tech' });
run(sim, 1);
expect(sim.snapshot().research!.active).toBe('flourish'); // unchanged
sim.enqueue({ kind: 'setResearch', tech: null });
run(sim, 1);
expect(sim.snapshot().research!.active).toBeNull();
});
it('survives save/load, and a restored save can still build what it unlocked', () => {
const a = newSim();
packLine(a);
a.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
const evs: SimEvent[] = [];
for (let i = 0; i < 800; i++) {
run(a, 1, evs);
if (evs.some((e) => e.kind === 'researched')) break;
}
expect(a.snapshot().research!.unlocked).toEqual(['metallurgy']);
const b = newSim();
b.load(a.save());
expect(b.snapshot().research).toEqual(a.snapshot().research);
// The future still matches: research state is part of the world, not decoration.
run(a, 500);
run(b, 500);
expect(b.snapshot()).toEqual(a.snapshot());
// And the unlock survived as a RULE, not just as a list — a save that restored the
// text but not the gate would silently re-lock the player's own factory. Placed in
// both so the sims stay in step.
for (const s of [a, b]) {
s.enqueue(place('goldworks', 9, 9));
run(s, 1);
expect(at(s, 9, 9)).toBeDefined();
}
expect(b.snapshot()).toEqual(a.snapshot());
});
});
// Derived, never hard-coded: which machines DATA gates is theirs to change, and it moved
// twice while this round was being written. The rule is mine to pin down; the list isn't.
const gatedByReal = new Set(REAL.tech.flatMap((t) => t.unlocks));
const aGatedMachine = REAL.machines.find((m) => gatedByReal.has(m.id))!;
const aFreeMachine = REAL.machines.find(
(m) => !gatedByReal.has(m.id) && m.kind === 'crafter' && m.footprint.x <= 2,
)!;
describe('gating against the real tech tree', () => {
it('locks the machines DATA gates and frees the ones it does not', () => {
const sim = newSim(REAL, 7);
sim.enqueue(place(aGatedMachine.id, 0, 0));
sim.enqueue(place(aFreeMachine.id, 8, 0));
run(sim, 1);
expect(at(sim, 0, 0), `${aGatedMachine.id} is gated and must not place`).toBeUndefined();
expect(at(sim, 8, 0), `${aFreeMachine.id} is ungated and must place`).toBeDefined();
});
it('opens the real gate when the real tech completes', () => {
const unlocker = REAL.tech.find((t) => t.unlocks.includes(aGatedMachine.id))!;
const sim = newSim(REAL, 7);
grantResearch(sim, [unlocker.id]);
sim.enqueue(place(aGatedMachine.id, 0, 0));
run(sim, 1);
expect(at(sim, 0, 0)).toBeDefined();
expect(sim.snapshot().research!.unlocked).toEqual([unlocker.id]);
});
});

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { makeRng } from './rng';
import { grantAllResearch } from './testkit';
import type { Command, Dir, EntityState, GameData, RecipeDef, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
@ -26,6 +27,9 @@ const recipe = (id: string): RecipeDef => {
function newSim(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
// v4 gating locks the mosh reactor and friends; these tests build the M1 chain, which
// is an endgame build. Placement gating gets its own tests in research.test.ts.
grantAllResearch(sim, DATA);
return sim;
}

31
fktry/src/sim/testkit.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* Test/dev helpers for LANE-SIM. Not part of the game loop nothing in src/main.ts,
* src/render, src/ui or src/screen imports this, and it tree-shakes out of the bundle.
*
* Why it exists: contracts v4 gates every id named by a tech's `unlocks`, and that
* includes the mosh reactor, the software decoder and the subsampler. So the reference
* factory the endgame demo is no longer buildable from a fresh sim by design. It
* represents a save where the research has already happened, and this is how a test says
* so honestly, through the public save/load API rather than a back door into the sim.
*
* There is currently no legitimate in-sim path to this state: DATA ships no `kind:'lab'`
* machine, and every science pack is made on the artifact-bottler, which is itself locked
* behind a tech costing 10 analog-packs. See NOTES round 4.
*/
import type { GameData, Sim } from '../contracts';
/**
* Rewrites a sim's research state to treat `techIds` as completed, via a save/load
* round-trip. Call it on a fresh sim before enqueueing anything load() replaces the
* whole world, so any entities placed first would be discarded.
*/
export function grantResearch(sim: Sim, techIds: string[]): void {
const blob = JSON.parse(sim.save());
blob.research = { active: null, progress: {}, unlocked: [...techIds] };
sim.load(JSON.stringify(blob));
}
/** The late-game save: everything DATA's tech tree gates is open. */
export function grantAllResearch(sim: Sim, data: GameData): void {
grantResearch(sim, data.tech.map((t) => t.id));
}