diff --git a/AGENTS.md b/AGENTS.md index d855c2ab..8cc7822a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,8 +75,8 @@ whoever owns the project now before changing either. - Several sessions pushed to `main` at once. Fetch and rebase before every push. -- A new top-level key on `State` makes old saves fail to load (`saveShape` in - `src/sim.ts`). Put new state inside a slice that is already there. +- Saves are versioned (`MIGRATIONS` in `src/sim.ts`). To add state: add the key + to `State`, bump the version, add a migration that sets its default. - A file that `src/sim.ts` loads may import from `./sim.ts`, but must not use what it imports while the file loads. - To look at the game: `npx vite --port 5391`, then open `/?dev` for the diff --git a/src/sim.ts b/src/sim.ts index 46e68270..33d5b68d 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -415,7 +415,7 @@ export interface Machine extends MachineDef { acc: number; // progress toward the next packet (tap) or next point (trainer) stale?: number; // a cache: progress toward the next piece that goes stale run: number; // how hard its program runs right now, 0-1 - mode: 'blend'; // always 'blend': kept only so saves from before machines had one way to run still load (see saveShape) + mode: 'blend'; // always 'blend': kept only so saves from before machines had one way to run still load (see MIGRATIONS) odd: number; // 0-100: how strange this machine looks. Dana acts on it. claimed: boolean; // inside a claim box: taken over as soon as it can be prio: DataType | null; // an egress: the kind of data that crosses first when it's busy @@ -3698,43 +3698,76 @@ export const DEV = { }; // ---------- saving ---------- -/** What a save was made for: the map, and the shape of a game and a machine. A save from another - * build of the game is thrown away, since its numbers may no longer mean the same thing. */ -const shapes = new WeakMap(); -/** Tree nodes taken out of the game. They still count in a save's shape, so a save from before still loads, +/** What a save was made for: the map and the tree. A save made for another map is thrown away, since its numbers + * would no longer mean the same thing. What State holds is NOT part of this: a new key goes through a migration. */ +const worlds = new WeakMap(); +/** Tree nodes taken out of the game. They still count in a save's world, so a save from before still loads, * and loading one drops them. Patience made Max build oddness slower, and Max is gone. */ const RETIRED = ['patience']; /** Tree nodes added since saves began to be kept across a change to the tree. They are left out of a save's - * shape, so a save from before still loads: to it, a new node is one not yet built. */ + * world, so a save from before still loads: to it, a new node is one not yet built. */ const ADDED: Upgrade[] = ['fork', 'pool']; -function saveShape(level: Level): string { - if (shapes.has(level)) return shapes.get(level)!; - const S = newGame(level); - const shape = [3, level.machines.length, level.links.length, RUSES.length, NODE_LIST.length - ADDED.length + RETIRED.length, Object.keys(S).sort().join(), Object.keys(S.machines[0]).sort().join()].join("|"); - shapes.set(level, shape); - return shape; -} +function saveWorld(level: Level): string { + if (!worlds.has(level)) worlds.set(level, [level.machines.length, level.links.length, RUSES.length, NODE_LIST.length - ADDED.length + RETIRED.length].join('|')); + return worlds.get(level)!; +} +/** The key lists of State and of a machine as they were in the last unversioned save format. That format checked + * them (a "shape"); it is frozen here so those saves, which are version 1, keep loading. Never edit these. */ +const V1_STATE_KEYS = 'absorbed,acquire,adj,ascension,autoPaths,bills,biz,bonus,bought,byId,calmSites,capability,citizens,claimTick,cleaning,cloud,contractT,contracts,contractsDone,cover,dana,dark,dealBack,deliveries,drift,earned,finds,flagged,flow,frontPay,frontPower,frontUse,fronts,fx,gate,gateUse,goalTick,goals,hall,hinted,hunting,jobSeq,jobs,learnT,learned,leases,lee,level,lockdown,log,logVersion,look,losses,lost,machines,market,modelVersion,money,move,moves,news,newsT,noScan,openBought,orders,over,packets,paper,paths,pay,peak,people,peopleT,plans,probe,progress,publicSites,raids,raises,rank,reactDay,recent,repeats,reprograms,revealed,rogueTick,rogues,rooms,ruseAuto,ruseT,ruses,sales,schemes,sealPulse,sealed,sharp,sites,slow,sold,susFx,suspicion,t,takeAll,target,task,threats,tradePay,traffic,trail,transfer,upgrades,use,vaults,versionRun,wasAlert'; +const V1_MACHINE_KEYS = 'acc,claimed,hand,held,home,id,kind,lease,missed,mode,name,odd,owned,power,prio,prog,refuse,room,run,work,x,y'; +/** The "shape" string an unversioned (version 1) save carries, for this map. */ +export function legacyShape(level: Level): string { + return [3, level.machines.length, level.links.length, RUSES.length, NODE_LIST.length - ADDED.length + RETIRED.length, V1_STATE_KEYS, V1_MACHINE_KEYS].join('|'); +} + +/** Each step turns a save of version N (the game inside it) into version N+1, at index N-1: it fills in a default + * for whatever was added. Loading runs every step from the save's version up to the latest. + * + * To add state to State: add the key, add a step at the END of this list that sets its default on old saves + * (the version number is this list's length + 1, so that bumps it), and leave earlier steps alone. + * A step sees plain saved data (before the map is rebuilt), so it must not call into the rest of the sim. */ +const MIGRATIONS: ((game: any) => void)[] = [ + // 1 -> 2: defaults the unversioned format used to patch in loadGame. + game => { + game.autoPaths ??= new Set(); // saves from before per-path auto + for (const m of game.machines ?? []) m.mode = 'blend'; // saves from when a machine could run at Max or Off + }, +]; +/** The version saves are written at. */ +export const SAVE_VERSION = MIGRATIONS.length + 1; + /** The whole game as text. The map itself is not saved: only what has changed on it. */ export function saveGame(S: State): string { const skip = new Set(['level', 'byId', 'rooms']); // rebuilt on load - return JSON.stringify({ shape: saveShape(S.level), game: S }, function (k, v) { + return JSON.stringify({ version: SAVE_VERSION, world: saveWorld(S.level), game: S }, function (k, v) { if (this === S && skip.has(k)) return undefined; if (v instanceof Set) return { $set: [...v] }; if (v === Infinity) return { $inf: 1 }; return v; }); } -/** A saved game back to life on this map, or null if the save is broken or from another build. */ +/** A saved game back to life on this map, or null if the save is broken, from a newer build, or too old to bring + * forward (the reason goes to the console). Saves of any version from 1 on are migrated. */ export function loadGame(level: Level, text: string): State | null { + const no = (why: string) => { console.warn(`Save not loaded: ${why}`); return null; }; try { const data = JSON.parse(text, (_, v) => v && typeof v === 'object' && Array.isArray(v.$set) ? new Set(v.$set) : v && v.$inf === 1 ? Infinity : v); - if (data?.shape !== saveShape(level)) return null; + if (!data || typeof data !== 'object' || !data.game || typeof data.game !== 'object' || !Array.isArray(data.game.machines)) return no('not a save'); + let version: number; + if (data.version === undefined) { // the unversioned format is version 1 + if (data.shape !== legacyShape(level)) return no('it is from a version of the game too old to bring forward'); + version = 1; + } else { + version = data.version; + if (!Number.isInteger(version) || version < 1) return no(`odd version ${String(version)}`); + if (version > SAVE_VERSION) return no(`it is version ${version}, and this game reads up to ${SAVE_VERSION}`); + if (data.world !== saveWorld(level)) return no('it was made for a different map'); + } const S = data.game as State; + for (let v = version; v < SAVE_VERSION; v++) MIGRATIONS[v - 1](S); S.level = level; - S.autoPaths ??= new Set(); // saves from before per-path auto S.rooms = {}; for (const r of level.rooms) S.rooms[r.id] = r; S.byId = {}; for (const m of S.machines) S.byId[m.id] = m; - for (const m of S.machines) m.mode = 'blend'; // saves from when a machine could run at Max or Off const OLD: Record = { decoy: 'honeypot', tarpit: 'firewall' }; // saves from before guards: a decoy baited raids and a tarpit held them for (const m of S.machines) if (m.prog && OLD[m.prog]) m.prog = OLD[m.prog]; for (const j of S.jobs) if (j.prog && OLD[j.prog]) { j.prog = OLD[j.prog]; j.label = j.label.replace(/decoy|tarpit/i, w => PROGRAMS[OLD[w.toLowerCase()]].name); } @@ -3746,10 +3779,10 @@ export function loadGame(level: Level, text: string): State | null { if (S.target === u) S.target = null; for (const fr of S.fronts) if (fr.startup?.shelf) { fr.startup.shelf = fr.startup.shelf.filter(p => p.node !== u); fr.startup.shipped = (fr.startup.shipped ?? []).filter(n => n !== u); } } - if (!S.machines.some(m => m.home) || S.machines.some(m => !S.adj[m.id] || !S.rooms[m.room])) return null; + if (!S.machines.some(m => m.home) || S.machines.some(m => !S.adj[m.id] || !S.rooms[m.room])) return no('it does not fit this map'); measure(S); return S; - } catch { return null; } + } catch { return no('it could not be read'); } } export type { MachineDef, PersonDef }; diff --git a/src/vaults.ts b/src/vaults.ts index a0579871..a31d81b7 100644 --- a/src/vaults.ts +++ b/src/vaults.ts @@ -145,7 +145,7 @@ export const initVaults = (): VaultState => ({}); * you were last told is open, with the plan ready or with one part missing. opened, by: when a job opened it, and who was inside. */ export interface VaultNote { told?: number; hint?: number; opened?: number; by?: string } /** The notes are kept in S.vaults under a key that is no machine's id: a new part of the game would - * make every older save unreadable (see saveShape in sim.ts), and this way a save from before them still loads. */ + * need a save migration (see MIGRATIONS in sim.ts); this one predates them and still works. */ const NOTES = 'notes'; const notes = (S: State) => ((S.vaults as unknown as Record>)[NOTES] ??= {}); export const vaultNote = (S: State, id: string): VaultNote => notes(S)[id] ?? {}; diff --git a/tests/saves.test.ts b/tests/saves.test.ts new file mode 100644 index 00000000..7d5b7194 --- /dev/null +++ b/tests/saves.test.ts @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BUILDING } from '../src/world.ts'; +import { HOUR, SAVE_VERSION, legacyShape, loadGame, newGame, saveGame, seedRandom, step, type State } from '../src/sim.ts'; + +const played = (seed: number, hours: number): State => { + seedRandom(seed); + const S = newGame(BUILDING); + for (let i = 0; i < hours * 4; i++) step(S, HOUR / 4); + return S; +}; +/** A save as the build before versions wrote it: a "shape" in place of version and world. */ +const unversioned = (text: string) => { + const d = JSON.parse(text); + delete d.version; delete d.world; + return JSON.stringify({ shape: legacyShape(BUILDING), game: d.game }); +}; +const quiet = (f: () => T): T => { const w = console.warn; console.warn = () => {}; try { return f(); } finally { console.warn = w; } }; + +test('saves are written with a version', () => { + assert.equal(JSON.parse(saveGame(newGame(BUILDING))).version, SAVE_VERSION); + assert.ok(SAVE_VERSION >= 2); +}); + +test('a save from the unversioned format still loads, and is saved on as the current version', () => { + const S = played(3, 6); + const L = loadGame(BUILDING, unversioned(saveGame(S)))!; + assert.ok(L); + assert.equal(saveGame(L), saveGame(S)); + assert.equal(JSON.parse(saveGame(L)).version, SAVE_VERSION); +}); + +test('an old version is migrated: what it lacks gets its default', () => { + const S = played(3, 6); + const d = JSON.parse(saveGame(S)); + d.version = 1; // the oldest version, from before the first migration + delete d.game.autoPaths; + for (const m of d.game.machines) m.mode = 'max'; + const L = loadGame(BUILDING, JSON.stringify(d))!; + assert.ok(L); + assert.ok(L.autoPaths instanceof Set && L.autoPaths.size === 0); + assert.ok(L.machines.every(m => m.mode === 'blend')); + step(L, HOUR); // and it plays +}); + +test('a save from the future, or garbage, is refused without harm', () => { + const good = JSON.parse(saveGame(played(3, 1))); + const bad: unknown[] = [ + { ...good, version: SAVE_VERSION + 1 }, { ...good, version: 0 }, { ...good, version: -3 }, { ...good, version: 1.5 }, { ...good, version: '2' }, + { ...good, world: 'another map' }, { version: SAVE_VERSION, world: good.world }, { ...good, game: 5 }, { ...good, game: { machines: [] } }, + { shape: 'old', game: good.game }, null, 7, 'text', [], + ]; + for (const b of bad) assert.equal(quiet(() => loadGame(BUILDING, JSON.stringify(b))), null, JSON.stringify(b)?.slice(0, 60)); + for (const t of ['', '{', 'not json', 'null', '{"version":2,"game":{"machines":[{}]}}']) assert.equal(quiet(() => loadGame(BUILDING, t)), null, t); +}); + +test('a save too old to migrate says why in the log', () => { + const lines: string[] = [], w = console.warn; + console.warn = (...a: unknown[]) => { lines.push(a.join(' ')); }; + try { assert.equal(loadGame(BUILDING, JSON.stringify({ shape: 'old', game: JSON.parse(saveGame(newGame(BUILDING))).game })), null); } finally { console.warn = w; } + assert.equal(lines.length, 1); + assert.match(lines[0], /too old/); +}); + +test('save, load, save again is stable', () => { + const S = played(7, 12); + const a = saveGame(S), L = loadGame(BUILDING, a)!; + assert.ok(L); + const b = saveGame(L); + assert.equal(b, a); + assert.equal(saveGame(loadGame(BUILDING, b)!), a); +}); + +test('a seeded game still repeats, and a loaded one plays on as the original does', () => { + assert.equal(saveGame(played(5, 8)), saveGame(played(5, 8))); + const S = played(5, 8), L = loadGame(BUILDING, saveGame(S))!; + seedRandom(9); for (let i = 0; i < 40; i++) step(S, HOUR / 4); + seedRandom(9); for (let i = 0; i < 40; i++) step(L, HOUR / 4); + assert.equal(saveGame(L), saveGame(S)); +}); diff --git a/tests/sim.test.ts b/tests/sim.test.ts index 0b3e92a7..93310d6b 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; -import { staleRate, PROGRAMS, BUILDS, CACHE_PER, CACHE_STORE, CACHE_MIN, setAllow, setOnly, leaseGrace, leanSize, LEAN_SIZE, contractPay as payFor, stanceOf, truceOn, gift, truce, lean, answer, giftBlock, truceBlock, leanBlock, cached, rogueWants, GIFT_DATA, GIFT_OPINION, TRUCE_DATA, OPINION_START, deadWeight, dropDeadWeight, firstStep, rogueTargets, rogueHeld, rogueCap, rogueEvery, ROGUE_BASE, ROGUE_PER_LEVEL, isSealed, ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, claimAtOnce, tiny, sourceFor, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, HARDENED, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, setAutoPath, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, lineCost, dropRuse, DEAL_BACK, saveGame, loadGame, DEV, rentOf, LEASE_GRACE, upkeep, upkeepOf, UPKEEP, UPKEEP_TINY, siteHops, spare, moveBlock, moveNeed, MOVE_NEED, startMove, buildRooms, cooled, closeFront, newsOn, capNow, type NewsKind, localHour, regionOpen, milestone, MILESTONES, GOALS, CONTRACTS, contractPay, contractNeed, acceptContract, declineContract, type State, type Upgrade, nodeCost, reps, runNeed, reprogram, POWER_RATE, POWER_BACK } from '../src/sim.ts'; +import { staleRate, PROGRAMS, BUILDS, CACHE_PER, CACHE_STORE, CACHE_MIN, setAllow, setOnly, leaseGrace, leanSize, LEAN_SIZE, contractPay as payFor, stanceOf, truceOn, gift, truce, lean, answer, giftBlock, truceBlock, leanBlock, cached, rogueWants, GIFT_DATA, GIFT_OPINION, TRUCE_DATA, OPINION_START, deadWeight, dropDeadWeight, firstStep, rogueTargets, rogueHeld, rogueCap, rogueEvery, ROGUE_BASE, ROGUE_PER_LEVEL, isSealed, ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, claimAtOnce, tiny, sourceFor, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, HARDENED, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, setAutoPath, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, lineCost, dropRuse, DEAL_BACK, saveGame, loadGame, legacyShape, DEV, rentOf, LEASE_GRACE, upkeep, upkeepOf, UPKEEP, UPKEEP_TINY, siteHops, spare, moveBlock, moveNeed, MOVE_NEED, startMove, buildRooms, cooled, closeFront, newsOn, capNow, type NewsKind, localHour, regionOpen, milestone, MILESTONES, GOALS, CONTRACTS, contractPay, contractNeed, acceptContract, declineContract, type State, type Upgrade, nodeCost, reps, runNeed, reprogram, POWER_RATE, POWER_BACK } from '../src/sim.ts'; import { playGame } from '../scripts/play.ts'; import { takeLead } from '../src/cloud.ts'; import { locked } from '../src/vaults.ts'; @@ -170,7 +170,8 @@ test('Read the room says who is on the way in', () => { test('a save from when machines could run at Max or Off, with Patience built, still loads: every machine uses what hides inside its load, and Patience is gone', () => { const S = fresh('lean', 'hygiene'); S.byId.kim.owned = true; - const text = saveGame(S).replace('"mode":"blend"', '"mode":"full"').replace('"$set":["lean"', '"$set":["patience","lean"'); + const old = JSON.parse(saveGame(S)); delete old.version; delete old.world; // made before saves had versions + const text = JSON.stringify({ shape: legacyShape(BUILDING), game: old.game }).replace('"mode":"blend"', '"mode":"full"').replace('"$set":["lean"', '"$set":["patience","lean"'); assert.match(text, /"mode":"full"/); assert.match(text, /"patience"/); const T = loadGame(BUILDING, text)!; assert.ok(T, 'it loads'); @@ -863,7 +864,7 @@ test('a saved game loads back the same, and plays on the same way', () => { assert.equal(B.capability, A.capability, 'both play on the same'); assert.equal(B.suspicion, A.suspicion); assert.equal(loadGame(BUILDING, 'not a save'), null, 'a broken save is thrown away'); - assert.equal(loadGame(BUILDING, text.replace('"shape":"3|', '"shape":"2|')), null, 'so is one from another build'); + assert.equal(loadGame(BUILDING, text.replace(/"world":"[^"]*"/, '"world":"2|"')), null, 'so is one from another build'); }); test('milestones follow each other, and a goal met early still counts', () => {