[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:
parent
a48d45b4df
commit
039eff15fa
@ -19,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createSim } from './index';
|
import { createSim } from './index';
|
||||||
|
import { grantAllResearch } from './testkit';
|
||||||
import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts';
|
import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts';
|
||||||
|
|
||||||
import items from '../../data/items.json';
|
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 {
|
function newSim(seed = 1337): Sim {
|
||||||
const sim = createSim();
|
const sim = createSim();
|
||||||
sim.init(DATA, seed);
|
sim.init(DATA, seed);
|
||||||
|
grantAllResearch(sim, DATA); // the bloom duplicator is gated behind stream-bloom-loops
|
||||||
return sim;
|
return sim;
|
||||||
}
|
}
|
||||||
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
|
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
|
||||||
|
|||||||
57
fktry/src/sim/constants.ts
Normal file
57
fktry/src/sim/constants.ts
Normal 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;
|
||||||
@ -15,35 +15,31 @@ import {
|
|||||||
type GameData,
|
type GameData,
|
||||||
type MachineDef,
|
type MachineDef,
|
||||||
type RecipeDef,
|
type RecipeDef,
|
||||||
|
type ResearchState,
|
||||||
type Sim,
|
type Sim,
|
||||||
type SimEvent,
|
type SimEvent,
|
||||||
type SimSnapshot,
|
type SimSnapshot,
|
||||||
|
type TechDef,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom';
|
import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom';
|
||||||
import { makeRng, type Rng } from './rng';
|
import { makeRng, type Rng } from './rng';
|
||||||
|
|
||||||
/** Minimum gap between two items measured in belt-tiles. */
|
// The tuning constants live in ./constants so other lanes' tests can import truth
|
||||||
const BELT_SPACING = 0.45;
|
// instead of hand-copying numbers that go stale in silence.
|
||||||
/** Items a single belt tile may hold. */
|
import {
|
||||||
const BELT_CAPACITY = 2;
|
BELT_CAPACITY,
|
||||||
/** Items a splitter may hold while it waits for an output to open. */
|
BELT_SPACING,
|
||||||
const SPLITTER_CAPACITY = 2;
|
HEAT_COOL_DEFAULT,
|
||||||
/** A machine stalls once any output has piled to this multiple of the recipe yield. */
|
HEAT_COOL_MAX,
|
||||||
const OUTPUT_STALL_FACTOR = 4;
|
HEAT_RESTART,
|
||||||
/** A machine accepts an input up to this multiple of the recipe requirement. */
|
HEAT_SCRAM,
|
||||||
const INPUT_BUFFER_FACTOR = 2;
|
HEAT_THROTTLE_FLOOR,
|
||||||
/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */
|
HEAT_THROTTLE_START,
|
||||||
const MAX_TRACE = 512;
|
INPUT_BUFFER_FACTOR,
|
||||||
|
MAX_TRACE,
|
||||||
// Heat v1. Curve is LANE-SIM's call — see NOTES round 2.
|
OUTPUT_STALL_FACTOR,
|
||||||
/** Below this, heat is cosmetic. */
|
SPLITTER_CAPACITY,
|
||||||
const HEAT_THROTTLE_START = 0.7;
|
} from './constants';
|
||||||
/** 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;
|
|
||||||
|
|
||||||
/** Internal entity record. `state` is the contract-shaped object handed to consumers. */
|
/** Internal entity record. `state` is the contract-shaped object handed to consumers. */
|
||||||
interface Ent {
|
interface Ent {
|
||||||
@ -67,6 +63,7 @@ export function createSim(): Sim {
|
|||||||
const defs = new Map<string, MachineDef>();
|
const defs = new Map<string, MachineDef>();
|
||||||
const recipeDefs = new Map<string, RecipeDef>();
|
const recipeDefs = new Map<string, RecipeDef>();
|
||||||
const commissionDefs = new Map<string, CommissionDef>();
|
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
|
// 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.
|
// 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[] = [];
|
let allBeltItems: BeltItem[] = [];
|
||||||
/** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */
|
/** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */
|
||||||
const traceCache = new Map<number, number[]>();
|
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 nextId = 1;
|
||||||
let nextItemId = 1;
|
let nextItemId = 1;
|
||||||
@ -87,6 +86,12 @@ export function createSim(): Sim {
|
|||||||
let stored = 0;
|
let stored = 0;
|
||||||
let commissionQueue: string[] = [];
|
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[] = [];
|
const cmdQueue: Command[] = [];
|
||||||
let events: SimEvent[] = [];
|
let events: SimEvent[] = [];
|
||||||
/** items already advanced this tick, so a transfer downstream can't double-move */
|
/** items already advanced this tick, so a transfer downstream can't double-move */
|
||||||
@ -98,10 +103,12 @@ export function createSim(): Sim {
|
|||||||
paused: false,
|
paused: false,
|
||||||
entities: entityStates,
|
entities: entityStates,
|
||||||
beltItems: allBeltItems,
|
beltItems: allBeltItems,
|
||||||
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
|
bandwidth: { gen: 0, draw: 0, stored: 0, capacity: 0, brownout: false },
|
||||||
shippedTotal: {},
|
shippedTotal: {},
|
||||||
activeCommission: null,
|
activeCommission: null,
|
||||||
|
commissionQueue: [], // hardens to required in v5; always present
|
||||||
commissionProgress: {},
|
commissionProgress: {},
|
||||||
|
research,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------ lookups
|
// ------------------------------------------------------------------ lookups
|
||||||
@ -123,6 +130,12 @@ export function createSim(): Sim {
|
|||||||
/** Static appetite: does this machine's recipe ever call for this item? */
|
/** Static appetite: does this machine's recipe ever call for this item? */
|
||||||
const machineWants = (e: Ent, item: string): boolean => {
|
const machineWants = (e: Ent, item: string): boolean => {
|
||||||
if (e.def.kind === 'shipper') return true;
|
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);
|
const r = recipeOf(e);
|
||||||
return !!r && (r.inputs[item] ?? 0) > 0;
|
return !!r && (r.inputs[item] ?? 0) > 0;
|
||||||
};
|
};
|
||||||
@ -237,6 +250,49 @@ export function createSim(): Sim {
|
|||||||
e.state.inputBuf = buf;
|
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. */
|
/** Scrammed and idle machines contribute nothing; hot ones work at reduced speed. */
|
||||||
function heatThrottle(e: Ent): number {
|
function heatThrottle(e: Ent): number {
|
||||||
if (e.scrammed) return 0;
|
if (e.scrammed) return 0;
|
||||||
@ -249,6 +305,7 @@ export function createSim(): Sim {
|
|||||||
// ----------------------------------------------------------------- commands
|
// ----------------------------------------------------------------- commands
|
||||||
|
|
||||||
function doPlace(def: MachineDef, pos: { x: number; y: number }, dir: Dir): void {
|
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 fp = footprintOf(def.footprint, dir);
|
||||||
const tiles: number[] = [];
|
const tiles: number[] = [];
|
||||||
for (let dx = 0; dx < fp.x; dx++) {
|
for (let dx = 0; dx < fp.x; dx++) {
|
||||||
@ -266,7 +323,7 @@ export function createSim(): Sim {
|
|||||||
def: def.id,
|
def: def.id,
|
||||||
pos: { x: pos.x, y: pos.y },
|
pos: { x: pos.x, y: pos.y },
|
||||||
dir,
|
dir,
|
||||||
recipe: def.recipes.length ? def.recipes[0] : null,
|
recipe: defaultRecipe(def), // first recipe research has actually opened
|
||||||
progress: 0,
|
progress: 0,
|
||||||
inputBuf: {},
|
inputBuf: {},
|
||||||
outputBuf: {},
|
outputBuf: {},
|
||||||
@ -281,6 +338,7 @@ export function createSim(): Sim {
|
|||||||
for (const k of tiles) occ.set(k, state.id);
|
for (const k of tiles) occ.set(k, state.id);
|
||||||
if (def.kind === 'belt') beltContents.set(state.id, []);
|
if (def.kind === 'belt') beltContents.set(state.id, []);
|
||||||
traceCache.clear();
|
traceCache.clear();
|
||||||
|
coolCache.clear(); // auras are geometric: any move re-evaluates them
|
||||||
events.push({ kind: 'placed', entity: state.id, def: def.id, tick: curTick });
|
events.push({ kind: 'placed', entity: state.id, def: def.id, tick: curTick });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,6 +357,7 @@ export function createSim(): Sim {
|
|||||||
beltContents.delete(id);
|
beltContents.delete(id);
|
||||||
}
|
}
|
||||||
traceCache.clear();
|
traceCache.clear();
|
||||||
|
coolCache.clear(); // auras are geometric: any move re-evaluates them
|
||||||
events.push({ kind: 'removed', entity: id, def: e.def.id, tick: curTick });
|
events.push({ kind: 'removed', entity: id, def: e.def.id, tick: curTick });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,6 +383,7 @@ export function createSim(): Sim {
|
|||||||
e.tiles = tiles;
|
e.tiles = tiles;
|
||||||
e.state.dir = dir;
|
e.state.dir = dir;
|
||||||
traceCache.clear();
|
traceCache.clear();
|
||||||
|
coolCache.clear(); // auras are geometric: any move re-evaluates them
|
||||||
}
|
}
|
||||||
|
|
||||||
function doSetRecipe(entity: number, recipe: string | null): void {
|
function doSetRecipe(entity: number, recipe: string | null): void {
|
||||||
@ -334,6 +394,7 @@ export function createSim(): Sim {
|
|||||||
|
|
||||||
function setRecipeOn(e: Ent, recipe: string | null): void {
|
function setRecipeOn(e: Ent, recipe: string | null): void {
|
||||||
if (recipe !== null && !e.def.recipes.includes(recipe)) return;
|
if (recipe !== null && !e.def.recipes.includes(recipe)) return;
|
||||||
|
if (recipe !== null && !isAvailable(recipe)) return; // locked behind research
|
||||||
e.state.recipe = recipe;
|
e.state.recipe = recipe;
|
||||||
e.state.progress = 0;
|
e.state.progress = 0;
|
||||||
e.crafting = false;
|
e.crafting = false;
|
||||||
@ -351,6 +412,7 @@ export function createSim(): Sim {
|
|||||||
case 'remove': doRemove(cmd.pos); break;
|
case 'remove': doRemove(cmd.pos); break;
|
||||||
case 'rotate': doRotate(cmd.pos); break;
|
case 'rotate': doRotate(cmd.pos); break;
|
||||||
case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break;
|
case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break;
|
||||||
|
case 'setResearch': doSetResearch(cmd.tech); break;
|
||||||
case 'setRecipeAt': {
|
case 'setRecipeAt': {
|
||||||
const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works
|
const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works
|
||||||
if (e) setRecipeOn(e, cmd.recipe);
|
if (e) setRecipeOn(e, cmd.recipe);
|
||||||
@ -445,6 +507,7 @@ export function createSim(): Sim {
|
|||||||
snap.bandwidth.gen = gen;
|
snap.bandwidth.gen = gen;
|
||||||
snap.bandwidth.draw = draw;
|
snap.bandwidth.draw = draw;
|
||||||
snap.bandwidth.stored = stored;
|
snap.bandwidth.stored = stored;
|
||||||
|
snap.bandwidth.capacity = capacity;
|
||||||
snap.bandwidth.brownout = on;
|
snap.bandwidth.brownout = on;
|
||||||
return on ? (draw > 0 ? supplied / draw : 1) : 1;
|
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.
|
// 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.
|
// 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;
|
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);
|
const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
|
||||||
let h = e.state.heat + (active ? hpt : 0) - cool;
|
let h = e.state.heat + (active ? hpt : 0) - cool;
|
||||||
if (h < 0) h = 0;
|
if (h < 0) h = 0;
|
||||||
else if (h > 1) h = 1;
|
else if (h > 1) h = 1;
|
||||||
e.state.heat = h;
|
e.state.heat = h;
|
||||||
|
|
||||||
if (!e.scrammed && h >= 1) {
|
if (!e.scrammed && h >= HEAT_SCRAM) {
|
||||||
e.scrammed = true;
|
e.scrammed = true;
|
||||||
e.state.scrammed = true;
|
e.state.scrammed = true;
|
||||||
setJam(e, null); // scram is reported by its own event, not as a flow jam
|
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);
|
syncSplitter(e);
|
||||||
return true;
|
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);
|
const r = recipeOf(e);
|
||||||
if (!r) return false;
|
if (!r) return false;
|
||||||
const need = r.inputs[item] ?? 0;
|
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. */
|
/** Activates the commission at the head of the queue and zeroes its progress. */
|
||||||
function activateCommission(): void {
|
function activateCommission(): void {
|
||||||
const id = commissionQueue[0] ?? null;
|
const id = commissionQueue[0] ?? null;
|
||||||
@ -715,9 +847,15 @@ export function createSim(): Sim {
|
|||||||
defs.clear();
|
defs.clear();
|
||||||
recipeDefs.clear();
|
recipeDefs.clear();
|
||||||
commissionDefs.clear();
|
commissionDefs.clear();
|
||||||
|
techDefs.clear();
|
||||||
|
gatedIds.clear();
|
||||||
for (const m of gameData.machines) defs.set(m.id, m);
|
for (const m of gameData.machines) defs.set(m.id, m);
|
||||||
for (const r of gameData.recipes) recipeDefs.set(r.id, r);
|
for (const r of gameData.recipes) recipeDefs.set(r.id, r);
|
||||||
for (const c of gameData.commissions) commissionDefs.set(c.id, c);
|
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 = [];
|
ents = [];
|
||||||
entityStates = [];
|
entityStates = [];
|
||||||
@ -726,6 +864,7 @@ export function createSim(): Sim {
|
|||||||
occ.clear();
|
occ.clear();
|
||||||
beltContents.clear();
|
beltContents.clear();
|
||||||
traceCache.clear();
|
traceCache.clear();
|
||||||
|
coolCache.clear();
|
||||||
cmdQueue.length = 0;
|
cmdQueue.length = 0;
|
||||||
events = [];
|
events = [];
|
||||||
moved.clear();
|
moved.clear();
|
||||||
@ -737,13 +876,16 @@ export function createSim(): Sim {
|
|||||||
brownout = false;
|
brownout = false;
|
||||||
stored = 0;
|
stored = 0;
|
||||||
commissionQueue = data.commissions.map((c) => c.id);
|
commissionQueue = data.commissions.map((c) => c.id);
|
||||||
|
research = { active: null, progress: {}, unlocked: [] };
|
||||||
|
applyUnlocks();
|
||||||
|
|
||||||
snap.tick = 0;
|
snap.tick = 0;
|
||||||
snap.paused = false;
|
snap.paused = false;
|
||||||
snap.entities = entityStates;
|
snap.entities = entityStates;
|
||||||
snap.beltItems = allBeltItems;
|
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.shippedTotal = {};
|
||||||
|
snap.research = research;
|
||||||
activateCommission();
|
activateCommission();
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -764,6 +906,7 @@ export function createSim(): Sim {
|
|||||||
stepSplitters();
|
stepSplitters();
|
||||||
moveBelts(mult);
|
moveBelts(mult);
|
||||||
drainShippers();
|
drainShippers();
|
||||||
|
drainLabs();
|
||||||
|
|
||||||
curTick++;
|
curTick++;
|
||||||
snap.tick = curTick;
|
snap.tick = curTick;
|
||||||
@ -797,6 +940,7 @@ export function createSim(): Sim {
|
|||||||
nextItemId,
|
nextItemId,
|
||||||
rng: rng.state(),
|
rng: rng.state(),
|
||||||
commissionQueue,
|
commissionQueue,
|
||||||
|
research,
|
||||||
shippedTotal: snap.shippedTotal,
|
shippedTotal: snap.shippedTotal,
|
||||||
commissionProgress: snap.commissionProgress,
|
commissionProgress: snap.commissionProgress,
|
||||||
pending: cmdQueue, // commands enqueued but not yet applied
|
pending: cmdQueue, // commands enqueued but not yet applied
|
||||||
@ -822,10 +966,16 @@ export function createSim(): Sim {
|
|||||||
occ.clear();
|
occ.clear();
|
||||||
beltContents.clear();
|
beltContents.clear();
|
||||||
traceCache.clear();
|
traceCache.clear();
|
||||||
|
coolCache.clear();
|
||||||
cmdQueue.length = 0;
|
cmdQueue.length = 0;
|
||||||
events = [];
|
events = [];
|
||||||
moved.clear();
|
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) {
|
for (const rec of s.entities) {
|
||||||
const def = defs.get(rec.state.def);
|
const def = defs.get(rec.state.def);
|
||||||
if (!def) throw new Error(`sim.load: this GameData has no machine "${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.paused = paused;
|
||||||
snap.entities = entityStates;
|
snap.entities = entityStates;
|
||||||
snap.beltItems = allBeltItems;
|
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.shippedTotal = s.shippedTotal;
|
||||||
snap.commissionProgress = s.commissionProgress;
|
snap.commissionProgress = s.commissionProgress;
|
||||||
snap.activeCommission = commissionQueue[0] ?? null;
|
snap.activeCommission = commissionQueue[0] ?? null;
|
||||||
snap.commissionQueue = commissionQueue;
|
snap.commissionQueue = commissionQueue;
|
||||||
|
snap.research = research;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createSim } from './index';
|
import { createSim } from './index';
|
||||||
import { referenceFactory } from './reference';
|
import { referenceFactory } from './reference';
|
||||||
|
import { grantAllResearch } from './testkit';
|
||||||
import type { GameData, Sim } from '../contracts';
|
import type { GameData, Sim } from '../contracts';
|
||||||
|
|
||||||
import items from '../../data/items.json';
|
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 {
|
function fresh(seed = 1337): Sim {
|
||||||
const sim = createSim();
|
const sim = createSim();
|
||||||
sim.init(DATA, seed);
|
sim.init(DATA, seed);
|
||||||
|
grantAllResearch(sim, DATA); // the reference factory is a post-research build
|
||||||
return sim;
|
return sim;
|
||||||
}
|
}
|
||||||
function run(sim: Sim, ticks: number): void {
|
function run(sim: Sim, ticks: number): void {
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createSim } from './index';
|
import { createSim } from './index';
|
||||||
|
import { HEAT_COOL_DEFAULT, HEAT_COOL_MAX } from './constants';
|
||||||
import type {
|
import type {
|
||||||
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
|
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
@ -32,6 +33,9 @@ const FIXTURE: GameData = {
|
|||||||
mach({ id: 'ship', kind: 'shipper' }),
|
mach({ id: 'ship', kind: 'shipper' }),
|
||||||
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
|
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
|
||||||
mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
|
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: 'tank', kind: 'buffer', bufferCap: 100 }),
|
||||||
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
|
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
|
||||||
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
|
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
|
||||||
@ -269,6 +273,95 @@ describe('heat', () => {
|
|||||||
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
|
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Ticks until the generator at (x,y) first scrams, or -1 if it never does. */
|
||||||
|
const ticksToScram = (sim: Sim, limit = 1500): number => {
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const evs: SimEvent[] = [];
|
||||||
|
run(sim, 1, evs);
|
||||||
|
if (evs.some((e) => e.kind === 'scram' && e.on)) return i + 1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('spatial cooling', () => {
|
||||||
|
it('a cooler in range extends a hot generator’s run before it scrams', () => {
|
||||||
|
const bare = newSim();
|
||||||
|
bare.enqueue(place('hotgen', 0, 0));
|
||||||
|
const alone = ticksToScram(bare);
|
||||||
|
expect(alone).toBeGreaterThan(0); // 0.02 gross - 0.004 default cooling
|
||||||
|
|
||||||
|
const cooled = newSim();
|
||||||
|
cooled.enqueue(place('hotgen', 0, 0));
|
||||||
|
cooled.enqueue(place('cooler', 2, 0)); // 2 tiles away: inside the radius
|
||||||
|
const withAura = ticksToScram(cooled);
|
||||||
|
expect(withAura).toBeGreaterThan(0);
|
||||||
|
// 0.02 - (0.004 + 0.01) = 0.006/tick instead of 0.016: roughly 2.5x the run.
|
||||||
|
expect(withAura).toBeGreaterThan(alone * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the aura is bounded — a cooler out of range does nothing', () => {
|
||||||
|
const bare = newSim();
|
||||||
|
bare.enqueue(place('hotgen', 0, 0));
|
||||||
|
const alone = ticksToScram(bare);
|
||||||
|
|
||||||
|
const far = newSim();
|
||||||
|
far.enqueue(place('hotgen', 0, 0));
|
||||||
|
far.enqueue(place('cooler', 5, 0)); // radius is 2; this is 5 away
|
||||||
|
expect(ticksToScram(far)).toBe(alone);
|
||||||
|
expect(at(far, 0, 0)!.heat).toBe(at(bare, 0, 0)!.heat);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enough cooling in range stops it overheating at all', () => {
|
||||||
|
const sim = newSim();
|
||||||
|
sim.enqueue(place('hotgen', 0, 0));
|
||||||
|
sim.enqueue(place('bigcooler', 1, 0)); // 0.004 + 0.04 = 0.044 > 0.02 gross
|
||||||
|
const evs: SimEvent[] = [];
|
||||||
|
run(sim, 2000, evs);
|
||||||
|
expect(evs.filter((e) => e.kind === 'scram')).toHaveLength(0);
|
||||||
|
expect(at(sim, 0, 0)!.heat).toBe(0);
|
||||||
|
expect(sim.snapshot().bandwidth.gen).toBe(10); // never throttled, never dropped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auras stack by sum, and the total is capped however many you pile on', () => {
|
||||||
|
// The oven takes 0.3 heat per bake and has no heatPerTick, so between bakes its heat
|
||||||
|
// moves by exactly -cooling. That makes the shed rate directly measurable.
|
||||||
|
const shedRate = (coolers: Array<[number, number]>): number => {
|
||||||
|
const sim = newSim();
|
||||||
|
sim.enqueue(place('mine', 0, 0));
|
||||||
|
sim.enqueue(place('belt', 1, 0, E));
|
||||||
|
sim.enqueue(place('oven', 2, 0));
|
||||||
|
for (const [x, y] of coolers) sim.enqueue(place('bigcooler', x, y));
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
run(sim, 1);
|
||||||
|
if (at(sim, 2, 0)!.heat > 0.1) break; // a bake just landed
|
||||||
|
}
|
||||||
|
const before = at(sim, 2, 0)!.heat;
|
||||||
|
run(sim, 1);
|
||||||
|
return before - at(sim, 2, 0)!.heat;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(shedRate([])).toBeCloseTo(HEAT_COOL_DEFAULT, 6);
|
||||||
|
// One in range: own default + the aura, summed.
|
||||||
|
expect(shedRate([[3, 0]])).toBeCloseTo(HEAT_COOL_DEFAULT + 0.04, 6);
|
||||||
|
// Four in range would be 0.004 + 0.16 = 0.164 — the cap holds it at HEAT_COOL_MAX.
|
||||||
|
const piled = shedRate([[3, 0], [2, 1], [3, 1], [2, -1]]);
|
||||||
|
expect(piled).toBeCloseTo(HEAT_COOL_MAX, 6);
|
||||||
|
expect(HEAT_COOL_MAX).toBeLessThan(HEAT_COOL_DEFAULT + 4 * 0.04); // the cap really bites
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing a cooler puts the heat back', () => {
|
||||||
|
const sim = newSim();
|
||||||
|
sim.enqueue(place('hotgen', 0, 0));
|
||||||
|
sim.enqueue(place('bigcooler', 1, 0));
|
||||||
|
run(sim, 300);
|
||||||
|
expect(at(sim, 0, 0)!.heat).toBe(0); // held cold by the aura
|
||||||
|
|
||||||
|
sim.enqueue({ kind: 'remove', pos: { x: 1, y: 0 } });
|
||||||
|
run(sim, 200);
|
||||||
|
expect(at(sim, 0, 0)!.heat).toBeGreaterThan(0); // aura gone, it heats again
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves cold machines alone', () => {
|
it('leaves cold machines alone', () => {
|
||||||
const sim = newSim();
|
const sim = newSim();
|
||||||
sim.enqueue(place('gen', 0, 0)); // no heatPerTick
|
sim.enqueue(place('gen', 0, 0)); // no heatPerTick
|
||||||
|
|||||||
171
fktry/src/sim/realtech.test.ts
Normal file
171
fktry/src/sim/realtech.test.ts
Normal 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 DATA’s own tree', () => {
|
||||||
|
it('turns ore into a science pack and a science pack into a tech', () => {
|
||||||
|
const target = cheapestSpatialTech();
|
||||||
|
expect(target, 'DATA has no single-cost spatial-pack tech to aim at').toBeDefined();
|
||||||
|
|
||||||
|
const sim: Sim = createSim();
|
||||||
|
sim.init(DATA, 1337);
|
||||||
|
// Nothing granted: every machine below must be legal from a cold start.
|
||||||
|
expect(sim.snapshot().research!.unlocked).toEqual([]);
|
||||||
|
|
||||||
|
for (const c of packFactory()) sim.enqueue(c);
|
||||||
|
sim.enqueue({ kind: 'setResearch', tech: target!.id });
|
||||||
|
|
||||||
|
const evs: SimEvent[] = [];
|
||||||
|
let done = -1;
|
||||||
|
for (let i = 0; i < 12000 && done < 0; i++) {
|
||||||
|
sim.tick();
|
||||||
|
for (const e of sim.drainEvents()) {
|
||||||
|
evs.push(e);
|
||||||
|
if (e.kind === 'researched') done = e.tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every machine the layout asked for was legal from a cold start.
|
||||||
|
expect(sim.snapshot().entities.find((e) => e.def === 'archaeology-lab')).toBeDefined();
|
||||||
|
expect(sim.snapshot().entities.find((e) => e.def === 'artifact-bottler')).toBeDefined();
|
||||||
|
// The ladder ran, rung by rung, on real recipes.
|
||||||
|
for (const r of ['demux-ore', 'quantize', 'quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) {
|
||||||
|
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(done, `${target!.id} never completed`).toBeGreaterThan(0);
|
||||||
|
expect(sim.snapshot().research!.unlocked).toEqual([target!.id]);
|
||||||
|
expect(sim.snapshot().research!.active).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens what the tech promised: a recipe refused before is accepted after', () => {
|
||||||
|
const target = cheapestSpatialTech()!;
|
||||||
|
const unlockedRecipe = target.unlocks.find((u) =>
|
||||||
|
DATA.recipes.some((r) => r.id === u))!;
|
||||||
|
expect(unlockedRecipe, `${target.id} unlocks no recipe to test with`).toBeDefined();
|
||||||
|
const owner = DATA.recipes.find((r) => r.id === unlockedRecipe)!.machine;
|
||||||
|
|
||||||
|
const sim: Sim = createSim();
|
||||||
|
sim.init(DATA, 1337);
|
||||||
|
sim.enqueue({ kind: 'place', def: owner, pos: { x: 0, y: 0 }, dir: N });
|
||||||
|
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
|
||||||
|
sim.tick();
|
||||||
|
const before = sim.snapshot().entities.find((e) => e.pos.x === 0)!;
|
||||||
|
expect(before.recipe).not.toBe(unlockedRecipe); // silently refused
|
||||||
|
|
||||||
|
for (const c of packFactory()) sim.enqueue(c);
|
||||||
|
sim.enqueue({ kind: 'setResearch', tech: target.id });
|
||||||
|
for (let i = 0; i < 12000; i++) {
|
||||||
|
sim.tick();
|
||||||
|
if (sim.drainEvents().some((e) => e.kind === 'researched')) break;
|
||||||
|
}
|
||||||
|
expect(sim.snapshot().research!.unlocked).toEqual([target.id]);
|
||||||
|
|
||||||
|
sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
|
||||||
|
sim.tick();
|
||||||
|
expect(sim.snapshot().entities.find((e) => e.pos.x === 0)!.recipe).toBe(unlockedRecipe);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createSim } from './index';
|
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 type { GameData, Sim, SimEvent } from '../contracts';
|
||||||
|
|
||||||
import items from '../../data/items.json';
|
import items from '../../data/items.json';
|
||||||
@ -14,6 +15,9 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD
|
|||||||
function build(): Sim {
|
function build(): Sim {
|
||||||
const sim = createSim();
|
const sim = createSim();
|
||||||
sim.init(DATA, 1337);
|
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);
|
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
|
||||||
return sim;
|
return sim;
|
||||||
}
|
}
|
||||||
@ -67,6 +71,9 @@ describe('reference factory', () => {
|
|||||||
// Surplus is being turned into product, not silently dropped.
|
// Surplus is being turned into product, not silently dropped.
|
||||||
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
|
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
|
||||||
expect(snap.shippedTotal['hf-dust'] ?? 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', () => {
|
it('refuses to build against drifted data instead of deadlocking mysteriously', () => {
|
||||||
@ -74,3 +81,34 @@ describe('reference factory', () => {
|
|||||||
expect(() => referenceFactory(stripped)).toThrow(/quantize-crime/);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -32,6 +32,25 @@ import type { Command, Dir, GameData } from '../contracts';
|
|||||||
|
|
||||||
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
|
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[] {
|
export function referenceFactory(data: GameData): Command[] {
|
||||||
// Fail loudly and specifically if DATA moves under us — a missing id here would
|
// Fail loudly and specifically if DATA moves under us — a missing id here would
|
||||||
// otherwise surface as a mystery deadlock hours later.
|
// 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)
|
P('gop-assembler', -10, 0); // assemble-gop (default)
|
||||||
runX(-7, -6, 0, E);
|
runX(-7, -6, 0, E);
|
||||||
P('mosh-reactor', -5, 0);
|
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);
|
P('shipper', 0, 0);
|
||||||
|
|
||||||
// Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
|
// Recovered anchor slabs come back out of the reactor, and they go two places: the
|
||||||
// i-only assembler: 8 slabs -> another GOP crate -> more melt.
|
// i-only assembler turns 8 of them into another GOP crate (more melt), and the rest are
|
||||||
runY(3, 4, -5, S);
|
// sold. The splitter is what makes it "and" rather than "or" — half the slabs to each,
|
||||||
const iOnly = P('gop-assembler', -6, 5);
|
// 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');
|
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;
|
return cmds;
|
||||||
}
|
}
|
||||||
|
|||||||
281
fktry/src/sim/research.test.ts
Normal file
281
fktry/src/sim/research.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createSim } from './index';
|
import { createSim } from './index';
|
||||||
import { makeRng } from './rng';
|
import { makeRng } from './rng';
|
||||||
|
import { grantAllResearch } from './testkit';
|
||||||
import type { Command, Dir, EntityState, GameData, RecipeDef, Sim, SimEvent } from '../contracts';
|
import type { Command, Dir, EntityState, GameData, RecipeDef, Sim, SimEvent } from '../contracts';
|
||||||
|
|
||||||
import items from '../../data/items.json';
|
import items from '../../data/items.json';
|
||||||
@ -26,6 +27,9 @@ const recipe = (id: string): RecipeDef => {
|
|||||||
function newSim(seed = 1337): Sim {
|
function newSim(seed = 1337): Sim {
|
||||||
const sim = createSim();
|
const sim = createSim();
|
||||||
sim.init(DATA, seed);
|
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;
|
return sim;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
fktry/src/sim/testkit.ts
Normal file
31
fktry/src/sim/testkit.ts
Normal 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));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user