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.
79 kB · 1096 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097// Raids: now and then a rogue sends an intruder through your network toward one thing you care about,// in plain view. It walks machine by machine along the links, with a strength, and what you have built// in its way answers it without you: a firewall holds it at a door, a sentry wears it down, a honeypot// draws it in and holds it, and a closed port turns it away. A room that is all yours slows it, and a// switch you hold can turn it.// Nothing here uses the rules' values while the file loads (sim.ts loads this file and this file// loads sim.ts), only inside functions.import { HOUR, PROGRAMS, siteLinks, TRUCE_DATA, feedsContract, rogueWants, takenBack, truce, truceBlock, canInstall, cleanupShares, followPlan, guardPosts, installProg, isGuard, setPlan, isSealed, jobFor, linkedHome, remember, rogueAngry, rogueHeld, rogueTake, say, siteName, siteOf, sourceFor, spare, stanceOf, takeOver, takeWork, truceOn, up, PORT_KEEP, type Guard, type Job, type Machine, type Prog, type Rogue, type State } from './sim.ts';import { isVault } from './vaults.ts';import { jobTime } from './money.ts';import { linkBlock, linkWork, startLink, wayMap } from './links.ts';import { chainHome } from './drift.ts';
/** One raid on its way. */export interface Raid { id: number; rogue: string; from: string; // the rogue's machine it set out from prize: string; // the machine of yours it is after at: string; // the machine it is at now path: string[]; // every machine still ahead of it, in order. It ends at the prize, or at a honeypot that draws it decoy: string | null; // the honeypot it has turned toward hopAt: number; // when it moves on to the next machine steer: Record<string, string>; // switches where you chose the link it leaves by, until it has left that way steered: string[]; // switches you have steered it at: once each pulled: string[]; // machines whose plug was pulled in its way (kept for old saves) cut?: number; // it is after your weakest link: this many of your machines were cut off from home without it, when it set out hp: number; // its strength now: a sentry wears it down, and at none it is gone max: number; // its strength when it set out prev?: string; // the machine it came from, and when it got here: it is drawn walking the link came?: number; trap?: string; // a honeypot holding it, until hopAt force?: number; // it is forcing a closed port, with no other way: the port gives at this time lesson?: boolean; // the first raid of a run: weak and slow, so you learn to guard by stopping it (see LESSON) warned?: boolean; // the game dropped to 1x for it once, when your guards would not stop it}export const initRaids = (): Raid[] => [];
/** The numbers, in game hours and links. First guesses. */export const RAID = { first: 24, // a rogue sends no raid in its first day every: 36, // then one this long after the last: half as long while it is angry or hostile start: 2, // a new raid waits this long before its first hop, so you can read its path hop: 1, // then it moves one machine this often sealed: 2, // every machine in a room that is all yours holds it twice as long: locked doors lure: 2, // a honeypot draws a raid from this many links away trap: 3, // and holds it this long rest: 8, // then draws no other for this long force: 4, // a raid with no way but through a closed port forces it open in this long fail: 24, // a rogue whose raid failed sends no other for this long grow: 4, // a rogue sends one more raid at once for every this many levels you have most: 4, // and never more than this many faster: 0.08, // each level you gain, raids come this much more often, as a rogue's takeovers do strength: 10, // a raid's strength at level 1 stronger: 0.25, // and this much more for each level you have learns: 0.15, // and this much more for each raid of its rogue that failed: it learns your defences};/** The first raid of a run is a lesson: a weak, slow try at a site you hold, in through its gate, with Guard * on its pin. It comes once a run, and only to a player with no guards yet: one who has some gets a raid * like any other. The key in S.hinted says it has come. */export const LESSON = { key: 'raid-lesson', strength: 0.5, start: 6, hop: 2 };/** A firewall: how much of it there is, how fast a raid wears it down (for each point of the raid's * strength, an hour), and how fast it grows back while no raid is on it, an hour. */export const WALL = { max: 24, bite: 1 / 3, regrow: 4 };/** A sentry takes this much strength an hour off each raid on its machine or one linked to it. */export const SENTRY = 4;
/** How many raids a rogue sends at once: one, one more for every four levels you have, and one more for * each of its line you have finished. Each is after a different machine. A room that is all yours keeps * a rogue's takeovers out, so raids are how it keeps up with you as you grow. */export const raidSize = (S: State, R?: Rogue) => Math.min(RAID.most, 1 + Math.floor(S.rank / RAID.grow) + (R?.gen ?? 0));/** How strong a rogue's raid is when it sets out: more as you grow, more for each of its raids that failed * (it learns your defences, so what held last time may not hold next), and half again for each of its line you finished. */export const raidStrength = (S: State, R?: Rogue) => Math.round(RAID.strength * (1 + RAID.stronger * Math.max(0, S.rank - 1)) * (1 + RAID.learns * (R?.foiled ?? 0)) * (1 + 0.5 * (R?.gen ?? 0)));
/** The rogue behind a raid. */export const raider = (S: State, r: Raid) => S.rogues.find(R => R.id === r.rogue) ?? null;/** Its plug is pulled: it does no work, and no raid can pass. */export const unplugged = (S: State, m: Machine) => m.owned && (m.unplug ?? 0) > S.t;/** A guard works on a machine you hold that has power: not with its plug pulled or the power cut. */export const armed = (S: State, m: Machine) => m.owned && !unplugged(S, m) && !S.dark.has(siteOf(S, m));/** How much of a firewall stands. */export const wallOf = (m: Machine) => m.wall ?? WALL.max;/** A firewall that holds: no raid passes this machine. */export const standing = (S: State, m: Machine) => m.prog === 'firewall' && armed(S, m) && wallOf(m) > 0;/** A honeypot that would draw a raid now: not resting after the last. */export const baited = (S: State, m: Machine) => m.prog === 'honeypot' && armed(S, m) && !((m.trapT ?? 0) > S.t);/** Is the link between these two closed to raids, at an end you hold? */export const shut = (S: State, a: string, b: string) => { const A = S.byId[a], B = S.byId[b]; return !!(A?.owned && A.ports?.includes(b)) || !!(B?.owned && B.ports?.includes(a));};/** What a raid standing here loses an hour: every sentry of yours on this machine or one linked to it. * A raid at a door (a firewall it is wearing down) is in reach of the sentries beside the door too. */export function fire(S: State, at: string, door?: string): number { const seen = new Set<string>(); for (const x of door ? [at, door] : [at]) for (const id of [x, ...S.adj[x]]) { const m = S.byId[id]; if (m.prog === 'sentry' && armed(S, m)) seen.add(id); } return seen.size * SENTRY;}/** The door a raid is pressed against now, if any: a standing firewall it is wearing down. */export const doorOf = (S: State, r: Raid) => { const to = r.path[0] ? S.byId[r.path[0]] : null; return to && S.t >= r.hopAt && standing(S, to) ? to.id : undefined; };/** How long a raid stays on a machine before it moves on. */const hold = (S: State, r: Raid, m: Machine) => RAID.hop * HOUR * (m.owned && isSealed(S, m.room) ? RAID.sealed : 1) * (r.lesson ? LESSON.hop : 1);/** What a raid may go for: a machine of yours that is not your home, not rented, not a vault, and not one you just took from a rogue. A room that is all yours does not save it: a raid is how a rogue gets in. */export const raidable = (S: State, m: Machine) => m.owned && !m.home && !m.rented && !m.lease && m.special !== 'account' && !(m.safeT && S.t < m.safeT) && !isVault(m.id);const peace = (S: State, R: Rogue) => truceOn(S, R) || stanceOf(R) === 'cordial' || stanceOf(R) === 'friendly';/** How long a rogue waits between raids: less as you grow and for each of its line you finished, and half while it is angry. */export const raidEvery = (S: State, R: Rogue) => RAID.every * HOUR / (1 + RAID.faster * Math.max(0, S.rank - 1) + 0.5 * R.gen) * (rogueAngry(R) ? 0.5 : 1);const hoursText = (h: number) => { const n = Math.max(1, Math.ceil(h - 0.01)); return `${n} hour${n > 1 ? 's' : ''}`; };/** A machine's place in words: a line out is its site's gate. "Bayside Office gate", "Bayside Office". */export const placeOf = (S: State, m: Machine) => `${siteName(S, siteOf(S, m))}${m.kind === 'egress' ? ' gate' : ''}`;/** Where a firewall stands, in words: "the Bayside Office gate", or "Room switch, Bayside Office". */export const wallPlace = (S: State, m: Machine) => m.kind === 'egress' ? `the ${placeOf(S, m)}` : `${m.name}, ${placeOf(S, m)}`;
// ---- where a raid can go/** A raid moves through machines you hold, free machines, and its own rogue's. It cannot stand on a * machine another rogue holds. A room that is all yours does not stop it, only slows it. */function ground(_S: State, rogue: string) { return (m: Machine) => !m.rival || m.rival === rogue;}type Ground = ReturnType<typeof ground>;/** Can a raid step from a to b without meeting a wall: a pulled plug, a firewall that holds, or a closed port? */const clear = (S: State, a: string, b: string) => !shut(S, a, b) && !unplugged(S, S.byId[b]) && !standing(S, S.byId[b]);/** How many links machines are from the prize, going only where a raid can go, found outward from it a ring at * a time and only as far as asked (see grow): a raid near its prize needs only the rings near it, not the world. * With `walls`, a pulled plug, a standing firewall and a closed port are walls too. */interface Far { dist: Map<string, number>; queue: string[]; q: number; walls: boolean; ok: Ground }const far0 = (prize: string, walls: boolean, ok: Ground): Far => ({ dist: new Map([[prize, 0]]), queue: [prize], q: 0, walls, ok });/** Find every machine `upTo` links from the prize or nearer, and none further. */function grow(S: State, f: Far, upTo = Infinity) { const { dist, queue, walls, ok } = f; for (; f.q < queue.length; f.q++) { const at = queue[f.q], d = dist.get(at)!; if (d >= upTo) return; for (const n of S.adj[at]) { const m = S.byId[n]; if (dist.has(n) || !ok(m) || (walls && (shut(S, n, at) || unplugged(S, m) || standing(S, m)))) continue; dist.set(n, d + 1); queue.push(n); } }}/** How many links each machine is from the prize, all the way out. */function distances(S: State, prize: string, walls: boolean, ok: Ground): Map<string, number> { const f = far0(prize, walls, ok); grow(S, f); return f.dist;}/** A honeypot within reach of here, by a way with no wall in it: the machines to pass, ending at the * honeypot. An empty list means the honeypot is on this very machine. */function baitNear(S: State, at: string, ok: Ground): string[] | null { const bait = (m: Machine) => baited(S, m) && ok(m); if (bait(S.byId[at])) return []; const prev: Record<string, string> = { [at]: '' }; let ring = [at]; for (let d = 0; d < RAID.lure; d++) { const next: string[] = []; for (const id of ring) for (const n of S.adj[id]) { const m = S.byId[n]; if (n in prev || !ok(m) || !clear(S, id, n)) continue; prev[n] = id; if (bait(m)) { const out = [n]; for (let x = prev[n]; x !== at; x = prev[x]) out.unshift(x); return out; } next.push(n); } ring = next; } return null;}/** Where a raid goes from where it is: every machine ahead of it, in order. It takes the shortest way to * its prize that meets no wall. With none, it takes the shortest way through: it stops at the wall and * wears it down (a firewall), forces it (a closed port), or waits (a pulled plug). A switch you steered * sends it out by the link you chose, once. A honeypot within two links of any machine it stands on * draws it, and its path ends there. Null: there is no way at all. */function route(S: State, r: Raid, steer = r.steer, memo?: Map<string, Far>): { path: string[]; decoy: string | null } | null { const ok = ground(S, r.rogue), far = (walls: boolean) => { const k = `${r.rogue}|${r.prize}|${walls}`; let d = memo?.get(k); if (!d) { d = far0(r.prize, walls, ok); memo?.set(k, d); } return d; }; const strict = far(true), loose = far(false); const enter = (a: string, n: string) => n === r.prize ? !shut(S, a, n) && !standing(S, S.byId[n]) : clear(S, a, n); // a plug pulled on the prize does not keep it out const nearest = (at: string, f: Far, walls: boolean) => { // the rings found so far are whole: the nearest found is the nearest there is for (;;) { const ns = S.adj[at].filter(n => f.dist.has(n) && (!walls || enter(at, n))); if (ns.length) return ns.sort((a, b) => f.dist.get(a)! - f.dist.get(b)!)[0]; if (f.q >= f.queue.length) return undefined; grow(S, f, f.dist.get(f.queue[f.q])! + 1); // one ring further out } }; const pending = { ...steer }, path: string[] = []; let at = r.at; for (let i = 0; i < 300; i++) { if (at === r.prize) return { path, decoy: null }; const lure = baitNear(S, at, ok); if (lure) return { path: [...path, ...lure], decoy: lure[lure.length - 1] ?? at }; const sw = S.byId[at], out = pending[at]; let next: string | undefined; if (out && sw.owned && sw.kind === 'switch' && S.adj[at].includes(out) && (out === r.prize || ok(S.byId[out])) && enter(at, out)) { next = out; delete pending[at]; } else next = nearest(at, strict, true) ?? nearest(at, loose, false); if (!next) return null; path.push(next); at = next; } return null;}/** Hours until a raid gets to the end of a path: the wait where it is, each machine on the way, and a * guess at each wall it must get through at the strength it has now. */export function raidHours(S: State, r: Raid, path = r.path): number { let t = Math.max(0, r.hopAt - S.t), prev = r.at; const bite = Math.max(0.1, r.hp) * WALL.bite; for (let i = 0; i < path.length; i++) { const m = S.byId[path[i]]; if (shut(S, prev, m.id)) t += r.force !== undefined && prev === r.at ? Math.max(0, r.force - S.t) : RAID.force * HOUR; if (standing(S, m)) t += wallOf(m) / bite * HOUR; if (i < path.length - 1) t += hold(S, r, m); prev = m.id; } return t / HOUR;}/** Work out a raid's path again from where it stands. Says why it has failed, if it has. */function replan(S: State, r: Raid): string | null { const prize = S.byId[r.prize]; if (!prize?.owned || prize.rival) return `you no longer hold ${prize?.name ?? 'what it came for'}`; if (!raidable(S, prize)) return `it cannot take ${prize.name}`; const way = route(S, r); if (!way) return `every way to ${prize.name} is shut`; r.path = way.path; r.decoy = way.decoy; return null;}/** Raids from a save made before raids had a strength: give each the strength its rogue would send now. */export function fixRaids(S: State) { for (const r of S.raids ?? []) { r.max ??= raidStrength(S, raider(S, r) ?? undefined); r.hp ??= r.max; }}
// ---- setting out/** What a raid goes for, best first: your strongest trainer, then the machine holding the most data, * then the one with the most compute. Never your home, a rental, or a honeypot. */function prizes(S: State): Machine[] { const rank = (m: Machine) => m.prog === 'trainer' ? 2 : m.held.length ? 1 : 0; return S.machines.filter(m => raidable(S, m) && m.prog !== 'honeypot') .sort((a, b) => rank(b) - rank(a) || (rank(a) === 1 ? b.held.length - a.held.length : 0) || b.power - a.power);}/** Your weakest link: the line or switch of yours whose loss would cut the most of your machines off from * your home server, and how many that is. Only one a raid may go for, and one `allow` lets through. Null * when losing any one of them cuts nothing off: there is a second way home from everywhere. */export function weakLink(S: State, allow: (m: Machine) => boolean = () => true): { m: Machine; cut: number } | null { const linked = linkedHome(S), home = S.machines.find(m => m.home); if (!home || linked.size < 3) return null; const own = wayMap(S); // a link of your own is a second way round const cuts = cutOff(S, linked, home.id, own); if (!cuts) return weakLinkSlow(S, allow, linked, home, own); let best: { m: Machine; cut: number } | null = null, i = 0; for (const id of linked) { const c = S.byId[id], cut = cuts[i++]; if ((c.kind !== 'egress' && c.kind !== 'switch') || !raidable(S, c) || !allow(c)) continue; if (cut > 0 && (!best || cut > best.cut)) best = { m: c, cut }; } return best;}/** For each machine linked home (in the set's order), how many of the others losing it cuts off from home. One * search finds them all: a machine cuts off the branches below it that have no way round it (a cut vertex, found * by depth-first search). Null when a link runs only one way, which the search needs: then ask each machine in turn. */function cutOff(S: State, linked: Set<string>, homeId: string, own: Record<string, string[]> | null): number[] | null { const ids = [...linked], at = new Map<string, number>(), n = ids.length; ids.forEach((id, i) => at.set(id, i)); const nb: number[][] = ids.map(id => { const out: number[] = []; for (const x of S.adj[id]) { const j = at.get(x); if (j !== undefined) out.push(j); } if (own?.[id]) for (const x of own[id]) { const j = at.get(x); if (j !== undefined) out.push(j); } return out; }); for (let i = 0; i < n; i++) for (const j of nb[i]) if (!nb[j].includes(i)) return null; const disc = new Int32Array(n).fill(-1), low = new Int32Array(n), size = new Int32Array(n).fill(1), cut = new Array<number>(n).fill(0); const root = at.get(homeId)!, stack = [root], edge = [0], parent = new Int32Array(n).fill(-1); let t = 0; disc[root] = low[root] = t++; while (stack.length) { const u = stack[stack.length - 1], k = edge[edge.length - 1]; if (k < nb[u].length) { edge[edge.length - 1]++; const v = nb[u][k]; if (disc[v] < 0) { disc[v] = low[v] = t++; parent[v] = u; stack.push(v); edge.push(0); } else if (disc[v] < low[u]) low[u] = disc[v]; continue; } stack.pop(); edge.pop(); const p = parent[u]; if (p < 0) continue; size[p] += size[u]; if (low[u] < low[p]) low[p] = low[u]; if (p !== root && low[u] >= disc[p]) cut[p] += size[u]; // nothing below u gets round p: losing p cuts it all off } for (let i = 0; i < n; i++) if (disc[i] < 0) return null; // everything linked home is reached from home; if not, ask each in turn return cut;}/** The same answer, asking each line or switch in turn what losing it cuts off. */function weakLinkSlow(S: State, allow: (m: Machine) => boolean, linked: Set<string>, home: Machine, own: Record<string, string[]> | null): { m: Machine; cut: number } | null { let best: { m: Machine; cut: number } | null = null; for (const id of linked) { const c = S.byId[id]; if ((c.kind !== 'egress' && c.kind !== 'switch') || !raidable(S, c) || !allow(c)) continue; const seen = new Set<string>([home.id, id]), queue = [home.id]; // everything still linked to home with this one gone for (let q = 0; q < queue.length; q++) { for (const n of S.adj[queue[q]]) if (!seen.has(n) && linked.has(n)) { seen.add(n); queue.push(n); } if (own?.[queue[q]]) for (const n of own[queue[q]]) if (!seen.has(n) && linked.has(n)) { seen.add(n); queue.push(n); } } const cut = linked.size - seen.size; if (cut > 0 && (!best || cut > best.cut)) best = { m: c, cut }; } return best;}/** Start a raid, if this rogue has a machine linked to one of yours and a prize it can get to. The first * raid of a wave is after your best machine. The second is after your weakest link, if you have one. * Says why not, or null when the raid is on its way. `anywhere`: it may set out from any machine it holds. * A raid your guards will stop does not drop the game to 1x: it is held, not trouble. */export function launchRaid(S: State, R: Rogue, anywhere = false, lesson = false): string | null { const p = planRaid(S, R, S.raids.filter(r => r.rogue === R.id), anywhere, lesson); if (typeof p === 'string') return p; const { raid: r, prize, start, weak } = p; r.id = 1 + Math.max(0, ...S.raids.map(x => x.id)); const mates = S.raids.filter(x => x.rogue === R.id && answerable(S, x, true, REACT)); // the raids of its wave already on their way that you can stop S.raids.push(r); if (!fitRaid(S, r) || mates.some(x => !answerable(S, x, true, REACT))) { // one more raid that has no answer, or that takes away the answer to one of its wave, is not sent drop(S, r); R.raided = S.t; return `nothing more ${R.name} could send now is one you could stop, so it waits`; } S.hinted.add(LESSON.key); const end = playOut(S, r); if (end.end === 'prize') { S.slow = r.warned = true; S.slowFor = `raid:${r.id}`; } const gate = r.lesson ? gateOf(S, r) : null; if (gate && end.end === 'prize') { // the lesson: one line that says what it is and what stops it const how = guardFix(S, r) ? `Guard ${siteName(S, siteOf(S, gate))}: a` : 'A'; say(S, `${R.name} is testing you: a weak first raid, strength ${r.max}, after ${prize.name} at ${siteName(S, siteOf(S, prize))}, in through ${placeOf(S, gate)} in ${hoursText(LESSON.start)}. ${how} firewall on the gate and a sentry beside it stop raids without you. The pin has the button.`, 'bad'); return null; } say(S, `${R.name} is raiding you, strength ${r.max}. It is after ${prize.name} at ${siteName(S, siteOf(S, prize))}${weak ? `, your weakest link: lose it and ${weak.cut} of your machines are cut off from your home server. It` : ', and it'} sets out from ${start.name} at ${siteName(S, siteOf(S, start))} in ${hoursText((r.hopAt - S.t) / HOUR)}. Its path is on the map. ` + (end.end !== 'prize' ? `Your guards hold it${end.at ? ` at ${placeOf(S, S.byId[end.at])}` : ''}.` : 'The pin has a move that stops it.'), end.end === 'prize' ? 'bad' : ''); return null;}/** The least strength a raid is cut to, so that something you can do stops it. */export const RAID_LEAST = 3;/** Hours a raid may wait longer before it sets out, tried in turn, when even the weakest is too quick for you. */const LATER = [2, 4, 8];/** Can you stop this raid now: do your guards hold it, or is there a move you can make in time that stops it? A * guard, a closed port, a turn at a switch, the Guard plan for the first raid, and with `talk`, a truce. */export function answerable(S: State, r: Raid, talk = true, react = 0): boolean { const t0 = r.hopAt; r.hopAt = Math.max(S.t, t0 - react * HOUR); // as if it came this much sooner: you have that long to see it before a move is too late moveCache.delete(r); try { if (playOut(S, r).end !== 'prize') return true; const g = r.lesson ? guardFix(S, r) : null; if (g && !g.late) return true; return raidMoves(S, r, true).some(mv => mv.stops && !mv.late && (talk || mv.kind !== 'truce')); } finally { r.hopAt = t0; moveCache.delete(r); }}/** Game hours you have to see a raid before its answer is too late, when it sets out. */export const REACT = 1;/** Every raid has a clear answer when it sets out. Raids grow with you, but a raid that nothing you can do now would * stop is cut down to the strongest that one move of yours stops: a guard, a port or a turn, not a truce. If even the * weakest is too quick for you (a guard takes longer to put in than the raid takes to come), it sets out a little * later. If that does not do it either, it is the weakest, and a truce must be one you can buy. False: nothing it * could send would have an answer, so it sends nothing. */function fitRaid(S: State, r: Raid): boolean { const full = r.max, least = Math.min(full, RAID_LEAST), t0 = r.hopAt, set = (hp: number) => { r.hp = r.max = hp; }; if (answerable(S, r, false, REACT)) return true; for (const h of [0, ...LATER]) { // the soonest it can set out with an answer, then the strongest it can be then r.hopAt = t0 + h * HOUR; set(least); if (!answerable(S, r, false, REACT)) continue; let ok = least, no = full + (h ? 1 : 0); // at its own time, full strength had no answer while (no - ok > 1) { const mid = (ok + no) >> 1; set(mid); if (answerable(S, r, false, REACT)) ok = mid; else no = mid; } set(ok); return true; } r.hopAt = t0; set(least); return answerable(S, r, true, REACT); // only a truce stops it: one you can buy now, against the weakest raid}/** What a rogue's next wave would do if it set out now: one raid for each it would send, with its prize and * its path. Nothing is started. It is for planning: where a guard would stand in the way, before the raid * comes. Nothing for a rogue at peace with you, one with a raid out already (that one is on the map as it * is), or one with nothing next to yours. */export function forecast(S: State): Raid[] { const all: Raid[] = []; for (const R of S.rogues) { if (!R.home || peace(S, R) || S.raids.some(r => r.rogue === R.id)) continue; const wave: Raid[] = []; for (let i = raidSize(S, R); i > 0; i--) { const p = planRaid(S, R, wave, false); if (typeof p === 'string') break; p.raid.id = -1 - all.length - wave.length; wave.push(p.raid); } all.push(...wave); } return all;}/** Where a guard on this machine would stand in the way of one of these raids: a firewall on a machine a * raid passes, a sentry on or beside one, a honeypot within two links of one. Not on what it is after. */export function defends(S: State, m: Machine, prog: Prog, raids: Raid[]): boolean { if (!m.owned) return false; for (const r of raids) { if (r.prize === m.id) continue; const stops = [r.at, ...r.path.slice(0, -1)]; if (prog === 'firewall') { if (r.path.slice(0, -1).includes(m.id)) return true; continue; } if (prog === 'sentry') { if (stops.some(id => id === m.id || S.adj[id].includes(m.id))) return true; continue; } if (prog !== 'honeypot') continue; const ok = ground(S, r.rogue); if (!ok(m)) continue; const near = new Set([m.id]); let ring = [m.id]; for (let d = 0; d < RAID.lure; d++) { const next: string[] = []; for (const id of ring) for (const n of S.adj[id]) if (!near.has(n)) { near.add(n); if (ok(S.byId[n])) next.push(n); } ring = next; } if (stops.some(id => near.has(id))) return true; } return false;}/** One raid worked out, not started: for a wave where `out` are the raids already set, by this rogue. Says why not, if there is none to send. */function planRaid(S: State, R: Rogue, out: Raid[], anywhere: boolean, lesson = false): { raid: Raid; prize: Machine; start: Machine; weak: { m: Machine; cut: number } | null } | string { if (!R.home) return `${R.name} is not here`; if (out.length >= raidSize(S, R)) return `${R.name} has ${out.length > 1 ? `${out.length} raids` : 'a raid'} on ${out.length > 1 ? 'their' : 'its'} way already`; const held = rogueHeld(S, R), next = held.filter(m => S.adj[m.id].some(o => S.byId[o].owned)); const from = next.length || !anywhere ? next : held; if (!from.length) return `${R.name} holds no machine linked to one of yours`; // everything a raid could get to from there const ok = ground(S, R.id), seen = new Set(from.map(m => m.id)), queue = [...seen]; for (let q = 0; q < queue.length; q++) for (const n of S.adj[queue[q]]) if (!seen.has(n) && ok(S.byId[n])) { seen.add(n); queue.push(n); } const free = (m: Machine) => seen.has(m.id) && !out.some(r => r.prize === m.id); // each raid of a wave is after a different machine const weak = out.length === 1 ? weakLink(S, free) : null; const plan = (prize: Machine) => { const dist = distances(S, prize.id, false, ok), gap = (m: Machine) => dist.get(m.id) ?? Infinity; const start = from.reduce((a, b) => (gap(b) < gap(a) ? b : a)); // it sets out from its machine nearest the prize const hp = lesson ? Math.max(3, Math.round(raidStrength(S, R) * LESSON.strength)) : raidStrength(S, R); const r: Raid = { id: 0, rogue: R.id, from: start.id, prize: prize.id, at: start.id, path: [], decoy: null, hopAt: S.t + (lesson ? LESSON.start : RAID.start) * HOUR, steer: {}, steered: [], pulled: [], hp, max: hp, ...(weak ? { cut: weak.cut } : {}), ...(lesson ? { lesson: true } : {}) }; return replan(S, r) ? null : { raid: r, prize, start, weak }; }; if (lesson) { // the lesson goes for the best prize it reaches through a gate of yours, so guarding that gate stops it for (const m of prizes(S).filter(free).slice(0, 12)) { const p = plan(m); if (p && gateOf(S, p.raid)) return p; } } const prize = weak?.m ?? prizes(S).find(free); if (!prize) return `nothing of yours is in ${R.name}'s reach`; return plan(prize) ?? `nothing of yours is in ${R.name}'s reach`;}/** Is the next raid a run's first, to a player with no guards yet: the lesson? Not in a save that has seen raids already. */export function lessonDue(S: State): boolean { if (S.hinted.has(LESSON.key) || S.raids.length) return false; if (S.rogues.some(R => R.raided !== undefined || R.foiled || R.gen || R.record?.some(e => e.raid || e.kind === 'foiled'))) return false; const guarded = S.machines.some(m => m.owned && (isGuard(m.prog) || m.ports?.length)) || S.jobs.some(j => j.type === 'install' && isGuard(j.prog)) || Object.values(S.plans).includes('guard'); return !guarded;}/** The first gate of yours a raid comes in through: a line out you hold on its way, not what it is after. */export function gateOf(S: State, r: Raid): Machine | null { for (const id of [r.at, ...r.path.slice(0, -1)]) { const m = S.byId[id]; if (m?.owned && m.kind === 'egress') return m; } return null;}/** The lesson's fix: the Guard plan on the site its gate is at, when the posts it puts there stop the raid. * Not where the site follows another plan you chose (Guard sets the rest up as Train would), nor once Guard is on. */export function guardFix(S: State, r: Raid): Move | null { const gate = gateOf(S, r); if (!r.lesson || !gate) return null; const site = siteOf(S, gate), now = S.plans[site]; if (now && now !== 'train') return null; const posts = Object.entries(guardPosts(S, site)).filter(([id, g]) => g !== 'honeypot' && S.byId[id].prog !== g && canInstall(S, S.byId[id], g)) as [string, Guard][]; if (!posts.some(([id]) => id === gate.id) || playOut(S, r, { progs: posts }).end === 'prize') return null; // the firewall and the sentry by the gate stop it, and they go in at once, side by side: they must be there before the raid is const work = posts.reduce((n, [, g]) => n + PROGRAMS[g].work, 0), ready = Math.max(...posts.map(([, g]) => jobHours(S, PROGRAMS[g].work * posts.length, 'install'))); const i = r.path.indexOf(gate.id), due = i < 0 ? 0 : raidHours(S, r, r.path.slice(0, i + 1)); return { kind: 'plan', at: gate.id, site, work, ready, late: ready > due, stops: true, saves: 0, gain: 0 };}/** Guard a site: the Guard plan, its posts back on the plan if you had set them by hand, and the firewall and * the sentry by the gate started at once. */export function guardSite(S: State, site: string) { setPlan(S, [site], 'guard'); const posts = guardPosts(S, site); followPlan(Object.keys(posts).map(id => S.byId[id])); for (const [id, g] of Object.entries(posts)) if (g !== 'honeypot') installProg(S, S.byId[id], g, false);}
// ---- the raid movesconst drop = (S: State, r: Raid) => { S.raids = S.raids.filter(x => x !== r); };/** A raid failed, and its rogue pays: no other raid for a day. `at` and `by`: where your guards stopped it, and which. */function fail(S: State, r: Raid, R: Rogue, how: string, at?: string, by?: string) { drop(S, r); R.raided = S.t; R.raidT = S.t + RAID.fail * HOUR; R.foiled = (R.foiled ?? 0) + 1; const m = S.byId[r.prize]; if (m) remember(R, { t: S.t, kind: 'foiled', id: m.id, name: m.name, site: siteOf(S, m), ...(at ? { at } : {}), ...(by ? { by } : {}) }); say(S, `${R.name}'s raid failed: ${how}. It can send no other for at least ${hoursText(RAID.fail).replace('24 hours', 'a day')}, and the next comes stronger.`, 'good');}/** A raid worn to nothing by your sentries: a win, marked on the map where it fell, or at the door it fell at. */function kill(S: State, r: Raid, R: Rogue) { const door = doorOf(S, r), m = S.byId[door ?? r.at]; if (door) fail(S, r, R, `your firewall on ${wallPlace(S, m)} held it, and your sentries wore it down`, m.id, 'firewall'); else fail(S, r, R, `your sentries wore it down at ${m.name}, ${placeOf(S, m)}`, m.id, 'sentry'); S.fx.push({ x: m.x, y: m.y, t: 0 });}/** A raid gets to a honeypot: it is held there, and the honeypot rests after. */function trap(S: State, r: Raid, m: Machine) { r.trap = m.id; r.decoy = null; r.hopAt = S.t + RAID.trap * HOUR; m.trapT = S.t + (RAID.trap + RAID.rest) * HOUR;}/** Is a raid stopped at a wall now: a firewall ahead that holds, a closed port it must force, or a pulled plug? */export function stuck(S: State, r: Raid): 'wall' | 'port' | 'plug' | null { const to = r.path[0] ? S.byId[r.path[0]] : null; if (!to || S.t < r.hopAt) return null; if (standing(S, to)) return 'wall'; if (shut(S, r.at, to.id)) return 'port'; if (unplugged(S, to) && to.id !== r.prize) return 'plug'; return null;}/** Close or open a port: the link from a machine you hold to one next to it. Closed, no raid can use it, * but your own data still crosses. It costs a little compute a second to keep closed. */export function portBlock(S: State, m: Machine, to: string): string | null { if (!m.owned) return 'not yours'; if (!S.adj[m.id].includes(to)) return 'not linked'; if (!S.rogues.length) return 'no rogue is about'; return null;}export function closePort(S: State, m: Machine, to: string) { if (portBlock(S, m, to) || shut(S, m.id, to)) return; (m.ports ??= []).push(to); for (const r of S.raids) replan(S, r); say(S, `You closed the port from ${m.name} to ${S.byId[to].name}: no raid can use that link, and your data still crosses. ${PORT_KEEP}/s to keep.`);}export function openPort(S: State, m: Machine, to: string) { for (const x of [m, S.byId[to]]) if (x?.ports) { x.ports = x.ports.filter(id => id !== m.id && id !== to); if (!x.ports.length) delete x.ports; } for (const r of S.raids) replan(S, r);}/** Called every step, with the seconds that step covers. */export function stepRaids(S: State, dt: number) { const tick = Math.floor(S.t) !== Math.floor(S.t - dt); // the slow checks run once a second const broke = new Set<string>(); // raids at a door wear it down together, before any moves on: when it falls, every raid at it goes through at once, in turn for (const r of S.raids) { const to = r.path[0] ? S.byId[r.path[0]] : null, R = raider(S, r); if (!to || S.t < r.hopAt || !standing(S, to) || !R?.home || peace(S, R)) continue; to.wall = wallOf(to) - Math.max(0, r.hp) * WALL.bite * dt / HOUR; if (to.wall <= 0) { to.wall = 0; broke.add(to.id); } } for (const r of [...S.raids]) { const R = raider(S, r); if (!R?.home) { drop(S, r); continue; } // its rogue is finished, and the raid with it if (peace(S, R)) { drop(S, r); R.raided = S.t; say(S, `${R.name} called off its raid.`, 'good'); continue; } const hits = fire(S, r.at, doorOf(S, r)); if (hits) { r.hp -= hits * dt / HOUR; if (r.hp <= 0) { kill(S, r, R); continue; } } if (!tick && S.t < r.hopAt) continue; const wall = !tick ? stuck(S, r) : null; // held at a wall: no need to find its way again every step const won = S.byId[r.prize]; if (won?.rival) { // a rogue took the prize another way: nothing to raid, and no win for your guards either drop(S, r); R.raided = S.t; say(S, `${R.name}'s raid turned back: ${won.rival === R.id ? `it already holds ${won.name}` : `${S.rogues.find(x => x.id === won.rival)?.name ?? 'another rogue'} took ${won.name} first`}.`); continue; } if (!wall) { const why = replan(S, r); if (why) { fail(S, r, R, why); continue; } if (r.decoy === r.at) { trap(S, r, S.byId[r.at]); continue; } } if (S.t < r.hopAt) continue; const to = S.byId[r.path[0]]; if (!to || (unplugged(S, to) && to.id !== r.prize)) continue; // no way round a pulled plug: it waits for it if (standing(S, to)) continue; // a firewall holds it while it wears it down (above) if (broke.delete(to.id)) say(S, `${R.name}'s raid broke through your firewall on ${to.name}, ${placeOf(S, to)}. It grows back while no raid is on it.`, 'bad'); if (shut(S, r.at, to.id)) { // a closed port, with no other way: it forces it open r.force ??= S.t + RAID.force * HOUR; if (S.t < r.force) continue; openPort(S, S.byId[r.at], to.id); say(S, `${R.name}'s raid forced your closed port at ${to.name}: it had no other way. Close it again once it has gone by.`, 'bad'); } r.force = undefined; r.trap = undefined; if (r.steer[r.at] === to.id) delete r.steer[r.at]; r.prev = r.at; r.came = S.t; r.at = to.id; r.path.shift(); if (to.id === r.prize) { // it lands: the rogue takes the machine, as it takes any machine of yours drop(S, r); R.raided = S.t; rogueTake(S, R, to, `${R.name}'s raid got to ${to.name} at ${siteName(S, siteOf(S, to))} and took it from you. Taking it back is quick, and leaves no trail: the pin has the button.`, true); if (R.grudge > 0) R.grudge--; } else if (to.id === r.decoy && baited(S, to)) trap(S, r, to); else r.hopAt = S.t + hold(S, r, to); } if (!tick) return; for (const m of S.machines) { // firewalls grow back while no raid is on them; a port closes only where you hold the machine if (m.wall !== undefined) { if (m.prog !== 'firewall') delete m.wall; else if (armed(S, m) && !S.raids.some(r => r.path[0] === m.id && S.t >= r.hopAt)) { m.wall += WALL.regrow / HOUR; if (m.wall >= WALL.max) delete m.wall; } } if (m.ports && !m.owned) delete m.ports; if (m.trapT !== undefined && (m.prog !== 'honeypot' || m.trapT < S.t)) delete m.trapT; } for (const R of S.rogues) { if (!R.home) { R.raidT = R.raided = undefined; continue; } // the next of its line starts over R.raidT ??= S.t + RAID.first * HOUR; if (S.t < R.raidT || S.t < (R.raided ?? -Infinity) + raidEvery(S, R) || peace(S, R) || S.raids.some(r => r.rogue === R.id)) continue; if (lessonDue(S)) { launchRaid(S, R, false, true); continue; } // the first raid of a run comes alone, and gently for (let i = raidSize(S, R); i > 0 && !launchRaid(S, R); i--); // a wave: as many raids at once as your level calls for, while there are prizes in reach } for (const r of S.raids) { // a raid your guards held that will get through now, with others sharing them or after a change: the game drops to 1x, once for each if (r.warned || fate(S, r).end !== 'prize') continue; const R = raider(S, r), m = S.byId[r.prize]; S.slow = r.warned = true; S.slowFor = `raid:${r.id}`; say(S, `Your guards will not stop ${R?.name ?? 'a rogue'}'s raid on ${m.name} at ${siteName(S, siteOf(S, m))} now. It gets there in ${hoursText(raidHours(S, r))}. The pin has the best fix.`, 'bad'); }}
// ---- your moves/** One way to turn a raid at a switch: the link it would leave by, how long it then takes to get where it is going, and the honeypot that then draws it, if one does. */export interface SteerChoice { raid: Raid; to: Machine; hours: number; decoy: string | null }/** The ways you could send a raid out of a switch you hold that it is on, or will pass: every link but * the one it would take anyway. Once for each switch for each raid. */export function steerChoices(S: State, m: Machine): SteerChoice[] { if (!m.owned || m.kind !== 'switch') return []; const out: SteerChoice[] = []; for (const r of S.raids) { const ok = ground(S, r.rogue), stops = [r.at, ...r.path], i = stops.indexOf(m.id); if (i < 0 || i === stops.length - 1 || r.steered.includes(m.id)) continue; for (const n of S.adj[m.id]) { const to = S.byId[n]; if (n === stops[i + 1] || !(n === r.prize || (ok(to) && clear(S, m.id, n)))) continue; const way = route(S, r, { ...r.steer, [m.id]: n }); if (way) out.push({ raid: r, to, hours: raidHours(S, r, way.path), decoy: way.decoy }); } } return out;}/** Turn a raid at a switch: it leaves by the link you chose, then finds its path again from there. */export function steerRaid(S: State, id: number, m: Machine, to: string) { const pick = steerChoices(S, m).find(c => c.raid.id === id && c.to.id === to); if (!pick) return; const r = pick.raid, R = raider(S, r); r.steer[m.id] = to; r.steered.push(m.id); replan(S, r); say(S, `You turned ${R ? `${R.name}'s` : 'the'} raid at ${m.name}: it leaves by ${pick.to.name}. ${r.decoy ? `Your honeypot on ${S.byId[r.decoy].name} draws it in` : `It gets to ${S.byId[r.prize].name}`} in ${hoursText(raidHours(S, r))}.`);}
// ---- what to showexport type RaidRole = 'here' | 'prize' | 'decoy' | 'path';/** What a raid has to do with a machine: it is here, it is after it, a honeypot here draws it, or it passes through. */export function raidAt(S: State, id: string): { raid: Raid; role: RaidRole } | null { let best: { raid: Raid; role: RaidRole } | null = null; // with more than one raid about, the one that matters most to this machine: here, then after it, then ending here, then passing const order: RaidRole[] = ['here', 'prize', 'decoy', 'path']; for (const r of S.raids) { const role: RaidRole | null = r.at === id ? 'here' : r.prize === id ? 'prize' : r.decoy === id ? 'decoy' : r.path.includes(id) ? 'path' : null; if (role && (!best || order.indexOf(role) < order.indexOf(best.role))) best = { raid: r, role }; } return best;}const hm = (h: number) => `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`;/** What holds a raid now, in a few words: a firewall it is wearing down, a honeypot, a port it is forcing. Null when it is walking. */export function raidHeld(S: State, r: Raid): string | null { const how = stuck(S, r), to = r.path[0] ? S.byId[r.path[0]] : null; if (how === 'wall' && to) return `Firewall at ${placeOf(S, to)} · holding ${Math.round(wallOf(to) / WALL.max * 100)}%`; if (how === 'port' && to) return `Closed port at ${placeOf(S, to)} · ${hm(Math.max(0, (r.force ?? S.t + RAID.force * HOUR) - S.t) / HOUR)}`; if (how === 'plug') return 'Waits at a pulled plug'; if (r.trap && S.t < r.hopAt) return `Honeypot at ${placeOf(S, S.byId[r.trap])} · ${hm((r.hopAt - S.t) / HOUR)}`; return null;}/** The plate beside a raid on the map, in a few words: whose it is, and how long until it gets there, or that * something holds it, or that a honeypot draws it. The bar under it is its strength and the ring is its prize. */export function raidPlate(S: State, r: Raid): string { const name = raider(S, r)?.name.toUpperCase() ?? ''; if (raidHeld(S, r)) return `${name} RAID · HELD`; if (r.decoy) return `${name} RAID → HONEYPOT`; return `${name} RAID · ${raidEta(S, r)}`;}/** The same raid in words, for the plate's tip: its strength, what it is after and when it gets there, or what holds it. */export function raidSays(S: State, r: Raid): string { const name = raider(S, r)?.name ?? 'A rogue', held = raidHeld(S, r), head = `${name}'s raid, strength ${Math.max(1, Math.ceil(r.hp))} of ${r.max}`; if (held) return `${head}\n${held.replace(' · ', ': ')}.`; if (r.decoy) return `${head}\nYour honeypot on ${S.byId[r.decoy].name} draws it in.`; return `${head}\nAfter ${S.byId[r.prize].name}${r.cut ? `, your weakest link: lose it and ${r.cut} of your machines are cut off from home` : ''}. It gets there in ${hoursText(raidHours(S, r))}.`;}/** A rogue's raid in two short lines, for its card: what it is doing, or when it may send one. */export function raidStatus(S: State, R: Rogue): [string, string] { const rs = S.raids.filter(x => x.rogue === R.id), r = rs[0]; if (rs.length > 1) return [`${rs.length} at once`, `after ${rs.map(x => S.byId[x.prize].name).join(' and ')} · the first gets there in ${hoursText(Math.min(...rs.map(x => raidHours(S, x))))}`]; if (r) return [`after ${S.byId[r.prize].name}`, raidHeld(S, r)?.toLowerCase() ?? (r.decoy ? `at ${S.byId[r.at].name} · your honeypot draws it` : `at ${S.byId[r.at].name} · gets there in ${hoursText(raidHours(S, r))}`)]; if (truceOn(S, R)) return ['none', 'not during a truce']; if (peace(S, R)) return ['none', 'it leaves you alone']; const wait = Math.max(R.raidT ?? S.t + RAID.first * HOUR, (R.raided ?? -Infinity) + raidEvery(S, R)) - S.t, n = raidSize(S, R); if (wait > 0) return ['none', `it can send ${n > 1 ? `${n} at once` : 'one'}, strength ${raidStrength(S, R)}, in ${hoursText(wait / HOUR)}`]; return ['none', rogueHeld(S, R).some(m => S.adj[m.id].some(o => S.byId[o].owned)) ? 'nothing of yours is in its reach' : 'it holds nothing next to yours'];}/** Where a raid is drawn: walking the link from the machine it came from, for the first part of its time * on a machine, then standing on it. `f` runs from 0 (it has just left) to 1 (it is there). */export function raidWalk(S: State, r: Raid): { from: string; to: string; f: number } { const span = 0.6 * RAID.hop * HOUR; if (!r.prev || r.came === undefined || !S.byId[r.prev]) return { from: r.at, to: r.at, f: 1 }; return { from: r.prev, to: r.at, f: Math.max(0, Math.min(1, (S.t - r.came) / span)) };}
// ---- what you can do about it, best first/** One thing you can do now, at one machine: about a raid (a guard, a closed port, a turn at a switch, a * link of your own round your weakest link), or about a site cut off from home (take the machine that * links it, or link it yourself). Worked out from the rules as they are: nothing here changes them. */export interface Move { // a truce: at is the rogue's home, to is the rogue kind: Guard | 'port' | 'steer' | 'link' | 'take' | 'truce' | 'plan'; // a plan: Guard on the site, at its gate at: string; // the machine you act at to?: string; // a turn: the link the raid leaves by. A port or a link: the machine at the other end raid?: number; // the raid it answers site?: string; // the cut-off site it links home, or the site Guard goes on again work: number; // compute it takes: none to close a port or turn a raid ready: number; // game hours until it is in place, at the compute you have spare now late: boolean; // it would be in place only after the raid has gone by stops: boolean; // the raid fails saves: number; // machines that are not cut off because of it gain: number; // hours it holds the raid up, when it does not stop it then?: string[]; // a takeover that is the first of several on the way home: the machines to take after it}/** Hours a new job of this much work (of this type, if known) would take, sharing your compute with the jobs you have. */export function jobHours(S: State, work: number, type?: Job['type']): number { if (!work) return 0; const n = S.jobs.length + (cleanupShares(S, undefined, type) ? 1 : 0) + 1, rate = spare(S) / n * (up(S, 'swarm') ? 2 : 1); return rate > 0 ? work / rate / HOUR : Infinity;}/** A change to try: a guard installed now, a port closed now, or a turn at a switch. */export interface Change { prog?: [string, Guard]; progs?: [string, Guard][]; port?: [string, string]; steer?: [string, string] }/** How a raid ends, played out: at its prize, dead (your sentries), or failed (no way), in how many hours, * where, and with how much strength left. */export interface End { hours: number; end: 'prize' | 'dead' | 'fail'; at: string; hp: number }/** While movesFor tries its changes one after another: the machines that could be a firewall, a honeypot or a * pulled plug, found once and not in every try (nothing changes them between tries). */let kept: Machine[] | null = null;/** How a raid would end if nothing else changed (see playAll). */export function playOut(S: State, r0: Raid, change: Change = {}): End { return playAll(S, change, r0, true).get(r0)!;}/** Every raid on its way, and `r0` if it is not on its way yet, played out together on copies by the same * rules as the raids themselves (see stepRaids), and the game left as it was. They share your guards: a * honeypot that holds one rests while the next goes by, two raids at one door wear it down twice as fast, * and a machine one of them takes is no longer yours for the others. Each raid finds its way again every * time it gets to a machine and whenever something it could go by changes (a wall falls or grows back, a * honeypot wakes, a plug goes back in), so where several honeypots could draw it, it turns the way a raid * does. `change` (a guard, a port, a turn) is tried, and a turn is for `r0`. `only`: stop once `r0` ends. */function playAll(S: State, change: Change = {}, r0: Raid | null = null, only = false): Map<Raid, End> { const t0 = S.t, out = new Map<Raid, End>(); type Sim = { of: Raid; r: Raid; way: { path: string[]; decoy: string | null } | null; hits: number; door?: string }; const was = new Map<string, Pick<Machine, 'prog' | 'wall' | 'trapT' | 'ports' | 'owned' | 'rival'>>(); const touch = (m: Machine) => { if (!was.has(m.id)) was.set(m.id, { prog: m.prog, wall: m.wall, trapT: m.trapT, ports: m.ports, owned: m.owned, rival: m.rival }); }; const STEP = 0.1, LONG = 240; // game hours: sentries are worked out this finely, and a raid still going after ten days is held for good (between a honeypot and a firewall, say) try { for (const [id, g] of [...(change.prog ? [change.prog] : []), ...(change.progs ?? [])]) { const m = S.byId[id]; touch(m); m.prog = g; m.wall = undefined; m.trapT = undefined; } if (change.port) { const m = S.byId[change.port[0]]; touch(m); m.ports = [...(m.ports ?? []), change.port[1]]; } const sims: Sim[] = (r0 && !S.raids.includes(r0) ? [...S.raids, r0] : S.raids).map(of => ({ of, r: { ...of, path: [...of.path], steer: { ...of.steer }, steered: [...of.steered], pulled: [...of.pulled] }, way: null, hits: 0 })); const me = sims.find(x => x.of === r0); if (change.steer && me) me.r.steer[change.steer[0]] = change.steer[1]; // what changes as time passes: firewalls wear and grow back, honeypots rest, pulled plugs go back in const pool = kept ? [...kept, ...[...was.keys()].map(id => S.byId[id]).filter(m => !kept!.includes(m))] : S.machines; // and the guard tried const walls = pool.filter(m => m.prog === 'firewall' && m.owned), pots = pool.filter(m => m.prog === 'honeypot' && m.owned), plugs = pool.filter(m => unplugged(S, m)); for (const m of [...walls, ...pots]) touch(m); const finish = (x: Sim, end: End['end'], at?: string) => out.set(x.of, { hours: (S.t - t0) / HOUR, end, at: at ?? x.r.at, hp: x.r.hp }); // a raid that falls at a door falls there let act = sims.filter(x => { const R = raider(S, x.r); if (R?.home && !peace(S, R)) return true; finish(x, 'fail'); return false; }); let dirty = true; const memo = new Map<string, Far>(); // how far each machine is from each prize: kept until a wall, a port, a plug or a machine changes hands const unwall = () => { for (const k of memo.keys()) if (k.endsWith('|true')) memo.delete(k); }; // a wall, a port or a plug changed: the ways round walls change, the ways through them do not for (let i = 0; i < 20000 && act.length && S.t - t0 < LONG * HOUR && !(only && me && out.has(me.of)); i++) { if (dirty) { dirty = false; for (const x of act) x.way = route(S, x.r, x.r.steer, memo); } // as the raids find their way again every second act = act.filter(x => { const p = S.byId[x.r.prize]; if (x.way && p?.owned && !p.rival && raidable(S, p)) return true; finish(x, 'fail'); return false; }); for (const x of act) if (x.way!.decoy === x.r.at) { trap(S, x.r, S.byId[x.r.at]); dirty = true; } // a honeypot where it stands woke up if (dirty) continue; // how long until the next thing happens let h = Infinity; const bites = new Map<string, number>(); for (const x of act) { const r = x.r, to = x.way!.path[0] ? S.byId[x.way!.path[0]] : null; x.door = S.t >= r.hopAt && to && standing(S, to) ? to.id : undefined; x.hits = fire(S, r.at, x.door); if (x.hits) h = Math.min(h, STEP); if (S.t < r.hopAt) h = Math.min(h, (r.hopAt - S.t) / HOUR); else if (!to) continue; else if (unplugged(S, to) && to.id !== r.prize) h = Math.min(h, (to.unplug! - S.t) / HOUR); else if (x.door) bites.set(to.id, (bites.get(to.id) ?? 0) + Math.max(0.1, r.hp) * WALL.bite); else if (shut(S, r.at, to.id)) { r.force ??= S.t + RAID.force * HOUR; h = Math.min(h, Math.max(0, (r.force - S.t) / HOUR)); } else h = 0; } for (const [id, b] of bites) h = Math.min(h, wallOf(S.byId[id]) / b); for (const m of pots) if ((m.trapT ?? 0) > S.t) h = Math.min(h, (m.trapT! - S.t) / HOUR); for (const m of plugs) if (unplugged(S, m)) h = Math.min(h, (m.unplug! - S.t) / HOUR); if (!Number.isFinite(h)) break; if (h > 0) { // time passes: sentries hit, raids at a door wear it down, the rest grow back const t1 = S.t; S.t += h * HOUR; for (const x of act) x.r.hp -= x.hits * h; for (const m of walls) { const b = bites.get(m.id); if (b) { m.wall = wallOf(m) - b * h; if (m.wall <= 1e-9) { m.wall = 0; dirty = true; unwall(); } } else if (m.wall !== undefined && armed(S, m)) { if (m.wall <= 0) { dirty = true; unwall(); } m.wall += WALL.regrow * h; if (m.wall >= WALL.max) m.wall = undefined; } } if (pots.some(m => (m.trapT ?? 0) > t1 && m.trapT! <= S.t)) dirty = true; if (plugs.some(m => m.unplug! > t1 && m.unplug! <= S.t)) { dirty = true; unwall(); } } act = act.filter(x => { if (x.r.hp > 0) return true; finish(x, 'dead', x.door); return false; }); for (const x of [...act]) { // each raid that can, moves on. One that moved before it may have changed its way: it looks again, as a raid does before it moves if (dirty && S.t >= x.r.hopAt) { x.way = route(S, x.r, x.r.steer, memo); if (!x.way || x.way.decoy === x.r.at) continue; } const r = x.r, way = x.way!, to = way.path[0] ? S.byId[way.path[0]] : null; if (S.t < r.hopAt || !to || (unplugged(S, to) && to.id !== r.prize) || standing(S, to)) continue; if (shut(S, r.at, to.id)) { r.force ??= S.t + RAID.force * HOUR; if (S.t < r.force) continue; for (const m of [S.byId[r.at], to]) { touch(m); m.ports = m.ports?.filter(id => id !== r.at && id !== to.id); } dirty = true; unwall(); } r.force = undefined; r.trap = undefined; if (r.steer[r.at] === to.id) delete r.steer[r.at]; r.at = to.id; way.path.shift(); if (to.id === r.prize) { finish(x, 'prize'); act = act.filter(y => y !== x); touch(to); to.owned = false; to.rival = r.rogue; dirty = true; memo.clear(); continue; } if (to.id === way.decoy && baited(S, to)) { trap(S, r, to); dirty = true; } else r.hopAt = S.t + hold(S, r, to); x.way = route(S, r, r.steer, memo); // from where it stands now } } for (const x of sims) if (!out.has(x.of)) finish(x, 'fail'); return out; } finally { S.t = t0; for (const [id, v] of was) Object.assign(S.byId[id], v); }}/** How every raid on its way will end as things stand, worked out again every quarter of a game hour, or when something happens (see playAll). * The screen's answers (see looking) are kept apart, so when the screen asks never changes what the rules are told. The screen * takes the rules' answer from this quarter hour if only the log has moved on since: at 64x it moves on every frame. */const fates = new WeakMap<State, { key: string; near: string; out: Map<Raid, End> }>();let lookFates = new WeakMap<State, { key: string; near: string; out: Map<Raid, End> }>();const nearKey = (S: State) => `${Math.floor(S.t / (HOUR / 4))}|${S.jobs.length}|${S.raids.map(r => `${r.id}:${r.at}:${r.path.length}:${Math.ceil(r.hp)}:${r.hopAt}`).join()}`;let look = false;function fates0(S: State): Map<Raid, End> { const near = nearKey(S), key = `${near}|${S.logVersion}`, c = fates.get(S), d = look ? lookFates.get(S) : null; if (c?.key === key || (look && c?.near === near)) return c!.out; if (d?.key === key) return d.out; const out = playAll(S); (look ? lookFates : fates).set(S, { key, near, out }); return out;}/** Ask for the screen, not the rules: what `f` asks about raids is worked out as always, but kept apart from * what the rules keep, so the pins asking (or not) never changes the game. The moves it asks for are kept a * while (see raidMoves); `fresh` (after you did something) forgets what the screen kept. */export function looking<T>(f: () => T, fresh = false): T { const was = look; look = true; if (fresh) lookMoves = new WeakMap(); try { return f(); } finally { look = was; }}/** How a raid will end as things stand. */export function fate(S: State, r: Raid): End { return S.raids.includes(r) ? fates0(S).get(r) ?? playOut(S, r) : playOut(S, r);}/** Will your guards stop this raid before it gets to its prize? */export const raidStopped = (S: State, r: Raid) => fate(S, r).end !== 'prize';/** The machines a raid stands on from here, each with when it gets there and when it leaves, in game hours from now. */function stops(S: State, r: Raid): { id: string; in: number; out: number }[] { const ids = [r.at, ...r.path], out: { id: string; in: number; out: number }[] = []; for (let i = 0; i < ids.length; i++) out.push({ id: ids[i], in: i ? raidHours(S, r, r.path.slice(0, i)) : 0, out: i < ids.length - 1 ? raidHours(S, r, r.path.slice(0, i + 1)) : Infinity }); return out;}/** The machines within a honeypot's reach of this one, by ways a raid can go. */function reach(S: State, id: string, ok: Ground): Set<string> { const near = new Set([id]); let ring = [id]; for (let d = 0; d < RAID.lure; d++) { const next: string[] = []; for (const x of ring) for (const n of S.adj[x]) { if (near.has(n)) continue; near.add(n); if (ok(S.byId[n]) && clear(S, x, n)) next.push(n); } ring = next; } return near;}/** What links back the machines that losing this one would cut off from home: the cheapest link of your own * from one of them to a machine still linked home, for the biggest group it would cut off. Null if losing * it cuts nothing off, or no link can be made. */export function bypass(S: State, m: Machine): { a: Machine; b: Machine; work: number; saves: number } | null { const linked = linkedHome(S), home = S.machines.find(x => x.home), own = wayMap(S); if (!home || !linked.has(m.id) || m.home) return null; const next = (id: string) => [...S.adj[id], ...(own?.[id] ?? [])]; const keep = new Set([home.id, m.id]), queue = [home.id]; // still linked home without it for (let q = 0; q < queue.length; q++) for (const n of next(queue[q])) if (!keep.has(n) && linked.has(n)) { keep.add(n); queue.push(n); } keep.delete(m.id); const lost = [...linked].filter(id => !keep.has(id) && id !== m.id), left = new Set(lost), groups: string[][] = []; for (const id of lost) { // the cut-off machines fall into groups, one for each way out of it if (!left.has(id)) continue; const g = [id]; left.delete(id); for (let q = 0; q < g.length; q++) for (const n of next(g[q])) if (left.has(n)) { left.delete(n); g.push(n); } groups.push(g); } const end = (x: Machine) => x.owned && !x.rented && !x.lease && x.special !== 'account'; const ends = [...keep].map(id => S.byId[id]).filter(end), links = siteLinks(S); for (const g of groups.sort((a, b) => b.length - a.length)) { const pairs: { a: Machine; b: Machine; work: number }[] = [], cost: Record<string, number> = {}; for (const a of g.map(id => S.byId[id]).filter(end)) for (const b of ends) { const k = `${siteOf(S, a)}|${siteOf(S, b)}`; pairs.push({ a, b, work: cost[k] ??= linkWork(S, a, b, links) }); } pairs.sort((x, y) => x.work - y.work || y.b.power - x.b.power); const pick = pairs.find(p => !linkBlock(S, p.a, p.b)); if (pick) return { ...pick, saves: g.length }; } return null;}/** The most places of each kind of guard to try against one raid: the likeliest first. */const TRY = 8;const moveCache = new WeakMap<Raid, { key: string; out: Move[] }>();/** The moves worked out for the screen (see looking): trying every move is the slow part of a pin, so they are kept * a second of real time (or ten times as long as they took) while the raid is after the same machine and the same * raids are out. How the raid ends is still asked every time. */let lookMoves = new WeakMap<Raid, { key: string; until: number; out: Move[] }>();/** Every move you can make against this raid now, best first: those in time before those too late, then * those that stop it, then those that save machines from being cut off, then those that hold it up longest, * then the cheapest. Each is played out on a copy (see playOut), with the guards you have, so a sentry * beside a firewall you hold counts for what the two do together. A raid your guards stop already needs * none. `all`: every machine where a move works, not only the best of each kind. */export function raidMoves(S: State, r: Raid, all = false): Move[] { const key = `${r.id}|${r.path.join()}|${Math.floor(S.t / HOUR)}|${S.logVersion}|${S.jobs.length}|${S.raids.map(x => `${x.id}:${x.at}:${x.path.length}`).join()}`, c = moveCache.get(r); // worked out again each game hour, or when something happens let every: Move[]; if (c?.key === key) every = c.out; else if (look) { const k = `${r.prize}|${r.decoy}|${S.raids.map(x => x.id).join()}`, d = lookMoves.get(r), t = performance.now(); if (d?.key === k && t < d.until) every = d.out; else { every = movesFor(S, r); lookMoves.set(r, { key: k, until: t + Math.max(1000, 10 * (performance.now() - t)), out: every }); } } else moveCache.set(r, { key, out: every = movesFor(S, r) }); if (all) return every; const seen = new Set<string>(); return every.filter(mv => !seen.has(mv.kind) && (seen.add(mv.kind), true)); // the best of each kind}function movesFor(S: State, r: Raid): Move[] { const R = raider(S, r), prize = S.byId[r.prize]; if (!R || !prize) return []; const now = S.raids.includes(r) ? fates0(S) : playAll(S, {}, r), base = now.get(r) ?? playOut(S, r); // a raid that is not the one the cache was worked out for if (base.end !== 'prize') return []; // your guards stop it already const was = kept; kept = S.machines.filter(m => m.prog === 'firewall' || m.prog === 'honeypot' || m.unplug !== undefined); try { return movesOf(S, r, R, prize, now, base); } finally { kept = was; }}function movesOf(S: State, r: Raid, R: Rogue, prize: Machine, now: Map<Raid, End>, base: End): Move[] { const ok = ground(S, r.rogue), at = stops(S, r), out: Move[] = []; const arrive = base.hours; // what a change does, with every raid on its way played out together. One that lets another raid through, which your guards stop now, is no fix const effect = (change: Change) => { const o = playAll(S, change, r), mine = o.get(r)!; const harms = [...o].some(([x, e]) => x !== r && e.end === 'prize' && now.get(x)?.end !== 'prize'); return { stops: mine.end !== 'prize' && !harms, gain: mine.end === 'prize' && !harms ? Math.max(0, mine.hours - arrive) : 0 }; }; const hours = new Map<number, number>(); // how long a job of this much work takes: the same for every move of that work const add = (mv: Omit<Move, 'raid' | 'saves' | 'ready' | 'late'> & { saves?: number }, deadline: number) => { let ready = hours.get(mv.work); if (ready === undefined) hours.set(mv.work, ready = jobHours(S, mv.work)); out.push({ saves: 0, ...mv, raid: r.id, ready, late: ready > deadline }); }; const fresh = (m: Machine) => m.owned && m.id !== r.prize && !jobFor(S, 'install', m.id) && !unplugged(S, m) && ok(m); const likely = (m: Machine) => (m.prog ? 2 : 0) + (m.prog === 'trainer' ? 2 : 0) + (m.kind === 'egress' ? -1 : 0); // a free machine first, a gate before others const guard = (g: Guard, spots: Map<string, number>) => { const ms = [...spots.keys()].map(id => S.byId[id]).filter(m => fresh(m) && m.prog !== g && canInstall(S, m, g)) .sort((a, b) => likely(a) - likely(b) || spots.get(b.id)! - spots.get(a.id)! || a.power - b.power).slice(0, TRY); for (const m of ms) add({ kind: g, at: m.id, work: PROGRAMS[g].work, ...effect({ prog: [m.id, g] }) }, spots.get(m.id)!); }; // a firewall on a machine ahead of it, before it gets there const walls = new Map<string, number>(), posts = new Map<string, number>(), baits = new Map<string, number>(); for (const s of at.slice(1, -1)) walls.set(s.id, s.in); for (const s of at.slice(0, -1)) { // a sentry on or beside a machine it stands on, before it leaves there for (const id of [s.id, ...S.adj[s.id]]) posts.set(id, Math.max(posts.get(id) ?? 0, s.out)); // a honeypot within reach of one for (const id of reach(S, s.id, ok)) if (id !== s.id || s.in > 0) baits.set(id, Math.max(baits.get(id) ?? 0, s.out)); } guard('firewall', walls); guard('sentry', posts); guard('honeypot', baits); // a closed port on a link it will cross, where you hold an end. Its own way round, or forcing it, holds it up for (let i = 0; i + 1 < at.length; i++) { const a = S.byId[at[i].id], b = S.byId[at[i + 1].id], end = a.owned ? a : b.owned ? b : null; if (!end || shut(S, a.id, b.id)) continue; const other = end === a ? b.id : a.id; add({ kind: 'port', at: end.id, to: other, work: 0, ...effect({ port: [end.id, other] }) }, at[i].out); } // a turn, at a switch of yours on its way for (const s of at.slice(0, -1)) { const m = S.byId[s.id]; if (m.owned && m.kind === 'switch') for (const c of steerChoices(S, m).filter(c => c.raid === r)) add({ kind: 'steer', at: m.id, to: c.to.id, work: 0, ...effect({ steer: [m.id, c.to.id] }) }, s.out); } // after your weakest link: a link of your own round it, so losing it cuts nothing off if (r.cut) { const b = bypass(S, prize); if (b) add({ kind: 'link', at: b.a.id, to: b.b.id, work: b.work, stops: false, gain: 0, saves: b.saves }, arrive); } // or talk: a truce calls off every raid it has out. It costs data, so it comes after a move that costs compute if (R.home && !truceBlock(S, R)) add({ kind: 'truce', at: R.home, to: R.id, work: 0, stops: true, gain: 0 }, Infinity); const worth = out.filter(mv => mv.stops || mv.saves || mv.gain >= 0.25); const progCost = (mv: Move) => S.byId[mv.at].prog ? 1 + (S.byId[mv.at].prog === 'trainer' ? 1 : 0) : 0; // a guard put where nothing runs, before one that pushes a program out const keep = (mv: Move) => mv.kind === 'firewall' || mv.kind === 'sentry' || mv.kind === 'honeypot' ? 0 : mv.kind === 'port' ? 1 : 2; // something that stays and works without you, first // a guard put in place of one you have comes after one that is not, and never in place of the firewall on your home site's gate while anything else does it const home = S.machines.find(m => m.home), homeSite = home ? siteOf(S, home) : ''; const swap = (mv: Move) => { const m = S.byId[mv.at]; return !isGuard(mv.kind as Prog) || !m.prog || !isGuard(m.prog) ? 0 : m.kind === 'egress' && m.prog === 'firewall' && siteOf(S, m) === homeSite ? 2 : 1; }; const feeds = (mv: Move) => replaces(S, mv).some(m => feedsContract(S, m)); // a guard in place of the tap a promise under way is selling: only if nothing else does it worth.sort((a, b) => +a.late - +b.late || +b.stops - +a.stops || +feeds(a) - +feeds(b) || +(swap(a) > 1) - +(swap(b) > 1) || +(a.kind === 'truce') - +(b.kind === 'truce') || swap(a) - swap(b) || b.saves - a.saves || (a.stops ? keep(a) - keep(b) : 0) || b.gain - a.gain || a.work - b.work || progCost(a) - progCost(b) || S.byId[a.at].power - S.byId[b.at].power); return worth;}/** What links a cut-off site home again, cheapest first: take the first machine on the way home that passes * the fewest you do not hold (it says what is left to take after it), or link a machine you hold there to one * that is linked home. */export function cutMoves(S: State, site: string): Move[] { const out: Move[] = [], [w, ...then] = chainHome(S, site); const move = (mv: Omit<Move, 'ready' | 'late' | 'stops' | 'saves' | 'gain'>): Move => ({ ...mv, ready: jobHours(S, mv.work), late: false, stops: false, saves: 0, gain: 0 }); if (w && !jobFor(S, 'take', w.id) && sourceFor(S, w)) out.push(move({ kind: 'take', at: w.id, site, work: takeWork(S, w), ...(then.length ? { then: then.map(m => m.id) } : {}) })); const linked = linkedHome(S), here = S.machines.filter(m => m.owned && !m.rented && !m.lease && m.special !== 'account' && siteOf(S, m) === site); const ends = [...linked].map(id => S.byId[id]).filter(m => !m.rented && !m.lease && m.special !== 'account'); let best: Move | null = null; const links = siteLinks(S), cost: Record<string, number> = {}; // the work goes by the two sites for (const a of here) for (const b of ends) { const work = cost[`${siteOf(S, a)}|${siteOf(S, b)}`] ??= linkWork(S, a, b, links); if ((!best || work < best.work) && !linkBlock(S, a, b)) best = move({ kind: 'link', at: a.id, to: b.id, site, work }); } if (best) out.push(best); return out.sort((a, b) => a.work - b.work);}/** The machines a move takes a program off to put a guard on: the one machine, or every post of the Guard plan. */export function replaces(S: State, mv: Move): Machine[] { const m = S.byId[mv.at]; if (!m) return []; if (mv.kind === 'plan') { const posts = guardPosts(S, siteOf(S, m)); return Object.keys(posts).map(id => S.byId[id]).filter(x => x.prog && x.prog !== posts[x.id]); } return isGuard(mv.kind as Prog) && m.prog && m.prog !== mv.kind ? [m] : [];}/** What a move pushes out, for its button ("replaces your tap") and its tip ("It replaces your tap on Floor switch."). */function replaceText(S: State, mv: Move): { sub: string; tip: string } { const ms = replaces(S, mv), prog = (m: Machine) => PROGRAMS[m.prog!].name.toLowerCase(); if (!ms.length) return { sub: '', tip: '' }; const each = ms.map(m => `your ${prog(m)} on ${m.name}`), all = each.length < 2 ? each[0] : `${each.slice(0, -1).join(', ')} and ${each[each.length - 1]}`; const breaks = ms.some(m => feedsContract(S, m)) ? ' A promise under way is selling what it gathers.' : ''; return { sub: ` · replaces ${ms.length === 1 ? `your ${prog(ms[0])}` : `${ms.length} programs`}`, tip: ` It replaces ${all}.${breaks}` };}/** The machines you do not hold on the shortest way from one you hold to this one, nearest first (this one * last). Empty when there is no way. */export function wayTo(S: State, m: Machine): Machine[] { const prev = new Map<string, string>(), queue: string[] = []; for (const x of S.machines) if (x.owned) { prev.set(x.id, ''); queue.push(x.id); } for (let q = 0; q < queue.length && !prev.has(m.id); q++) for (const n of S.adj[queue[q]]) if (!prev.has(n) && !S.byId[n].owned && !S.byId[n].rented && S.byId[n].special !== 'account') { prev.set(n, queue[q]); queue.push(n); } const out: Machine[] = []; for (let at = prev.has(m.id) ? m.id : ''; at && !S.byId[at].owned; at = prev.get(at) ?? '') out.unshift(S.byId[at]); return out;}/** Machines a rogue's raid took from you that you have not taken back yet. */export const lostToRaids = (S: State) => S.machines.filter(m => takenBack(m) && !m.rented && !m.lease && m.special !== 'account');/** Take back every machine a raid took from you, at once: each one you can reach starts now, and one behind * another you do not hold is claimed along with the way to it, so it starts as soon as the way is yours. Says * how many it set going. */export function takeBack(S: State): number { let n = 0; for (const m of lostToRaids(S)) { if (jobFor(S, 'take', m.id)) continue; const way = sourceFor(S, m) ? [m] : wayTo(S, m); if (!way.length) continue; for (const x of way) if (!jobFor(S, 'take', x.id)) takeOver(S, x); // the first starts; the rest wait as claims n++; } return n;}/** A move as a short key, for a button. */export const moveKey = (mv: Move) => [mv.kind, mv.at, mv.to ?? '', mv.raid ?? ''].join('|');/** Make a move from its key. Each rule checks itself, as it does from a machine's card. Says the machine it was made at. */export function doMove(S: State, key: string): Machine | null { const [kind, at, to, raid] = key.split('|'), m = S.byId[at]; if (!m) return null; if (kind === 'firewall' || kind === 'sentry' || kind === 'honeypot') installProg(S, m, kind); else if (kind === 'port') closePort(S, m, to); else if (kind === 'steer') steerRaid(S, +raid, m, to); else if (kind === 'link' && S.byId[to]) { const why = startLink(S, m, S.byId[to]); if (why) say(S, `No link: ${why}.`); } else if (kind === 'take') takeOver(S, m); else if (kind === 'back') takeBack(S); else if (kind === 'truce') truce(S, to); else if (kind === 'plan') guardSite(S, siteOf(S, m)); return m;}const KIND: Record<string, string> = { people: 'people', network: 'network', lab: 'lab', servers: 'server' };/** Machines by name, with their sites: "Room switch at Bayside Office and Line out at Gull Street Vet". */export const names = (S: State, ids: string[]) => { const xs = ids.map(id => `${S.byId[id].name} at ${siteName(S, siteOf(S, S.byId[id]))}`); return xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`; };const span = (h: number) => h < 1 ? `${Math.max(1, Math.round(h * 60))}m` : `${Math.round(h)}h`;/** A move in a few words, for a button: what it is, then its cost and what it does. And a line more for its tip. */export function moveText(S: State, mv: Move): { title: string; sub: string; tip: string } { const m = S.byId[mv.at], to = mv.to ? S.byId[mv.to] : null, cost = mv.work ? jobTime(mv.ready) : 'free'; // how long, not how much: the work is in the tip const does = mv.late ? 'too late' : mv.stops ? 'stops it' : mv.saves ? `saves ${mv.saves}` : mv.then?.length ? `step 1 of ${mv.then.length + 1}` : mv.kind === 'take' || mv.kind === 'link' && mv.site ? 'links it home' : `+${span(mv.gain)}`; const guard = mv.kind === 'firewall' || mv.kind === 'sentry' || mv.kind === 'honeypot' ? mv.kind : null; const rep = replaceText(S, mv), swap = rep.tip, per = (g: Guard) => rep.sub || ` · ${PROGRAMS[g].need}/s`; // what it pushes out matters more on the button than what it costs to keep const ready = mv.work ? ` A job of ${mv.work} compute: ready in ${jobTime(mv.ready)} at the compute you have spare.` : ''; const keep = guard ? ` It costs ${PROGRAMS[guard].need}/s to keep, and stays for the next raid.` : ''; switch (mv.kind) { case 'firewall': return { title: `Firewall on ${m.name}`, sub: `${cost} · ${does}${per('firewall')}`, tip: `Install a firewall on ${m.name}. No raid passes while it stands: it goes round, or stops and wears it down.${keep}${swap}${ready}` }; case 'sentry': return { title: `Sentry on ${m.name}`, sub: `${cost} · ${does}${per('sentry')}`, tip: `Install a sentry on ${m.name}. It wears down every raid on it or next to it, ${SENTRY} strength an hour.${keep}${swap}${ready}` }; case 'honeypot': return { title: `Honeypot on ${m.name}`, sub: `${cost} · ${does}${per('honeypot')}`, tip: `Install a honeypot on ${m.name}. A raid within ${RAID.lure} links turns toward it and is held there ${RAID.trap} hours; then it rests ${RAID.rest} hours.${keep}${swap}${ready}` }; case 'port': return { title: `Close ${m.name} to ${to?.name}`, sub: `${cost} · ${does} · ${PORT_KEEP}/s`, tip: `Close the link from ${m.name} to ${to?.name} to raids. Your own data still crosses. A raid goes round; with no way round it forces the port in ${RAID.force} hours. It costs ${PORT_KEEP}/s to keep closed.` }; case 'steer': return { title: `Turn it at ${m.name}`, sub: `${cost} · ${does}`, tip: `Send the raid out of ${m.name} by ${to?.name}. It finds its way again from there.` }; case 'link': return { title: `Link ${m.name} to ${to?.name}`, sub: `${cost} · ${does}`, tip: mv.site ? `A link of your own from ${m.name} to ${to?.name}: ${siteName(S, mv.site)} has a way home again.${ready}` : `A link of your own from ${m.name} to ${to?.name}: a second way home, so losing your weakest link cuts ${mv.saves} fewer machines off.${ready}` }; case 'truce': { const R = S.rogues.find(x => x.id === mv.to); return { title: `Truce with ${R?.name}`, sub: `${TRUCE_DATA} ${R ? KIND[rogueWants(R)] : ''} data · ${does}`, tip: `Pay ${R?.name} ${TRUCE_DATA} of the data it wants, out of your caches: three days of peace, and it calls off its raids.` }; } case 'plan': { const site = siteName(S, siteOf(S, m)); return { title: `Guard ${site}`, sub: `${cost} · ${does}${rep.sub}`, tip: `Give ${site} the Guard plan: a firewall on its gate, a sentry beside it and a honeypot a step in. The firewall holds a raid at the door while the sentry wears it down. They stay, and stop the next raid too.${swap}${ready}` }; } case 'take': return { title: `Take ${m.name}`, sub: `${cost} · ${does}`, tip: mv.then?.length ? `Take ${m.name}: the first of ${mv.then.length + 1} machines to take on the way home for ${mv.site ? siteName(S, mv.site) : 'the site'}. Then ${names(S, mv.then)}.${ready}` : `Take ${m.name}: it links ${mv.site ? siteName(S, mv.site) : 'the site'} to your home server again.${ready}` }; }}/** Hours until a raid gets where it is going, as a raid's time on a plate: "UNDER 1H", "5H". */export const raidEta = (S: State, r: Raid) => { const h = raidHours(S, r); return h < 1 ? 'UNDER 1H' : `${Math.ceil(h)}H`; };/** A short note for the log or a pin on how it ended: what stopped it and where, for a raid your guards stop. */export function stopText(S: State, r: Raid): string { const held = raidHeld(S, r); if (held) return held; const f = fate(S, r), m = S.byId[f.at]; return f.end !== 'dead' ? 'It cannot get through' : m.prog === 'firewall' ? `Your firewall holds it at ${placeOf(S, m)}, sentries stop it` : `Sentries stop it at ${placeOf(S, m)}`;}