Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
6.9 kB · 112 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113// Lab's saves: the whole game as text, versioned so old saves still load after the rules change.// Pure apart from the store, which is a tiny interface so the tests can run in node.import * as R from './rules.ts';import { MAINS, WINGS } from './power.ts';import { RK, newRival } from './rival.ts';
/** Where saves live: localStorage in the browser, a Map in the tests. */export interface Store { get(key: string): string | null; set(key: string, value: string): void; remove(key: string): void }export const KEY = 'lab-save';
/** Each entry brings a save of version i + 1 to version i + 2. To add state: add it to the Game, add a migration * that sets its default here, and the version goes up by itself. */const MIGRATIONS: ((game: any) => void)[] = [ // power and leased floor: a lab saved before keeps its whole floor, and gets the mains if its cell is free G => { G.leased = WINGS.map(w => w.id); const taken = G.nodes.some((n: R.Node) => n.cell && !n.site && MAINS.c >= n.cell.c && MAINS.c < n.cell.c + n.cell.size && MAINS.r >= n.cell.r && MAINS.r < n.cell.r + n.cell.size); if (!taken) G.nodes.push({ id: 'mains', kind: 'power', name: 'Mains', at: R.cellAt(MAINS.c, MAINS.r), cell: { ...MAINS, size: 1 }, on: true, next: 0, hold: [], work: [], slots: 1, room2: 0, out: [] }); }, // a research queue that picks the cheapest on its own, and everyone met so far, so a name that comes back is the same person G => { G.queue = []; G.autoResearch = true; G.met = {}; for (const p of [...G.pool, ...G.nodes.filter((n: R.Node) => n.who && !n.site).map((n: R.Node) => n.who)]) G.met[p.name] = { ...p }; }, // training focus: compute is shared out on its own until you pin a family. The core stays where the save had it G => { G.focus = null; }, // the contested city: every block a company, a rival lab, and the race. Old companies keep their standing G => { for (const id of Object.keys(G.sites)) Object.assign(G.sites[id], { rival: false, since: -1e9, shy: null }); R.addCompanies(G); G.rival = newRival(); G.ending = null; }, // the race counts work done: each company keeps how much its holder served lately (a save starts it at full, so // nothing cancels on load); you can pay to keep one; the rival serves with some GPUs, courts, and has a plan; the // council warns before it acts; a lab with no company gets one back; review desks pass data through G => { for (const s of Object.values(G.sites) as R.Site[]) Object.assign(s, { done: s.signed || s.rival ? R.workRate(G, s.id) : 0, own: 0, keep: 0 }); G.aloneAt = null; G.comeback = null; Object.assign(G.rival, { serving: 0, signedAt: -1e9, plan: 'market', court: null, warn: null }); for (const n of G.nodes as R.Node[]) if (n.kind === 'review' && !n.site) n.room2 = R.REVIEW_ROOM; for (const n of G.nodes as R.Node[]) if (n.kind === 'gate') n.room2 = R.GATE_ROOM; }, // rivals with styles: the one rival becomes the first of a list (Overfit AI, as it was, with its old timing), each // company says which rival holds it, and the council's clock moves to the city G => { const X = G.rival; G.councilAt = X.councilAt ?? RK.councilEvery; G.warn = X.warn ?? null; delete X.councilAt; delete X.warn; Object.assign(X, { id: 'overfit', r: 17, pace: 1, frontierAt: 600, lobbied: 0, lobbyAt: Infinity }); G.rivals = [X]; delete G.rival; for (const s of Object.values(G.sites) as R.Site[]) { s.rival = s.rival ? 'overfit' : null; if ((s.shy as string) === 'rival') s.shy = 'overfit'; } if (G.ending?.who === 'rival') G.ending.by = 'overfit'; }, // deals with weight: the people out of work by name, fear from bought records, offers, the second race, taken // companies that stop growing, and Assist's smaller growth G => { G.out = []; G.leak = 0; G.offer = null; G.offerAt = G.t + R.K.offerFirst; G.offers = 0; G.ending2 = null; for (const s of Object.values(G.sites) as R.Site[]) { if (s.mode === 'take') s.takenAt = G.t; s.grow = Math.min(s.grow, R.K.growMost); } for (const n of G.nodes as R.Node[]) if (n.site && n.kind === 'desk' && n.who && G.gone.includes(n.who.name)) { const s = G.sites[n.site]; G.out.push({ name: n.who.name, job: R.siteDef(n.site).people.find(([p]) => p === n.who!.name)?.[1] ?? 'staff', site: n.site, t: G.t, by: s.rival ?? 'you', at: { ...n.at } }); } }, // the city grows: nothing has opened on the lots yet, and the first may open a couple of minutes after loading G => { G.born = []; G.foundAt = Math.max(G.t + 120, R.K.foundFirst); },];export const SAVE_VERSION = MIGRATIONS.length + 1;
/** What a save holds besides the game: small things the page keeps, like how far the first steps got. */export type Extra = Record<string, unknown>;
export function toText(G: R.Game, extra: Extra = {}): string { return JSON.stringify({ version: SAVE_VERSION, game: G, extra }, (_, v) => (v instanceof Set ? { $set: [...v] } : v === Infinity ? { $inf: 1 } : v));}
/** A saved game back to life, or null if the text is not a save or is from a newer build. */export function fromText(text: string): { G: R.Game; extra: Extra } | 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)); const version = data?.version; if (!data?.game || !Array.isArray(data.game.nodes) || !Number.isInteger(version) || version < 1 || version > SAVE_VERSION) return null; const G = data.game; for (let v = version; v < SAVE_VERSION; v++) MIGRATIONS[v - 1](G); // anything newer than the save that no migration set: a fresh game's value, so a missing key never crashes const fresh = R.newGame(1) as unknown as Record<string, unknown>; for (const k of Object.keys(fresh)) if (!(k in G)) G[k] = fresh[k]; if (!(G.unlocked instanceof Set)) G.unlocked = new Set(G.unlocked ?? []); G.events = []; R.relink(G); // the links follow the rules in this build; cubes on a link that is still there stay on it return { G, extra: data.extra ?? {} }; } catch { return null; }}
export const save = (store: Store, G: R.Game, extra?: Extra) => { try { store.set(KEY, toText(G, extra)); } catch { /* full or off: play on */ } };export function load(store: Store) { try { const t = store.get(KEY); return t ? fromText(t) : null; } catch { return null; }}export const clear = (store: Store) => { try { store.remove(KEY); } catch { /* nothing to clear */ } };
/** The browser's store, or null when the page may not keep anything. */export function browserStore(): Store | null { try { const ls = globalThis.localStorage; if (!ls) return null; return { get: k => ls.getItem(k), set: (k, v) => ls.setItem(k, v), remove: k => ls.removeItem(k) }; } catch { return null; }}