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