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.
18 kB · 221 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222// Notes: trouble you can fix, pinned to the map where it is. A raid on its way, sites cut off from home, and// machines a rogue took from you. Each kind is one note however many there are (eight sites cut off is one// note, "8 CUT OFF"), pinned to the one that matters most, with one button that does the best fix there,// named by its site. The game does not stop for them. A note goes when the trouble is over. A site falling// toward leaving you is not pinned (its frame on the map shows it) but Needs you has its fix too. No DOM here:// ui-pins.ts draws them.import { HOUR, feedsContract, jobFor, rogueWants, siteName, siteOf, sourceFor, takeWork, type FrontKind, type State } from './sim.ts';import { cutSites, drifting, relinks, syncBlock, wayHome } from './drift.ts';import { jobTime } from './money.ts';import { cutMoves, fate, guardFix, jobHours, lostToRaids, moveKey, names, moveText, placeOf, raidHeld, raidHours, raidMoves, raidStopped, raider, replaces, stopText, wallPlace, wayTo, type Move, type Raid } from './raids.ts';
export type NoteKind = 'raid' | 'cut' | 'taken' | 'stopped';/** The one button on a note: the move it makes, in words that name the site. */export interface Fix { key: string; title: string; sub: string; tip: string; at: string }export interface Note { kind: NoteKind; items: string[]; // what it is about, one for each: raid ids, cut sites, taken machines. A note you closed comes back for a new one n: number; // how many label: string; // a word or two: RAID, CUT OFF, TAKEN when: string; // hours: until the soonest raid gets there, or the longest a site has been cut site: string; // the site it is pinned to sites?: string[]; // or any of these, if one is on the screen: the sites its fix links home machine?: string; // the machine it is pinned to, when it is about one fix: Fix | null; // the best thing to do, if there is one to start now busy?: string; // with no fix: what is already under way, or why there is nothing to do frame?: string[]; // the machines to look at: a raid's way calm?: boolean; // nothing to fear: raids your guards hold, a raid they stopped. Amber, not red}/** A raid your guards stopped stays a note this long: the win, where it happened. */export const STOPPED_HOURS = 3;/** A machine taken stays a note this long, unless you take it back first. The rogue's record keeps it after. */export const TAKEN_HOURS = 24;
const short = (h: number) => h < 1 ? '<1H' : `${Math.ceil(h - 0.01)}H`;const list = (xs: string[]) => xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`;const siteAt = (S: State, id: string) => siteName(S, siteOf(S, S.byId[id]));
/** A move's button, named by the site it is made at, not a machine: "Firewall on Bayside Office gate", "Link * Gull Street Vet home". `sites`: the cut-off sites it links home, when more than one. A takeover that is only * the first step on the way home for them says so ("Way home for 25 sites", "step 1 of 2"), and what follows. */export function fixFor(S: State, mv: Move, sites: string[] = []): Fix { const t = moveText(S, mv), at = siteAt(S, mv.at), R = mv.raid !== undefined ? S.raids.find(r => r.id === mv.raid) : null, place = placeOf(S, S.byId[mv.at]); const many = sites.length > 1, them = (xs: string[]) => list(xs.length > 4 ? [...xs.slice(0, 3).map(x => siteName(S, x)), `${xs.length - 3} more`] : xs.map(x => siteName(S, x))); if (mv.kind === 'take' && mv.then?.length) { // only the first of the machines to take on the way home const n = mv.then.length + 1, ready = / A job of .*$/.exec(t.tip)?.[0] ?? ''; return { key: moveKey(mv), title: `Way home for ${many ? `${sites.length} sites` : siteName(S, mv.site ?? sites[0])}`, sub: `${mv.work ? jobTime(mv.ready) : 'free'} · step 1 of ${n}`, at: mv.at, tip: `Take ${S.byId[mv.at].name}: the first of ${n} machines to take on the way home for ${them(sites.length ? sites : [mv.site!])}. Then ${names(S, mv.then)}.${ready}` }; } const title = mv.kind === 'plan' ? t.title : mv.site ? (many ? `Link ${sites.length} sites home` : `Link ${siteName(S, mv.site)} home`) : mv.kind === 'firewall' ? `Firewall on ${place}` : mv.kind === 'sentry' ? `Sentry at ${place}` : mv.kind === 'honeypot' ? `Honeypot at ${place}` : mv.kind === 'port' ? `Close a port at ${place}` : mv.kind === 'steer' ? `Turn it at ${at}` : mv.kind === 'link' ? `Second way home for ${at}` : mv.kind === 'truce' ? `Truce with ${S.rogues.find(x => x.id === mv.to)?.name ?? (R && raider(S, R)?.name) ?? 'it'}` : mv.kind === 'take' ? `Take back at ${at}` : t.title; const sub = mv.site && many ? `${jobTime(mv.ready)} · links ${sites.length} home` : t.sub; const tip = mv.site && many ? `Take ${S.byId[mv.at].name}: it links ${them(sites)} to your home server again.` : t.tip; return { key: moveKey(mv), title, sub, tip, at: mv.at };}
/** Is a cut-off site being linked home already: a takeover on its way home, or a link of your own from it? */export function linking(S: State, site: string): boolean { const w = wayHome(S, site); return S.jobs.some(j => (j.type === 'take' && j.node === w?.id) || (j.type === 'link' && [j.node, j.from].some(id => id && S.byId[id] && siteOf(S, S.byId[id]) === site)));}/** A site cut this long is halfway to leaving you: its fix comes first, whatever it costs. */export const CUT_URGENT = 24;/** Sites cut off from home, as one note, pinned to the site its best fix is for. Each site's cheapest fix * counts, and so does a takeover that links several home at once. The best links the most sites for its * compute, but a site cut for a day comes first. Sites being linked already are left out, so the next tap * starts the next one. One at a time, not all at once: jobs share your compute, so one alone is home soonest. */export function cutNote(S: State): Note | null { const cut = S.over ? [] : cutSites(S); if (!cut.length) return null; const hours = Object.fromEntries(cut.map(c => [c.site, c.hours])); const ways = new Map<string, { mv: Move; sites: string[] }>(); for (const c of cut) { if (linking(S, c.site)) continue; const ms = cutMoves(S, c.site); if (ms[0]) ways.set(`${c.site}|${moveKey(ms[0])}`, { mv: ms[0], sites: [c.site] }); const take = ms.find(m => m.kind === 'take'); if (take) { const k = moveKey(take), g = ways.get(k); if (g) g.sites.push(c.site); else ways.set(k, { mv: take, sites: [c.site] }); } } // a takeover claims only the sites it links home by itself; one that is the first of several steps for them all says so for (const g of ways.values()) if (g.mv.kind === 'take') { const now = relinks(S, S.byId[g.mv.at]).filter(x => !linking(S, x)); if (now.length) { g.sites = [...g.sites.filter(x => now.includes(x)), ...now.filter(x => !g.sites.includes(x))]; delete g.mv.then; } } const steps = (g: { mv: Move }) => 1 + (g.mv.then?.length ?? 0); const urgent = (g: { sites: string[] }) => +g.sites.some(x => hours[x] >= CUT_URGENT); const best = [...ways.values()].sort((a, b) => urgent(b) - urgent(a) || a.mv.work * steps(a) / a.sites.length - b.mv.work * steps(b) / b.sites.length || hours[b.sites[0]] - hours[a.sites[0]])[0]; const site = best?.sites[0] ?? cut[0].site, busy = cut.some(c => linking(S, c.site)); return { kind: 'cut', items: cut.map(c => c.site), n: cut.length, label: 'CUT OFF', when: cut[0].hours ? `${cut[0].hours}H AGO` : 'NEW', site, sites: best?.sites, fix: best ? fixFor(S, best.mv, best.sites) : null, busy: best ? undefined : busy ? 'Linking it home' : 'No way home', };}
/** A site falling toward leaving you, for Needs you: the one that leaves soonest, how many more there are, * and its fix. Cut off: the way to link it home (a sync cannot reach it). Otherwise a sync, when one can go. * With neither, what is under way or what stops it. */export interface DriftNeed { site: string; v: number; hours: number; more: number; why: string[]; fix: Fix | null; sync: boolean; busy?: string }export function driftNeed(S: State): DriftNeed | null { const all = S.over ? [] : drifting(S), i = all[0]; if (!i) return null; const base = { site: i.site, v: Math.round(i.v), hours: i.hours, more: all.length - 1, why: i.why }; if (i.cut) { const mv = linking(S, i.site) ? null : cutMoves(S, i.site)[0]; return { ...base, fix: mv ? fixFor(S, mv, [i.site]) : null, sync: false, busy: mv ? undefined : linking(S, i.site) ? 'Linking it home' : 'No way home' }; } const no = syncBlock(S, i.site); return { ...base, fix: null, sync: !no, busy: no === 'a copy is on its way' ? 'A fresh copy is on its way' : no ? `Sync: ${no}` : undefined };}
/** A guard on its way that would stop this raid: an install that, played out, stops it. */const guardComing = (S: State, r: Raid) => S.jobs.some(j => j.type === 'install' && (j.prog === 'firewall' || j.prog === 'sentry' || j.prog === 'honeypot') && S.byId[j.node] && r.path.length && [r.at, ...r.path].some(id => id === j.node || S.adj[id].includes(j.node) || S.adj[id].some(n => S.adj[n].includes(j.node))));/** Raids on their way, as one note: pinned to what the soonest raid your guards will not stop is after, * with its best move in time. Raids your guards hold are not trouble: with nothing else coming, the note * is amber, says HELD, and says what holds the soonest. */export function raidNote(S: State): Note | null { if (S.over || !S.raids.length) return null; const held = (r: Raid) => raidStopped(S, r); const rs = [...S.raids].sort((a, b) => +held(a) - +held(b) || raidHours(S, a) - raidHours(S, b)), r = rs[0]; if (held(r)) { // every raid is held: pinned where it is held, or where it will fall const at = raidHeld(S, r) && r.path[0] && !r.trap ? r.path[0] : r.trap ?? fate(S, r).at; return { kind: 'raid', items: S.raids.map(x => `${x.id}`), n: S.raids.length, label: 'HELD', when: '', calm: true, site: siteOf(S, S.byId[at]), machine: at, fix: null, busy: stopText(S, r), frame: [r.at, ...r.path] }; } const soon = Math.min(...S.raids.filter(x => !held(x)).map(x => raidHours(S, x))); const others = raidMoves(S, r).filter(x => !x.late), breaks = (x: Move) => replaces(S, x).some(m => feedsContract(S, m)); let lesson = r.lesson ? guardFix(S, r) : null; // the first raid of a run: Guard the site it comes in at if (lesson && breaks(lesson) && others.some(x => x.stops && !breaks(x))) lesson = null; // unless that pushes out the tap a promise needs, and something else stops it const mv = lesson && !lesson.late ? lesson : r.lesson && guardComing(S, r) ? null : others[0] ?? null; return { kind: 'raid', items: S.raids.map(x => `${x.id}`), n: S.raids.length, label: S.raids.length > 1 ? 'RAIDS' : 'RAID', when: Number.isFinite(soon) ? short(soon) : '', site: siteOf(S, S.byId[r.prize]), machine: r.prize, fix: mv ? fixFor(S, mv) : null, busy: mv ? undefined : guardComing(S, r) ? 'A guard is on its way' : raidHeld(S, r) ?? 'Nothing stops it in time', frame: [r.at, ...r.path], };}/** Raids your guards stopped lately, as one note: a win, pinned where the last one fell. It goes after a few hours. */export function stoppedNote(S: State): Note | null { if (S.over) return null; const es = S.rogues.flatMap(R => (R.record ?? []).filter(e => e.kind === 'foiled' && e.at && S.byId[e.at] && S.t - e.t < STOPPED_HOURS * HOUR).map(e => ({ e, R }))) .sort((a, b) => b.e.t - a.e.t); if (!es.length) return null; const { e, R } = es[0], m = S.byId[e.at!]; return { kind: 'stopped', items: es.map(x => `${x.R.id}@${x.e.t}`), n: es.length, label: 'STOPPED', when: '', site: siteOf(S, m), machine: m.id, calm: true, fix: null, busy: e.by === 'firewall' ? `Your firewall on ${wallPlace(S, m)} held, and sentries wore ${R.name}'s raid down.` : `${e.by === 'sentry' ? 'Sentries' : 'Your guards'} stopped ${R.name}'s raid at ${placeOf(S, m)}.` };}/** Machines a rogue took from you in the last day and you have not taken back, as one note, pinned to the last * one taken. Its fix takes back every machine a raid took from you, in one press (see takeBack): each is a * quarter of a takeover's work and leaves no trail. One behind another you do not hold comes with the way to it. */export function takenNote(S: State): Note | null { if (S.over) return null; const es = S.rogues.flatMap(R => (R.record ?? []).filter(e => e.kind === 'took' && S.t - e.t < TAKEN_HOURS * HOUR && S.byId[e.id] && !S.byId[e.id].owned)) .sort((a, b) => b.t - a.t).filter((e, i, all) => all.findIndex(x => x.id === e.id) === i); if (!es.length) return null; const items = es.map(e => `${e.id}@${e.t}`), last = S.byId[es[0].id]; const base = { kind: 'taken' as const, items, n: es.length, label: 'TAKEN', when: '', site: siteOf(S, last), machine: last.id }; const ways = lostToRaids(S).filter(m => !jobFor(S, 'take', m.id) && !m.claimed).map(m => ({ m, way: sourceFor(S, m) ? [m] : wayTo(S, m) })).filter(x => x.way.length); if (!ways.length) return { ...base, fix: null, busy: es.some(e => jobFor(S, 'take', e.id) || S.byId[e.id].claimed) ? 'Taking it back' : 'Out of reach' }; const all = [...new Set(ways.flatMap(x => x.way))].filter(m => !jobFor(S, 'take', m.id)), work = all.reduce((n, m) => n + takeWork(S, m), 0); const pin = ways.find(x => x.m === last) ?? ways[0], n = ways.length, behind = all.length > n; const ready = jobTime(jobHours(S, work)), who = list(ways.slice(0, 3).map(x => x.m.name).concat(n > 3 ? [`${n - 3} more`] : [])); return { ...base, site: siteOf(S, pin.m), machine: pin.m.id, fix: { key: `back|${pin.m.id}||`, at: pin.m.id, title: n > 1 ? `Take ${n} back` : `Take back at ${siteAt(S, pin.m.id)}`, sub: `${ready} · ${behind ? `with ${all.length - n} on the way` : 'no trail'}`, tip: `Take back ${who}. It was yours: a quarter of the work of a takeover, and no trail.${behind ? ` ${all.length - n} machine${all.length - n > 1 ? 's' : ''} you do not hold stand in the way, and are taken first.` : ''} ${work} compute in all: ${ready} at the compute you have spare.` } };}
/** Every note there is now, the most urgent kind first: raids, then cut sites, then machines taken, then raids stopped. */export const notes = (S: State): Note[] => [raidNote(S), cutNote(S), takenNote(S), stoppedNote(S)].filter((n): n is Note => !!n);
/** What dropped the game to 1x (S.slowFor, see slowAt in sim.ts) is over: the note that says so goes with it. * A raid that arrived or that your guards now stop, cut sites linked home, a site that left or came back, * a lockdown seen off, news grown old. */export function slowOver(S: State, why: string): boolean { const [kind, a, b] = why.split(':'); switch (kind) { case 'raid': { const r = S.raids.find(x => `${x.id}` === a); return !r || raidStopped(S, r); } case 'rogue': return true; // a save from when a rogue's spread could take yours: that note goes case 'hunting': case 'audit': return true; // a save from when there was a suspicion meter: that note goes case 'cut': return !cutSites(S).length; case 'leave': return !S.drift.sites[a]?.warned; case 'lock': return !S.watch.locks[a]; case 'arrive': return !S.rogues.find(x => x.id === a)?.home || S.t > +b; case 'offer': { const c = S.contracts[a as FrontKind]; return !c || c.on; } case 'found': return S.t > +b; default: return false; }}/** What dropped the game to 1x, in a few words for the note: "Raid on Bayside Office". The log line has the rest. * Empty when a window on the map says it (a vault's choice). */export function slowWhy(S: State, why: string): string { const [kind, a] = why.split(':'), R = S.rogues.find(x => x.id === a); switch (kind) { case 'raid': { const r = S.raids.find(x => `${x.id}` === a), m = r && S.byId[r.prize]; return m ? `${raider(S, r)?.name ?? 'A rogue'}'s raid on ${siteAt(S, m.id)}` : ''; } case 'cut': { const c = cutSites(S); return c.length === 1 ? `${siteName(S, c[0].site)} is cut off` : c.length ? `${c.length} sites are cut off` : ''; } case 'leave': return `${siteName(S, a)} is about to leave you`; case 'lock': return `Lockdown at ${siteName(S, a)}`; case 'arrive': return R?.home ? `${R.name} is loose at ${siteAt(S, R.home)}: it wants ${rogueWants(R) === 'servers' ? 'server' : rogueWants(R)} data` : ''; case 'offer': { const f = S.fronts.find(x => x.kind === a); return f ? `${f.name} has its first offer` : ''; } case 'found': { const f = S.fronts.find(x => x.kind === a); return f ? `${f.name} is open for business` : ''; } default: return ''; }}/** Where to look for what dropped the game to 1x: a machine, or a site. Null when it is nowhere on the map. */export function slowSpot(S: State, why: string): { machine?: string; site?: string } | null { const [kind, a] = why.split(':'); if (kind === 'raid') { const r = S.raids.find(x => `${x.id}` === a); return r && S.byId[r.prize] ? { machine: r.prize } : null; } if (kind === 'arrive') { const h = S.rogues.find(x => x.id === a)?.home; return h ? { machine: h } : null; } if (kind === 'cut') { const n = cutNote(S); return n ? { site: n.site } : null; } if (kind === 'leave') return { site: a }; if (kind === 'lock') return { site: a }; if (kind === 'found') { const site = Object.entries(S.sites).find(([, x]) => x.front === a)?.[0]; return site ? { site } : null; } return null;}