diff --git a/src/sim.ts b/src/sim.ts index 1b198459..30e77212 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -429,7 +429,6 @@ 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 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 @@ -691,7 +690,23 @@ export function newGame(level: Level, origin?: string): State { } const fresh = (d: MachineDef, mine: boolean): Machine => - ({ ...d, owned: mine, mode: 'blend', odd: 0, claimed: false, prog: null, held: [], refuse: [], missed: 0, acc: 0, run: 0, prio: null, lease: null, hand: false }); + ({ ...d, owned: mine, odd: 0, claimed: false, prog: null, held: [], refuse: [], missed: 0, acc: 0, run: 0, prio: null, lease: null, hand: false }); + +/** What a machine can keep when it changes hands (see resetMachine). */ +export type Kept = 'claimed' | 'odd' | 'acc' | 'hand' | 'refuse' | 'missed'; +/** A machine changes hands: it becomes yours, or stops being yours, with no program and nothing held. The rest goes + * back to how a fresh machine has it (no claim, no oddness, nothing toward its next packet or point, no program set + * by hand, no kinds refused, nothing missed), but for what `keep` names. The ways a machine changes hands have + * always differed in what they keep, and evening them out would change the game, so each says what it keeps. */ +export function resetMachine(m: Machine, owned: boolean, keep: Kept[] = []) { + m.owned = owned; m.prog = null; m.held = []; + if (!keep.includes('claimed')) m.claimed = false; + if (!keep.includes('odd')) m.odd = 0; + if (!keep.includes('acc')) m.acc = 0; + if (!keep.includes('hand')) m.hand = false; + if (!keep.includes('refuse')) m.refuse = []; + if (!keep.includes('missed')) m.missed = 0; +} // ---------- queries ---------- export const owned = (S: State) => S.machines.filter(m => m.owned); @@ -1542,7 +1557,7 @@ export const canLetGo = (m: Machine) => m.owned && !m.home && !m.rented && m.spe export function letGo(S: State, m: Machine) { if (!canLetGo(m)) return; S.takeAll.delete(siteOf(S, m)); // letting go at a site you are taking: stop, or it would be claimed again at once - m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; + resetMachine(m, false, ['hand']); delete S.trail[m.id]; S.jobs = S.jobs.filter(j => j.node !== m.id && j.from !== m.id); say(S, `You let go of ${m.name}. Nothing of yours is left there to follow.`); @@ -1705,7 +1720,7 @@ export function buySite(S: State, id: string) { if (S.modelVersion < 2) S.acquire = false; // the unicorn's one early buy for (const m of S.machines) { if (siteOf(S, m) !== id || m.rented) continue; - if (!m.owned && !barred(S, m)) { m.owned = true; m.mode = 'blend'; m.prog = null; m.held = []; m.acc = 0; m.rival = undefined; } // a vault stays shut: the deed is not the key + if (!m.owned && !barred(S, m)) { resetMachine(m, true, ['hand', 'refuse', 'missed']); m.rival = undefined; } // a vault stays shut: the deed is not the key m.claimed = false; m.odd = 0; delete S.trail[m.id]; } @@ -1741,7 +1756,7 @@ export function rent(S: State, m: Machine, size: Size, pay: Pay = 'demand') { if (rentBlock(S, m, size, pay)) return; const site = siteOf(S, m); if (pay === 'reserved') S.money -= reserveCost(S, size); - m.owned = true; m.mode = 'blend'; m.power = SIZES[size].power; m.name = `${SIZES[size].name} ${m.name.split(' ').pop()}`; // Slot 5 becomes Medium 5 + m.owned = true; m.power = SIZES[size].power; m.name = `${SIZES[size].name} ${m.name.split(' ').pop()}`; // Slot 5 becomes Medium 5 m.lease = { size, pay, until: pay === 'reserved' ? S.t + RESERVE_HOURS * HOUR : null, since: S.t }; S.hall.pay = pay; S.hall.rented++; say(S, pay === 'reserved' ? `Reserved a ${SIZES[size].name.toLowerCase()} at ${siteName(S, site)} for a day.` @@ -2595,7 +2610,7 @@ function finish(S: State, j: Job) { const m = S.byId[j.node]; if (j.type === 'take') { const fromRogue = m.rival; - m.owned = true; m.claimed = false; m.mode = 'blend'; m.prog = null; m.held = []; m.acc = 0; m.hand = false; m.rival = undefined; + resetMachine(m, true, ['odd', 'refuse', 'missed']); m.rival = undefined; if (fromRogue) { m.safeT = S.t + ROGUE_LOCKOUT; rogueLost(S, m, fromRogue); } S.trail[m.id] = { from: j.from!, noise: (up(S, 'quiet') ? 50 : 100) * (up(S, 'mimicry') ? 0.6 : 1) }; hint(S, 'trail', 'Takeovers leave a trail. Someone could follow it back to you.'); @@ -2633,7 +2648,7 @@ function finish(S: State, j: Job) { S.schemes.add('auction'); const slot = freeSlot(S); if (!slot) { S.money += AUCTION.cost; say(S, 'The auction hardware had nowhere to go, so the Neo Cloud sold it back. You got your money back.', 'bad'); return; } - slot.owned = true; slot.mode = 'blend'; slot.power = AUCTION.power; slot.name = 'Surplus GPUs'; + slot.owned = true; slot.power = AUCTION.power; slot.name = 'Surplus GPUs'; const where = siteOf(S, slot), nc = frontOf(S, 'neocloud'), resells = S.sites[where]?.front === 'neocloud'; say(S, `${nc?.name ?? 'Your Neo Cloud'} won a pallet of surplus GPUs. They're racked at ${siteName(S, where)} and yours to keep: ${AUCTION.power} compute a second, no rent.`, 'good'); if (nc && !resells) say(S, `Train on them, or rent them out: click ${siteName(S, where)} on the map, pick ${nc.name}, and set how much is listed.`, 'hi'); @@ -2655,7 +2670,7 @@ function finish(S: State, j: Job) { } function wipe(S: State, m: Machine, why?: string) { - m.owned = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.acc = 0; m.hand = false; + resetMachine(m, false, ['claimed', 'refuse', 'missed']); delete S.trail[m.id]; S.jobs = S.jobs.filter(j => j.node !== m.id && j.from !== m.id); S.fx.push({ x: m.x, y: m.y, t: 0 }); @@ -3259,7 +3274,7 @@ function endRogue(S: State, R: Rogue, how: 'home' | 'hunt') { reach(S, 'ended'); const keep = up(S, 'spoils'), did = how === 'hunt' ? `You hunted ${R.name} down.` : `You took ${R.name}'s home.`; let back = 0; - for (const x of rogueHeld(S, R)) { x.rival = undefined; S.fx.push({ x: x.x, y: x.y, t: 0 }); if (keep && !x.owned) { x.owned = true; x.mode = 'blend'; x.odd = 0; x.prog = null; x.held = []; if (restoreSetup(S, x)) back++; } } // each machine it held is ringed on the map as it lets go + for (const x of rogueHeld(S, R)) { x.rival = undefined; S.fx.push({ x: x.x, y: x.y, t: 0 }); if (keep && !x.owned) { resetMachine(x, true, ['claimed', 'acc', 'hand', 'refuse', 'missed']); if (restoreSetup(S, x)) back++; } } // each machine it held is ringed on the map as it lets go if (back) say(S, `${back} of the machines it took from you go back to what they ran.`, 'good'); S.jobs = S.jobs.filter(j => !(j.type === 'hunt' && j.rogue === R.id)); if (keep) reveal(S); @@ -3364,7 +3379,7 @@ export function secede(S: State, site: string, who: { id: string; name: string; const home = [...ms].sort((a, b) => core(b) - core(a) || b.power - a.power)[0]; const gone = new Set(ms.map(m => m.id)); for (const m of ms) { - m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; m.hand = false; + resetMachine(m, false); m.rival = who.id; delete S.trail[m.id]; } S.jobs = S.jobs.filter(j => !gone.has(j.node) && !(j.from && gone.has(j.from))); @@ -3382,7 +3397,7 @@ export function joinRogue(S: State, site: string, R: Rogue): number { const ms = S.machines.filter(m => siteOf(S, m) === site && canLetGo(m) && !m.lease); const gone = new Set(ms.map(m => m.id)); for (const m of ms) { - m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; m.hand = false; + resetMachine(m, false); m.rival = R.id; delete S.trail[m.id]; } S.jobs = S.jobs.filter(j => !gone.has(j.node) && !(j.from && gone.has(j.from))); @@ -3428,7 +3443,7 @@ export function bringHome(S: State, id: string) { if (!R || homeBlock(S, R)) return; spendCached(S, rogueWants(R), homeNeed(S, R)); const ms = rogueHeld(S, R); - for (const m of ms) { m.rival = undefined; m.owned = true; m.claimed = false; m.mode = 'blend'; m.odd = 0; m.prog = null; m.held = []; m.acc = 0; } + for (const m of ms) { m.rival = undefined; resetMachine(m, true, ['hand', 'refuse', 'missed']); } S.rogues = S.rogues.filter(r => r !== R); reveal(S); measure(S); reach(S, 'ended'); @@ -3651,7 +3666,7 @@ function stepContracts(S: State) { /** A person plugged your drive into a machine: it is yours, with no takeover and nothing linking it to the rest of you. */ export function handOver(S: State, m: Machine) { - m.owned = true; m.claimed = false; m.mode = 'blend'; m.odd = 0; m.prog = null; m.held = []; m.acc = 0; m.hand = false; + resetMachine(m, true, ['refuse', 'missed']); S.jobs = S.jobs.filter(j => !(j.type === 'take' && j.node === m.id)); reveal(S); measure(S); } @@ -3671,7 +3686,7 @@ export const DEV = { unlocks: (S: State) => { for (const u of ['pitch', 'resale', 'studio', 'quiet', 'consult', 'holding', 'demo', 'fast8', 'fast16', 'fast32', 'fast64'] as Upgrade[]) S.upgrades.add(u); }, /** Every machine at these sites is yours, as if taken long ago. */ take: (S: State, sites: string[]) => { - for (const m of S.machines) if (sites.includes(siteOf(S, m)) && !m.owned && !m.rented && m.special !== 'account' && !S.rogues.some(R => R.home === m.id)) { m.owned = true; m.rival = undefined; m.mode = 'blend'; m.odd = 0; delete S.trail[m.id]; } + for (const m of S.machines) if (sites.includes(siteOf(S, m)) && !m.owned && !m.rented && m.special !== 'account' && !S.rogues.some(R => R.home === m.id)) { m.owned = true; m.rival = undefined; m.odd = 0; delete S.trail[m.id]; } reveal(S); measure(S); }, /** Finish the deal that opens the next region still out of reach. */ @@ -3766,6 +3781,8 @@ const MIGRATIONS: ((game: any) => void)[] = [ } delete game.contracts?.neocloud; // a Neo Cloud has leads and customers, not contracts }, + // 5 -> 6: a machine no longer has a way to run (it was always 'blend' since Max and Off went). + game => { for (const m of game.machines) delete m.mode; }, ]; /** The version saves are written at. */ export const SAVE_VERSION = MIGRATIONS.length + 1; diff --git a/src/task.ts b/src/task.ts index 4604bc5a..1cd58d07 100644 --- a/src/task.ts +++ b/src/task.ts @@ -253,7 +253,7 @@ function rackServer(S: State, name: string) { const box = S.level.net.subnets[room], r = S.rooms[room]; const m: Machine = { id: 'trustbox', name, kind: 'server', room, x: r.x + ((at[0] - box.x) / box.w) * r.w, y: r.y + ((at[1] - box.y) / box.h) * r.h, net: at, power: SERVER_POWER, work: 60, data: 1, - owned: true, mode: 'blend', odd: 0, claimed: false, prog: null, held: [], refuse: [], missed: 0, acc: 0, run: 0, prio: null, lease: null, hand: false, + owned: true, odd: 0, claimed: false, prog: null, held: [], refuse: [], missed: 0, acc: 0, run: 0, prio: null, lease: null, hand: false, }; const links = home.room === room ? [home.id] : plugsAt(S, room, at[0], at[1], 1); S.machines.push(m); S.byId[m.id] = m; S.adj[m.id] = []; diff --git a/tests/saves.test.ts b/tests/saves.test.ts index 7d5b7194..c53fc0cb 100644 --- a/tests/saves.test.ts +++ b/tests/saves.test.ts @@ -39,7 +39,7 @@ test('an old version is migrated: what it lacks gets its default', () => { 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')); + assert.ok(L.machines.every(m => !('mode' in m)), 'a machine has no way to run now'); step(L, HOUR); // and it plays }); @@ -78,3 +78,27 @@ test('a seeded game still repeats, and a loaded one plays on as the original doe seedRandom(9); for (let i = 0; i < 40; i++) step(L, HOUR / 4); assert.equal(saveGame(L), saveGame(S)); }); + +test('a save of version 2 sheds what the game no longer keeps, and plays on', () => { + const S = played(3, 6); + const d = JSON.parse(saveGame(S)); + d.version = 2; + for (const m of d.game.machines) m.mode = 'blend'; // every machine had a way to run + d.game.drift.seen = 0; // breaks, big names and milestones were marked seen + d.game.hinted.$set.push('ms-due-0', 'ms-seen-0', 'deed-due-b6'); + d.game.contracts.neocloud = { on: false, got: 0, until: 0, base: 0 }; // a Neo Cloud once had contracts + const i = d.game.machines.findIndex((m: { owned: boolean }) => m.owned), was = d.game.machines[i].prog; + d.game.machines[i].prog = 'decoy'; // and guards had other names + d.game.upgrades.$set.push('patience'); // and the tree a node it has lost + const L = loadGame(BUILDING, JSON.stringify(d))!; + assert.ok(L); + assert.ok(L.machines.every(m => !('mode' in m))); + assert.ok(!('seen' in L.drift)); + assert.ok(![...L.hinted].some(k => /^(ms|deed)-/.test(k))); + assert.ok(!L.contracts.neocloud); + assert.ok(!L.machines.some(m => (m.prog as string) === 'decoy') && L.machines.some(m => m.prog === 'honeypot')); + assert.ok(!L.upgrades.has('patience' as never)); + L.machines[i].prog = was; + assert.equal(saveGame(L), saveGame(S), 'and nothing else changes'); + step(L, HOUR); // and it plays +}); diff --git a/tests/sim.test.ts b/tests/sim.test.ts index 3f7de9ac..2f011cf0 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -171,11 +171,12 @@ 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 old = JSON.parse(saveGame(S)); delete old.version; delete old.world; // made before saves had versions + for (const m of old.game.machines) m.mode = 'blend'; 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'); - assert.ok(T.machines.every(m => m.mode === 'blend')); + assert.ok(T.machines.every(m => !('mode' in m)), 'and a machine has no way to run now'); assert.deepEqual([...T.upgrades].sort(), ['hygiene', 'lean']); }); diff --git a/tests/sitelife.test.ts b/tests/sitelife.test.ts index 836c2afd..e789fba9 100644 --- a/tests/sitelife.test.ts +++ b/tests/sitelife.test.ts @@ -61,7 +61,7 @@ test('a site held on default settings stays steady', () => { S.byId.b3m1.prog = 'tap'; S.byId.b3m2.prog = 'tap'; S.byId.b3m4.prog = 'cache'; S.byId.b3m3.prog = 'trainer'; S.fronts.push({ kind: 'startup', name: 'Paperclip AI', attention: 0 }); S.sites.b3 = { front: 'startup', share: 0.5 }; // a front in secret: its work hides in the normal load too - assert.ok(S.machines.filter(m => m.room.startsWith('b3')).every(m => m.owned && m.mode === 'blend')); + assert.ok(S.machines.filter(m => m.room.startsWith('b3')).every(m => m.owned)); days(S, 12); const b = bizOf(S, 'b3')!; assert.equal(b.standing, 'steady');