File size: 12,216 Bytes
302a725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9af10ec
 
b88cc9c
302a725
 
021c865
302a725
9af10ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302a725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f96159
302a725
 
 
 
 
 
0974cb1
 
 
 
 
 
 
 
 
 
 
 
 
302a725
 
 
 
 
 
 
 
 
0974cb1
 
302a725
 
 
 
f1048a5
 
302a725
84220dc
 
 
 
 
 
 
0974cb1
302a725
 
 
 
 
84220dc
302a725
 
 
e505378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302a725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
021c865
b88cc9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
021c865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/* ============================================================
   Shared test fixtures + fingerprints. Two things live here so a dozen
   suites don't each re-roll them slightly differently:

   β€’ A byte-exact deterministic FINGERPRINT of engine state (entitySnapshot)
     and of a whole galaxy (galaxySnapshot). Unlike an ad-hoc snapshot these
     capture every sim-owned fact at FULL precision, in a fixed field order
     that's immune to Map-iteration / JSON-key order β€” so `equal(a, b)` is a
     true "these two runs are identical down to the last float" oracle.

   β€’ jumpReadyGalaxy(): the recurring "galaxy poised to launch an interplanetary
     jump" setup β€” start world settled, a finished Spaceport by the capital, a
     colony ship staged on the pad, credits to pay the fuel.
   ============================================================ */

import { readdirSync } from "node:fs";
import { join } from "node:path";
import { createGalaxy, activeState, addPlanet } from "../engine/galaxy.js";
import { makeBuilding, makeUnit } from "../engine/state.js";
import { deployColonyShip } from "../engine/colony.js";
import { THINK_INTERVAL } from "../engine/ai.js";

// Every .js file under `dir`, at ANY depth. The static guards (engine-purity, static-integrity)
// used a bare readdirSync, which is not recursive β€” so a file in an engine/ subdirectory escaped
// the determinism scan, the DOM scan, the parse check AND the import check all at once. Since
// engine-purity.test.js's own rationale is that the engine "could one day run server-side
// (netcode, replays)", the first thing that growth produces β€” engine/net/ β€” was exactly the
// blind spot. Both guards share this walker now so they can't drift apart again.
export function walkJs(dir) {
  const out = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
    const path = join(dir, entry.name);
    if (entry.isDirectory()) out.push(...walkJs(path));
    else if (entry.name.endsWith(".js")) out.push(path);
  }
  return out;
}

// mulberry32, kept local (identical to engine/rng.js): a test driving createGameState
// wants a varying-but-reproducible sequence without reaching into engine internals.
export function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Full-precision fingerprint of ONE engine state. Every field a same-seed replay must
// reproduce bit-for-bit: each entity's id/type/owner/position/hp/cargo/order, both
// economies, and how much fog is revealed. Entities are joined in a FIXED field order
// (not object-key order) and sorted by id, so neither Map iteration order nor JSON key
// order can leak in β€” only genuine value differences show up.
export function entitySnapshot(state) {
  const units = [...state.units.values()].map(u =>
    [u.id, u.type, u.owner, u.x, u.y, u.hp,
     u.cargo ? u.cargo.qty : "-", u.cargo ? (u.cargo.com || "-") : "-",
     u.order ? JSON.stringify(u.order) : "-"].join("|")
  ).sort();
  const builds = [...state.buildings.values()].map(b =>
    [b.id, b.type, b.owner, b.hp, b.buildProgress ?? "-", b.constructing ? 1 : 0,
     (b.queue || []).length, b.tier || 0, b.charge ?? "-"].join("|")
  ).sort();
  const res = JSON.stringify(state.players.player.resources) + "/" + JSON.stringify(state.players.ai.resources);
  const fog = state.fog.explored.reduce((a, v) => a + v, 0);
  return JSON.stringify({ units, builds, res, fog, tick: state.tick, time: state.time, over: state.over, winner: state.winner });
}

// JSON.stringify with keys sorted, recursively. The header above promises these fingerprints are
// "immune to Map-iteration / JSON-key order" β€” true of entitySnapshot, whose tuples are joined in a
// fixed field order, but NOT of the two raw JSON.stringify calls below. Live diplomacy grows its
// fields in whatever order the sim happens to assign them, while a loaded one grows them in the
// deserializer's order; identical values in a different insertion order then read as a divergence.
// That's a false failure about the fingerprint, not the sim β€” exactly what the promise rules out.
function stableJson(value) {
  if (value === null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return "[" + value.map(stableJson).join(",") + "]";
  return "{" + Object.keys(value).sort()
    .map(k => JSON.stringify(k) + ":" + stableJson(value[k])).join(",") + "}";
}

// Full-precision fingerprint of a whole galaxy: the meta fields plus, per planet (sorted
// by id), its entitySnapshot AND the galaxy-side running state that also has to survive a
// jump/reload β€” diplomacy stance, market pressure, background flag, AI wave clocks.
export function galaxySnapshot(galaxy) {
  const planets = [...galaxy.planets.entries()]
    .sort((a, b) => (a[0] < b[0] ? -1 : 1))
    .map(([id, s]) => [
      id, s.background ? 1 : 0,
      entitySnapshot(s),
      stableJson(s.diplomacy || {}),
      stableJson(s.market ? s.market.pressure : {}),
      // Normalise the AI wave clocks the way the engine (and serPlanet) do: an unset counter is
      // zero, an unscheduled wave is "none". Otherwise a live world's `undefined` vs a reloaded
      // world's serialize-defaulted 0/null would read as a difference when the sim treats them
      // identically β€” a false failure that isn't a real divergence.
      s.ai.nextWaveAt == null ? "-" : s.ai.nextWaveAt,
      Number(s.ai.waveCount) || 0,
    ].join("::"));
  // Freight Lanes + colony standing orders (P6 colony-economy rework): sorted by lane/planet id so
  // Map/array insertion order can't leak a false mismatch, only genuine value differences.
  const lanes = [...(galaxy.lanes || [])]
    .slice().sort((a, b) => (a.id < b.id ? -1 : 1))
    .map(l => [l.id, l.from, l.to, l.commodities.join(","), l.shipIds.join(",")].join("::"));
  const colonyPolicies = [...(galaxy.colonyPolicies || [])]
    .sort((a, b) => (a[0] < b[0] ? -1 : 1))
    .map(([id, p]) => [id, stableJson(p)].join("::"));
  return JSON.stringify({
    seed: galaxy.seed, credits: galaxy.credits, activeId: galaxy.activeId,
    tick: galaxy.tick, time: galaxy.time, entitySeq: galaxy.entitySeq,
    pacified: [...(galaxy.pacified || [])].sort(),
    reached: [...(galaxy.reached || [])].sort(),
    planets, lanes, colonyPolicies,
  });
}

// Every unit of matter `owner` holds anywhere on this world: the treasury, plus every buffer the
// domain can park goods in β€” a factory's input larder and output backlog, a freighter's freight
// hold, a worker's carried cargo. The economy's defining property is that matter MOVES between
// these; it isn't created or destroyed except where a rule says so. Nothing asserted that anywhere
// (no test summed holdings at all), which is why a wonder that kept eating a finished Gate's feed
// and a deposit that overfilled a hold both lived inside green suites.
export function totalHoldings(state, owner) {
  const out = {};
  const add = bag => { if (bag) for (const com in bag) out[com] = (out[com] || 0) + (bag[com] || 0); };
  add(state.players[owner].resources);
  for (const b of state.buildings.values()) {
    if (b.owner !== owner) continue;
    add(b.store);
    add(b.input);
  }
  for (const u of state.units.values()) {
    if (u.owner !== owner) continue;
    add(u.freight);
    if (u.cargo && u.cargo.com && u.cargo.qty > 0) add({ [u.cargo.com]: u.cargo.qty });
  }
  return out;
}

// A galaxy poised to launch an interplanetary jump FROM the start world: its colony ships
// deployed into Command Centers (a real settled base), a finished Spaceport next to the
// capital, a colony ship staged on the pad, and enough credits to fund the hop. The staple
// fixture for jump + jump-persist tests.
export function jumpReadyGalaxy(seed = 1) {
  const g = createGalaxy({ seed });
  const from = activeState(g);
  for (const u of [...from.units.values()]) if (u.type === "colonyship") deployColonyShip(from, u.id);
  const cc = [...from.buildings.values()].find(b => b.owner === "player" && b.type === "command");
  const sp = makeBuilding("spaceport", "player", cc.x + 40, cc.y);
  from.buildings.set(sp.id, sp);
  const ship = makeUnit("colonyship", "player", sp.x, sp.y);
  from.units.set(ship.id, ship);
  g.credits = 2000;
  return g;
}

/* ---------- which worlds a fresh galaxy is actually SIMULATING ----------

   createGalaxy brings up the start seat plus a seeded, pseudo-random handful of background
   worlds (engine/galaxy.js BACKGROUND_WORLDS / backgroundWorldIds); the rest of the roster stays
   DORMANT until the player jumps there. So a fixture that needs "some other world's state" has to
   ask for one that's actually alive, a fixture that needs an unbuilt destination asks for a
   dormant one, and a fixture whose subject is a galaxy-WIDE sweep (faction memory, expansion)
   wakes the whole roster up front rather than depending on which three the seed happened to draw. */

// Every world the galaxy is simulating apart from the active seat, in creation (roster) order.
export const liveWorlds = g => [...g.planets.keys()].filter(id => id !== g.activeId);

// The first of those β€” the plain "a background world" fixture.
export const liveWorld = g => liveWorlds(g)[0];

// A roster world the seed's draw did NOT bring up: on the starmap, jumpable, no state yet.
export const dormantWorld = g => g.worlds.find(id => !g.planets.has(id));

// Bring the WHOLE roster up as background worlds β€” the pre-bounded-draw shape, for fixtures that
// reason about the galaxy as a whole. Mirrors test/odyssey-meta.js's own inline idiom.
export function wakeRoster(g) {
  for (const id of g.worlds) if (!g.planets.has(id)) addPlanet(g, id, { unsettled: true });
  return g;
}

/* ============================================================
   DRIVING ACCUMULATING STATE AT THE PRODUCTION CADENCE

   Some engine state is not a fact about the current frame β€” it is carried forward and updated
   repeatedly (an opponent belief that fades, a damped stance that steps toward a target). For
   anything in that class, a test that JUMPS state.time and calls the function once is testing a
   code path the game never takes: runAI calls these every THINK_INTERVAL, so between two sightings
   they run dozens of times, and a per-call error that is invisible in one call compounds into a
   completely different curve.

   That is not hypothetical. engine/aiIntel.js shipped a fade documented as linear over four
   minutes that really emptied in about sixty seconds, because it re-applied its decay once per
   think cycle. Its tests β€” 441 lines of them β€” could not see it: they stepped time in one hop and
   asserted a DIRECTION ("it went down") where the docs specify a NUMBER.

   So: use this to advance an accumulating function, and assert the number.
   ============================================================ */

/**
 * Run `onThink` the way runAI does β€” once per think cycle β€” while advancing `state.time` by
 * `seconds` in total. The cadence defaults to the engine's own exported THINK_INTERVAL rather than
 * a copy, so a change to the AI's think rate reaches every test built on this automatically.
 *
 * @param {Object} state    anything carrying a numeric `.time` the callee reads
 * @param {number} seconds  sim seconds to advance
 * @param {(state: Object) => void} onThink   the accumulating update under test
 * @param {number} [cadence]
 */
export function advanceThinkCycles(state, seconds, onThink, cadence = THINK_INTERVAL) {
  const until = state.time + seconds;
  // Math.min on the last step so the total elapsed time is exactly `seconds` regardless of whether
  // it divides evenly by the cadence β€” otherwise a test's expected value silently drifts by up to
  // one cycle, which is precisely the kind of slop that hides a curve bug.
  while (state.time < until) {
    state.time = Math.min(until, state.time + cadence);
    onThink(state);
  }
  return state;
}