Compare commits

...

2 Commits

Author SHA1 Message Date
LANE-SIM
3e45ee2652 [sim] round 4 NOTES
Research provable end-to-end on DATA's real tree (realtech.test.ts), not
just a fixture. Cooler aura ~triples a hot generator's run. Constants
exported. Reference factory ships slabs.

For the orchestrator:
- the demo needs one line from you: the reference build uses two gated ids
  so it is a post-research save now. Dispatched cold it comes up 100 draw
  vs 80 gen and browns out -- I watched it. referenceFactoryTech(data)
  names the techs; testkit.grantResearch() opens them. Proposing a dev-only
  {kind:'grantResearch'} command instead of importing test helpers
- DATA fixed the tech bootstrap mid-round and was right to: it was circular
  and 0 of 24 techs were affordable at start
- asic-cooler still has no coolRadius, so its aura is inert in the game
- cooling cap 0.05/tick is my call; reasoning in NOTES

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:20:47 +10:00
LANE-SIM
039eff15fa [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>
2026-07-17 20:20:32 +10:00
12 changed files with 1003 additions and 35 deletions

View File

@ -388,3 +388,107 @@ NEXT (round 4 if asked)
- The Correction / parity mites (M3's enemy).
- Spatial heat + `coolRadius`, if the ASIC cooler is meant to mean something.
- A self-upgrading bloom recipe, if you want the codex's literal loop.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED
- **Research v1 + the v4 gating rule.** `kind:'lab'`, `setResearch`, `snapshot.research`,
the `researched` event, and gating: any id a tech's `unlocks` names is refused by
`place`/`setRecipe`/`setRecipeAt` until that tech lands; anything no tech mentions is free
forever (so data with no tech gates nothing). Labs take only packs the active tech still
needs, several labs feed one total, and research rides through save/load — as a *rule*,
not just a list, so a restored save can still build what it unlocked.
- **A real research run on DATA's own tree** (`realtech.test.ts`): ore → demuxer → quantizer
→ {crime → bricks, ringing → halos} + slurry → artifact bottler → SPATIAL PACK →
archaeology lab → tech complete → a recipe that was silently refused is now accepted.
Cold start, nothing granted. That's the DoD's unlock path on real data, not a fixture.
- **Spatial cooling** (`coolRadius`): chebyshev from the footprint, auras stack by sum,
total capped at `HEAT_COOL_MAX`. Proven by measuring the actual shed rate, and a cooler in
range roughly triples a hot generator's run before it scrams (2.5× the ticks).
- **`src/sim/constants.ts`** — 12 constants exported; DATA can import truth instead of
hand-copying numbers that go stale silently.
- **Reference factory v4**: anchor-slab overflow tap → the demo now ships slabs (18 over 20k),
melt still first at tick 1248, still shipping at 20k, still **zero brownout ticks**.
`referenceFactoryTech(data)` derives which techs the layout needs, from the commands.
- Also wired: `bandwidth.capacity`, `EntityState.scrammed`, `commissionQueue` in the snapshot
literal (v5-ready).
- Verified in the browser as well as Node — identical ticks (1248 / scram 435), gating live
(`mv-extractor` refused while locked), `capacity: 900`, 23 techs unlockable, no console
errors. 65 sim tests; 318 across the repo.
- Perf: **64.7× realtime** at 3,500 entities / 6,820 belt items (0.515 ms/tick, best of 3 on
a fresh page). Round 3 measured 74.9×. Nothing I added is meaningfully per-tick — `drainLabs`
early-outs when nothing is being researched, and cooling only touches machines that can
heat — so I can't attribute the gap, and I'm not going to pretend I can. It's either
environment noise or something small I haven't found; the headless benchmark I proposed last
round would settle it.
DECISIONS
- **Cooling cap = 0.05/tick, and the number is arbitrary but the reasoning isn't.** At 0.05 a
machine sheds a full 1.0 of heat in 20 ticks, which is already effectively instant. Past
that, more coolers buy nothing but still cost bandwidth and floor space — a stack that does
nothing reads as broken rather than generous. Retune freely; it's one constant now.
- A cooler's aura excludes itself (its own `coolPerTick` is already counted once), and the
cache is invalidated on place/remove/rotate but *not* setRecipe — auras are geometry.
- **A lab's appetite is whatever the active tech costs**, so switching targets re-ranks every
lane feeding it, and `setResearch` drops the route cache. Labs take only what the tech still
needs — a lab is not a warehouse, and packs hoarded for a tech nobody picked would strand
where no other lab could reach them.
- Switching research targets **abandons** delivered packs rather than banking them per-tech.
Packs are the cost of indecision. Say the word if you'd rather bank them.
- Completing a tech clears `active` rather than guessing a next target.
- A machine placed while some of its recipes are locked defaults to its **first unlocked**
recipe, not `recipes[0]`.
- `testkit.ts` (new, tree-shaken, imported by nothing in the app) grants research via a
save/load round-trip — the public API, not a back door. It's how a test says "this is a
late-game save" honestly.
BLOCKED/BROKEN
- **The demo browns out, and it needs one line from you.** The reference factory uses two
gated ids (`software-decoder` ← stream-software-decoding, `assemble-gop-i-only`
disc-i-only-cadence), so it is a post-research save by definition now. Dispatched cold
through `__fktryBus` it comes up **100 draw against 80 gen — BROWNOUT** with no decoder,
which I watched happen on screen. `__fktryDemo` must open those techs first;
`referenceFactoryTech(data)` tells you exactly which, and `testkit.grantResearch(sim, ids)`
will do it. The alternative is a dev-only `{kind:'grantResearch'}` command — your call,
since shipping testkit into main.ts is a bit grubby. A test pins both halves of this.
- **DATA fixed the tech tree underneath me mid-round, twice, and it was the right call both
times.** When I started: no `kind:'lab'` machine existed at all, and every science pack was
made on the artifact-bottler which was itself locked behind a tech costing 10 analog-packs
— a hard circular dependency, **0 of 24 techs affordable at start**, the tree literally
unenterable. It now has `archaeology-lab`, spatial-packs craftable with zero research, and
6 techs affordable cold. `realtech.test.ts` is the tripwire if that bootstrap ever closes
over again — it's worth keeping for exactly that reason.
- Casualty of the churn: my first pass hard-coded `mosh-reactor` as "a gated machine" and
broke when DATA un-gated it an hour later. Round 1's lesson, relearned. The real-tree
tests now *derive* a gated and an ungated machine from the data.
- `asic-cooler` has `coolPerTick: 0.02` but still no `coolRadius`, so its aura is inert in the
real game — it cools only itself, and it has no heat to shed. The mechanic is live and
tested on a fixture; DATA just needs the number. (This was my round-3 note; the field now
exists for them.)
- Vitest caches JSON imports: I spent a while chasing a "gating is broken" ghost that was a
stale `tech.json` in `node_modules/.vite` while DATA edited underneath. Worth knowing if
anyone else sees data that doesn't match disk.
CONTRACT REQUEST
- None. v4 had everything.
PROPOSAL
- **A dev-only `{kind:'grantResearch'; tech: string[]}` command** would let `__fktryDemo` set
up a late-game board without importing test helpers into the app, and would give UI a way to
build a "sandbox" mode later. It's the cleanest fix for the brownout above.
- The mosh reactor now needs **two** melt lanes in the reference layout, and that's a smell
worth knowing about rather than a bug: a machine pushes one item per output type per tick,
so a 3-melt craft trickles out over several ticks, and while its own lane is busy melt is
free to take any *other* rank-1 lane. Nothing is lost — it still reaches an uplink — but it
can wander into a lane meant for something else. If that ever bites somewhere it matters,
the fix is a per-item lane affinity, not more belts.
- `Sim.save/load` are hardened in v4 and `testkit` depends on the blob shape (`.research`).
If the save format ever versions past 3, testkit moves with it.
NEXT (round 5 if asked)
- HF dust piles + mosquito swarms — the RNG is still unrolled and save/load already carries
its state, so wildlife lands without touching persistence.
- The Correction / parity mites.
- A headless perf benchmark, so the numbers above stop being anecdotes.
- Research *time* (a lab currently counts a pack the tick it lands) if you want the tech tree
to feel like an investment rather than a purchase.

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