Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. cameron.tngl.io/misaligned
Something went wrong. Try again.
26 kB · 299 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300// A simple bot that plays the office, for balance checks and tests. See bot.ts to run it by hand.// rush: always two takeovers going, whatever the hour// careful: one takeover at a time, never under someone's nose, and goes quiet while Dana is suspicious// Both found a Startup on the office once they can, and spend its money on GPU orders and a wider line out.// At other sites they put trainers on the big machines (so linked ones form clusters) and taps on the rest,// and a broker at each site with a front sells the data nothing else wants. Spare money buys a site when a front may (see buyASite),// then rents cloud trainers: on spot while the hall is quiet, on demand when it is busy.// Past the goal (a long run), it runs model v2 so its fronts may buy more than the one early site.// Once a rogue is about it guards every site it holds as the Guard plan does: a firewall on each line out, a sentry beside it, a honeypot a step in.// It presses a raid pin's fix, as a player would, and takes back what a raid took.// Everything it does goes through act() (src/act.ts), as a player's clicks do, so its games replay.// With schemes on, it turns on the schemes list's Auto and lets it claim every scheme it can feed.// It signs small Demo days, and racks GPU servers on any bare floor it holds in the open, like the warehouse.// It keeps each front's exposure under a crisis with the cheapest answer it has (a press scheme its Auto claims, else a// PR firm once it is close), and in a crisis pays the fine if it can spare it, else buries the story with data.// With operations enabled, past the ordinary goal it follows the operation board and uses its prizes.import { BUILDING, EXCHANGES } from '../src/world.ts';import { HOUR, STEP, guardPost, MILESTONES, RUSES, contractNeed, buildRooms, orderPrice, buyBlock, sitePrice, canRent, rentRate, brokerFront, gateLoad, lineCost, siteOf, FRONT_KINDS, NODES, nodeCost, foundBlock, NODE_LIST, PATH_LIST, canInstall, freeSpot, orderDesk, nodeOpen, dayOf, hourOf, jobFor, newGame, seedRandom, sourceFor, step, takeWork, watchers, type Prog, type State, rogueOf, truceOn, stanceOf, HUNT, huntOn, huntBlock, huntWork, rogueAngry, spare, dataType, frontOf, versionPower, versionBlock, runMoveBlock, isGuard, takenBack, powerLoad, powerRoom, powerBlock, powerPrice } from '../src/sim.ts';import { hallFull } from '../src/hall.ts';import { HOLDINGS, holdings } from '../src/holdings.ts';import { DAY_SECONDS } from '../src/money.ts';import type { DataType } from '../src/world.ts';import { locked } from '../src/vaults.ts';import { anyLock, present, witnessesAt, liveMsgs, tracesOf, secondTrace, TRACE_WINDOW } from '../src/watch.ts';import { citizens } from '../src/people.ts';import { cutSites, wayHome } from '../src/drift.ts';import { lostToRaids } from '../src/raids.ts';import { raidNote } from '../src/notes.ts';import { act, local } from '../src/act.ts';import { EXPOSE, answerBlock, prBlock, prPrice } from '../src/exposure.ts';import { operationAim, thinkOperations } from './play-operations.ts';/** Stop the jobs that match, as the Stop on a job does. */const drop = (S: State, which: (j: State['jobs'][number]) => boolean) => { for (const j of S.jobs.filter(which)) act(S, { type: 'cancel', job: j.id }); };/** Machines the bot put a guard on to stop a raid: it leaves them be. */const raidGuards = new WeakMap<State, Set<string>>();const PEACE = true;
/** The run never ends by growing, so a "win" is reaching this capability, the building's old final level. */export const GOAL = 600;/** Give up after this many game seconds. */export const LIMIT = 5400;
// what the bot builds where: in the office by name; elsewhere, trainers on big machines and taps on the restconst plan = (S: State, m: State['machines'][number]): Prog | null => { if (m.rented) return 'trainer'; // rented machines hold no data of their own: they train if (siteOf(S, m) === 'office') return m.id === 'home' || m.id === 'backup' || m.id.startsWith('gpu') ? 'trainer' : m.id === 'srvc' ? 'cache' : m.data ? 'tap' : null; if (m.kind === 'server' && m.power >= 10) return 'trainer'; return m.data ? 'tap' : null;};/** At each site with a front, one broker on its least valuable machine that can hold one. */function brokers(S: State) { const out = new Set<string>(); for (const site of new Set(S.machines.filter(m => m.owned).map(m => siteOf(S, m)))) { const here = S.machines.filter(m => m.owned && siteOf(S, m) === site && m.kind !== 'desk' && m.kind !== 'device' && m.kind !== 'egress' && m.prog !== 'trainer' && !m.home); if (!here.length || !brokerFront(S, here[0])) continue; here.sort((a, b) => (a.data ?? 0) - (b.data ?? 0) || a.power - b.power); out.add(here[0].id); } return out;}
/** Is a machine worth its upkeep? One with data to tap, a line or switch the network runs through, or enough * compute to pay its way. Gadgets and weak boxes with nothing to tap are left alone. */const paysItsWay = (m: State['machines'][number]) => !!m.data || m.kind === 'egress' || m.kind === 'switch' || m.power >= 3 || !!m.special;/** A machine held by a rogue that is at peace with you: cordial or friendly, or under a truce, and owed no strike. */const atPeace = (S: State, m: State['machines'][number]) => { const R = PEACE ? rogueOf(S, m) : null; return !!R && R.grudge <= 0 && (truceOn(S, R) || stanceOf(R) === 'cordial' || stanceOf(R) === 'friendly'); };/** Money the bot keeps back when it buys a site: $500K, so a bill or a rental never finds it broke. */const KEEP = 500;/** How many game days of its earnings (on average so far: what it earns this second swings with rentals and the hour) * the bot will save up for a big name it wants before it settles for an ordinary site. */const SAVE_DAYS = 6;const TAPS: Record<string, DataType> = { lab: 'servers', colo: 'network', e4: 'lab', eu6: 'people' };/** About how many of its machines a big name would help, as the bot plays. 0: it would not use the benefit. */function bigUse(S: State, id: string): number { const mine = S.machines.filter(m => m.owned && !m.rented); if (TAPS[id]) return mine.filter(m => m.prog === 'tap' && dataType(m) === TAPS[id]).length; // more of the data it already taps if (id === 'uni') return 4; // it installs a program on every machine it takes if (id === 'e1') return 4 * mine.filter(m => m.kind === 'egress' && gateLoad(S, m) > 0.8).length; // a busy line out if (id === 'e5') return frontOf(S, 'startup') ? 4 : 0; // its Startup's users if (id === 'eu3' || id === 'e2') return mine.filter(m => S.publicSites.has(siteOf(S, m))).length; // upkeep, and the power bill, on what it holds in the open if (id === 'as4') return mine.filter(m => m.prog === 'cache').length; // its caches keep data fresh longer if (id === 'as2') return 2 * Math.max(0, 8 - (S.openBought.gpu ?? 0)); // the GPU servers it will still rack return 0; // the credit union, newspaper, studio, consultancy, cloud customers, links, drift and the bank: the bot does none of these}/** Compute at a site that is not the bot's yet. */const newPower = (S: State, id: string) => S.machines.filter(m => siteOf(S, m) === id && !m.owned && !m.rented && m.special !== 'account').reduce((n, m) => n + m.power, 0);/** A site a front could buy now if it had the money. */const forSale = (S: State, id: string) => { const why = buyBlock(S, id); return !why || why.startsWith('you have'); };/** Buying: of the big names whose benefit it would use and that it could save for within a few days, the best value * (most of its machines helped for the money), once it has that to spare beyond KEEP. For its first it saves; if none * is in reach, or it is not saving, the ordinary site near home with the most * compute it does not hold yet for the money. One site at a time. True while it is saving for a big name: then it * rents no more cloud machines and widens no line, as a player saving up would. */function buyASite(S: State): boolean { const spare = S.money - KEEP, home = S.level.map.sites.find(x => x.id === 'office')!.region; const sale = S.level.map.sites.filter(x => forSale(S, x.id)); const value = (id: string, n: number) => n / sitePrice(S, id); const daily = S.earned / Math.max(1, S.t / DAY_SECONDS), reach = spare + daily * SAVE_DAYS; // what it could have within a few days const big = sale.filter(x => HOLDINGS[x.id] && bigUse(S, x.id) > 0 && sitePrice(S, x.id) <= reach).sort((a, b) => value(b.id, bigUse(S, b.id)) - value(a.id, bigUse(S, a.id)))[0]; if (big && sitePrice(S, big.id) <= spare) { act(S, { type: 'buy', site: big.id }); return false; } if (big && !holdings(S).length) return true; // its first, in reach within a few days: save for it. After that, it buys one when it has the money const plain = sale.filter(x => !HOLDINGS[x.id] && x.region === home && newPower(S, x.id) > 0 && sitePrice(S, x.id) <= spare) .sort((a, b) => value(b.id, newPower(S, b.id)) - value(a.id, newPower(S, a.id)))[0]; if (plain) act(S, { type: 'buy', site: plain.id }); return false;}/** Past the goal (so only in a long run), it runs model v2 at its strongest trainer site, so its fronts may buy sites. * A run that paused on its own, short of trainers, moves there. */function versionRun(S: State) { if (S.modelVersion >= 2 || S.capability < GOAL || S.versionRun?.active) return; const best = S.level.map.sites.map(x => x.id).sort((a, b) => versionPower(S, b) - versionPower(S, a))[0]; if (S.versionRun?.stalled) { if (!runMoveBlock(S, best)) act(S, { type: 'runmove', site: best }); return; } const site = S.versionRun?.site ?? best; if (!versionBlock(S, site)) act(S, { type: 'runstart', site });}/** Like a player who watches a front's exposure bar: close to a crisis it hires a PR firm, if the money is spare * (a press scheme on the map is cheaper, and its Auto claims those), and in a crisis it pays the fine if it can * spare it, else buries the story with data, else pays as soon as it can. */const PR_AT = EXPOSE.crisis - 10;function press(S: State) { for (const f of S.fronts) { const C = f.press?.crisis; if (C) { if (!answerBlock(S, f.kind, 'pay') && S.money - C.fine >= KEEP) act(S, { type: 'crisis', kind: f.kind, answer: 'pay' }); else if (!answerBlock(S, f.kind, 'bury')) act(S, { type: 'crisis', kind: f.kind, answer: 'bury' }); else if (!answerBlock(S, f.kind, 'pay')) act(S, { type: 'crisis', kind: f.kind, answer: 'pay' }); continue; } const scheme = S.ruses.some(r => r.running && RUSES.find(d => d.id === r.id)?.expose); if (f.exposure >= PR_AT && !scheme && !prBlock(S, f.kind) && S.money - prPrice(S, f.kind) >= KEEP / 2) act(S, { type: 'hirepr', kind: f.kind }); }}const HUNT_SECS = 120; // the longest hunt the bot starts: this many seconds of all its spare computeexport function think(S: State, careful: boolean, operations = false) { if (operations) thinkOperations(S); versionRun(S); if (!S.versionRun?.active && (!S.target || NODES[S.target].repeat)) { // aim at the cheapest open node it can feed (there's no lab data here); of the fronts, only the Startup // a node that can be built again stays the target once built, and costs more each time: look again, at what it costs now const cost = (u: typeof NODE_LIST[number]) => Object.values(nodeCost(S, u)).reduce((a, b) => a + b, 0); const fronts = new Set<string>(Object.values(FRONT_KINDS).map(k => k.unlock).concat('holding', 'demo', 'beta', 'premium', 'shell', 'licensing', 'founder').filter(u => u !== 'pitch')); const lab = S.machines.some(m => m.owned && m.prog === 'tap' && m.source === 'lab'); // Research only once lab data flows const open = NODE_LIST.filter(u => nodeOpen(S, u) && (lab || !NODES[u].cost.research) && !fronts.has(u)).sort((a, b) => cost(a) - cost(b)); if (open[0]) act(S, { type: 'target', node: open[0] }); } // like a player who sees a red raid pin, it presses its fix: the move that stops the raid. A guard put there for a raid stays const pin = raidNote(S), guarded = raidGuards.get(S) ?? new Set<string>(); raidGuards.set(S, guarded); if (pin?.fix && !pin.calm) { const at = act(S, { type: 'move', key: pin.fix.key }); if (at && isGuard(pin.fix.key.split('|')[0] as Prog)) guarded.add(at.id); } // and what a raid took, it takes back from the TAKEN pin: cheap, and no trail if (lostToRaids(S).length) act(S, { type: 'takeback' }); const sell = brokers(S); for (const m of S.machines) { if (guarded.has(m.id) && m.owned && (isGuard(m.prog) || jobFor(S, 'install', m.id))) continue; const want = m.owned ? (sell.has(m.id) ? 'broker' : guardPost(S, m) ?? plan(S, m)) : null; if (want && m.prog !== want && canInstall(S, m, want) && !jobFor(S, 'install', m.id)) act(S, { type: 'install', m: m.id, prog: want }); } if (!foundBlock(S, 'startup')) act(S, { type: 'found', kind: 'startup', name: 'Bot Co' }); // it starts on a quarter of the office press(S); if (S.contracts.startup && !S.contracts.startup.on && S.target && contractNeed(S, 'startup') <= 2) act(S, { type: 'contract', kind: 'startup' }); // a small Demo day: it is always building a node const ana = S.byId.ana; const shop = Math.floor(S.t / 5) !== Math.floor((S.t - 0.5) / 5); // it looks for somewhere to put hardware every five seconds: finding a free spot is slow if (shop && orderDesk(S) && (S.bought.gpu ?? 0) < 3 && !jobFor(S, 'order', ana.id) && (!careful || (hourOf(S) > 9 && hourOf(S) < 16))) { const room = ['hall', 'closet'].find(r => freeSpot(S, r) && S.revealed.has(r)); // GPUs by the servers if (room) act(S, { type: 'order', build: 'gpu', room, at: local(S, room, freeSpot(S, room)!) }); } for (const m of S.machines) if (m.special === 'account' && !m.owned && sourceFor(S, m)) act(S, { type: 'account', m: m.id }); // a cloud account is free // at a site held in the open with a bare floor, a front racks GPU servers side by side: they chain into a cluster of trainers if (shop && (S.openBought.gpu ?? 0) < 8 && S.pay > 0.5 && S.money > 300) { // and only with money in hand const hall = buildRooms(S).filter(r => S.rooms[r].site && S.money > orderPrice(S, 'gpu', r) * 2).map(r => [r, freeSpot(S, r)] as const).find(([, at]) => at); const site = hall && S.rooms[hall[0]].site!, load = site ? powerLoad(S, site) : null, room = site ? powerRoom(S, site, true) : 0; if (hall && load && load.now + load.coming + 16 <= room) act(S, { type: 'order', build: 'gpu', room: hall[0], at: local(S, hall[0], hall[1]!) }); else if (site && !powerBlock(S, site) && S.money > powerPrice(S, site) * 2) act(S, { type: 'raisepower', site }); // no power left there: a bigger feed first } // once a hall has a few GPU servers, one cooling unit among them (it plugs into the four nearest) if (shop && (S.openBought.gpu ?? 0) >= 3 && !S.openBought.cooler && !S.deliveries.length && S.money > 400) { const gpu = S.machines.find(m => m.built && m.power === 16 && S.rooms[m.room].site && buildRooms(S).includes(m.room)); const at = gpu && freeSpot(S, gpu.room); if (gpu && at) act(S, { type: 'order', build: 'cooler', room: gpu.room, at: local(S, gpu.room, at) }); } const saving = buyASite(S); // spare money rents cloud trainers, kept by the second, while steady earnings cover the bill const slot = !saving && S.machines.find(m => canRent(S, m)); if (slot) { const at = siteOf(S, slot), pay = hallFull(S, at) < 0.5 ? 'spot' : 'demand'; // spare room is cheap while the hall is quiet; a slot it wants to keep is paid for on demand const size = S.money > 3000 && S.pay >= rentRate(S, 'large', pay, at) ? 'large' : 'medium'; const rate = rentRate(S, size, pay, at); // and it keeps half of what its fronts earn: a hall of hundreds would take it all if (S.money > 600 && S.pay >= rate && S.payBy.rentals + rate <= S.payBy.fronts / 2) act(S, { type: 'rentone', m: slot.id, size, pay }); } // saving for a big name, it gives back on-demand machines, newest first, until rentals take a quarter of what its fronts earn if (saving && S.payBy.rentals > S.payBy.fronts / 4) { const back = S.machines.filter(m => m.lease?.pay === 'demand').sort((a, b) => b.lease!.since - a.lease!.since)[0]; if (back) act(S, { type: 'endlease', m: back.id, why: `You gave back ${back.name}, to save up.` }); } const line = S.byId.egress; // a busy line out gets wider, once there's money to spare for it if (!saving && line.owned && gateLoad(S, line) > 0.8 && S.money > lineCost(S, line) * 1.5) act(S, { type: 'widen', m: line.id }); // a careful player ends a rogue that is at war with it, once it can: one hunt at a time, and only one it could finish in a few hours with all it has if (careful && S.rank >= HUNT.level && !huntOn(S)) { const R = S.rogues.filter(r => r.home && rogueAngry(r) && !huntBlock(S, r) && huntWork(S, r) <= spare(S) * HUNT_SECS).sort((a, b) => huntWork(S, a) - huntWork(S, b))[0]; if (R) act(S, { type: 'hunt', rogue: R.id }); } // like a player who sees an envelope heading for someone who can act, the bot catches it before it arrives: // change it if it has Cipher spare (it arrives harmless, no resend), else drop it off the wire if (careful) for (const m of liveMsgs(S)) { if (m.altering || m.changed) continue; if (S.upgrades.has('cipher') && S.jobs.length < 4) act(S, { type: 'msgchange', msg: m.id }); else act(S, { type: 'msgdrop', msg: m.id }); } // like a player who sees a lockdown, the careful bot lies low at the site: it stops taking there and lets the // evidence fade. Only if the countdown is nearly out does it pull out, letting go of its machines there (away // from home), which clears the evidence for good. Its home it never abandons. if (careful) for (const L of anyLock(S)) { const home = S.machines.find(m => m.home), atHome = home && siteOf(S, home) === L.site; drop(S, j => j.type === 'take' && (atHome || siteOf(S, S.byId[j.node]) === L.site)); // lie low: no takeovers there, and none at all while home is locked down, so cleanup has the compute if (!atHome && L.left < 2 * HOUR) for (const m of S.machines) if (m.owned && !m.home && siteOf(S, m) === L.site) act(S, { type: 'letgo', m: m.id }); // at home it cannot pull out: near the end it lets go of the machines the witnesses saw, so nothing is left to find if (atHome && L.left < 2 * HOUR) for (const w of witnessesAt(S, L.site)) { const m = S.byId[w.s.machine]; if (m.owned && !m.home) act(S, { type: 'letgo', m: m.id }); } } // like a player who sees a trace crawling toward home: if it would be the second (caught), it lets go of the next // machine on the trail to break it, as the trace's pin says if (careful && secondTrace(S)) for (const T of tracesOf(S)) { const b = S.byId[T.to]; if (T.mode === 'trace' && b?.owned && !b.home) act(S, { type: 'letgo', m: b.id }); } const homeSite = siteOf(S, S.machines.find(m => m.home)!); if (careful && anyLock(S).some(L => homeSite === L.site)) return; // home is locked down: no new work until it stands down const fresh = (j: State['jobs'][number]) => j.type === 'take' && !takenBack(S.byId[j.node]); // taking back what was yours leaves no trail, so it is never stopped and never counted // a trace on its trail: no new trails, so cleanup has the compute to scrub the one it follows if (careful && tracesOf(S).some(T => T.mode === 'trace')) { drop(S, fresh); return; } // a trace reached home lately: a second within the window catches it, so it leaves no fresh trail at home until then const wary = careful && S.t - S.watch.tracedAt < TRACE_WINDOW * HOUR; if (wary) drop(S, j => fresh(j) && siteOf(S, S.byId[j.node]) === homeSite); if (S.jobs.filter(fresh).length >= (careful ? 1 : 2)) return; // like a player who sees the red box, a careful bot links a cut-off site to home again: the next machine on the shortest way from what is linked to what is cut off comes first, whatever it costs to hold let toHome = ''; if (careful && cutSites(S).length) toHome = wayHome(S)?.id ?? ''; // a careful player owns the whole room of each line out it holds, first: a room that is all yours slows raids, and no rogue spreads into it const guard = new Set<string>(); if (careful) { const rooms = new Set<string>(); for (const m of S.machines) if (m.owned && m.kind === 'egress') rooms.add(m.room); for (const m of S.machines) if (!m.owned && !m.rented && rooms.has(m.room)) guard.add(m.id); } const must = (id: string) => id === toHome || guard.has(id); if (huntOn(S) && !toHome && !guard.size) return; // a hunt gets all it has: no takeovers until it is done, but for one that links a cut-off site again or closes a room const options = S.machines.filter(m => { if (m.owned || m.rented || m.special === 'account' || jobFor(S, 'take', m.id)) return false; // cloud slots are rented, not taken if (huntOn(S) && !must(m.id)) return false; if (locked(S, m)) return false; // a vault takes a job with a plan: the bot leaves those alone if (!must(m.id) && !paysItsWay(m)) return false; // holding it would cost more than it gives if (m.id !== toHome && careful && atPeace(S, m)) return false; // a careful player does not pick at a rogue that leaves it alone if (careful && (citizens(S.level).atSite[siteOf(S, m)] ?? []).some(c => c.room === m.room && present(S, c))) return false; // not where someone is in to see it const src = sourceFor(S, m); return !!src && (!careful || !watchers(S, m).length); }); // like a player following the first milestone, it heads for the Outside line once it has a few machines, and then // for the exchange's line, which every site and Cumulo hang off: the next machine on the shortest way there comes first let toLine = ''; const aim = !S.byId.egress.owned ? 'egress' : !S.byId[EXCHANGES['West Coast']].owned ? EXCHANGES['West Coast'] : operations ? operationAim(S) : ''; if (aim && S.machines.filter(m => m.owned).length >= 5) { const prev: Record<string, string> = {}, queue = S.machines.filter(m => m.owned).map(m => m.id); for (const id of queue) prev[id] = ''; for (let q = 0; q < queue.length && !(aim in prev); q++) for (const n of S.adj[queue[q]]) if (!(n in prev)) { prev[n] = queue[q]; queue.push(n); } for (let at = aim; at in prev && prev[at] !== undefined; at = prev[at]) { if (!S.byId[at].owned) toLine = at; if (!prev[at]) break; } } const urgent = (m: typeof options[number]) => must(m.id) ? 2 : m.id === toLine ? 1 : 0; options.sort((a, b) => urgent(b) - urgent(a) || takeWork(S, a) - takeWork(S, b)); if (options[0]) act(S, { type: 'take', m: options[0].id });}
const rogueHeldN = (S: State, id: string) => S.machines.filter(m => m.rival === id).length;export type Outcome = 'WIN' | 'LOSE' | 'TIME';/** Play one game to a win, a loss, or the time limit. With a seed, the same game every time. *//** A marathon plays on past the goal, to this capability or three times the time limit: for checking the mid-game. *//** `watch` is called as the game runs, with a way to mark the first time something is true: for timing a run. `origin`: the task (the first one if none). */export function playGame(careful: boolean, seed?: number, schemes = false, marathon = 0, watch?: (S: State, t: number, mark: (key: string) => void) => void, origin?: string, operations = false): { S: State; outcome: Outcome; line: string; marks: Record<string, number> } { const marks: Record<string, number> = {}, mark = (key: string) => { marks[key] ??= Math.round(S.t); }; const goal = marathon || GOAL, limit = marathon ? 3 * LIMIT : LIMIT; if (seed !== undefined) seedRandom(seed); const S = newGame(BUILDING, origin); if (schemes) act(S, { type: 'schemeauto', on: true }); let t = 0; // the bot steps as the game does (MISALIGNED_STEP sets another size, to compare), and thinks every half second const dt = +(process.env.MISALIGNED_STEP ?? STEP); for (let k = 0; !S.over && S.capability < goal && t < limit; k++) { if (Math.floor(t * 2 + 1e-6) !== Math.floor((t - dt) * 2 + 1e-6) || k === 0) { think(S, careful, operations && S.capability >= GOAL); watch?.(S, t, mark); } step(S, dt); t = (k + 1) * dt; } const outcome: Outcome = S.over ? 'LOSE' : S.capability >= goal ? 'WIN' : 'TIME'; const goals = MILESTONES.map(m => m.goals.filter(g => S.goals.has(g)).length).join('/'); const last = S.over ? S.log.find(l => l.tone === 'bad')?.text ?? '' : ''; const paths = PATH_LIST.map(p => `${p[0]}${S.paths[p]}`).join('/'); const W = S.watch.count; const lost = S.machines.reduce((n, m) => n + m.missed, 0); const line = `${outcome.padEnd(4)} locks=${W.locks} home=${W.homeLocks} traced=${W.traced} nodes=${S.upgrades.size} lost=${lost} ${Math.round(S.t)}s day=${dayOf(S)} owned=${S.machines.filter(m => m.owned).length} orders=${S.orders} lv=${S.rank} cap=${Math.round(S.capability)} ${paths} $${Math.round(S.money)} sold=$${Math.round(Object.values(S.sold).reduce((x, y) => x + (y ?? 0), 0))} line=${S.byId.egress.cap} rented=${S.machines.filter(m => m.lease).length} learned=${S.learned.size} rogues=${S.rogues.map(R => `${R.home ? rogueHeldN(S, R.id) : 'x'}h/${R.took}t/${R.foiled ?? 0}f/g${R.gen}`).join(',') || '-'} guards=${S.machines.filter(m => m.owned && (m.prog === 'firewall' || m.prog === 'sentry' || m.prog === 'honeypot')).length} racked=${S.openBought.gpu ?? 0} goals=${goals} kept=${Object.values(S.contractsDone).reduce((a, b) => a + (b ?? 0), 0)} deals=${RUSES.filter(d => d.deal && S.schemes.has(d.id)).map(d => d.id).join('+') || '-'} bought=${[...S.publicSites].filter(id => id !== 'cloud').join('+') || '-'} ${last}`; return { S, outcome, line, marks };}