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.
46 kB · 676 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677// Stealth is people. Every site has people (people.ts). While they are at work they notice odd things on// machines they can see: a takeover in progress, a fresh trail on their machine, a machine running odd,// hardware that appeared, a machine wiped out from under them. Each is a sighting. People in the same room// pass sightings on by talk; staff carry them between sites by the places they go; ordinary people// (guests, patients, regulars) notice and talk but can do nothing more.//// Two people backing each other up, or one person who can act (an admin, a manager, an owner), is a report:// it starts a lockdown at that site, a countdown you can see off (lie low, scrub trails, let go, pull out,// keep someone quiet). Let it run out and every machine of yours there is wiped and the site is hardened for// good. If the site holds your home, the run is over. A site you hold in the open has no lockdown: you control// the people there.//// An admin (or a small shop's owner) who holds a sighting at their own site also looks further: a scan walks the// site's network and finds your other odd machines and trails there, and a trace follows a trail back toward your// home, marking each machine of yours it reaches. The first trace that reaches your home server starts a lockdown// at the home site; a second within TRACE_WINDOW hours catches you. A power bill nobody pays gives away the// machines drawing it to whoever keeps the books there.//// This is the general machinery the office's Dana is one instance of: her walk is where she is, so she notices what// is odd in the room she is in. No DOM here. sim.ts loads this file and this file loads sim.ts, so nothing here uses// the rules' values while the file loads, only inside functions.import { HOUR, TRACE_COLD, addJob, billPaid, danaPerson, has, isSealed, knows, localHour, roomMachines, say, siteMachines, siteName, siteOf, slowAt, up, visible, wipe, type Machine, type State } from './sim.ts';import { canAct, citizens, tracks, visitorsAt, within, type CitizenDef } from './people.ts';
/** What someone saw: a takeover going on, a fresh trail on a machine, a machine running odd, hardware that * appeared (away from the office: there it is the power bill that gives it away), a machine of yours wiped where * they could see it, a trace of theirs that reached it, a power bill nobody can explain, or something they cannot * put a name to (mail that keeps vanishing; what was in the air when a save from before the people rules loaded). */export type SightKind = 'takeover' | 'trail' | 'odd' | 'hardware' | 'wiped' | 'traced' | 'bill' | 'doubt';export const SIGHT_WORD: Record<SightKind, string> = { takeover: 'a takeover', trail: 'odd traffic', odd: 'a machine acting strange', hardware: 'hardware that appeared', wiped: 'a machine wiped overnight', traced: 'a trail traced back to it', bill: 'a power bill nobody can explain', doubt: 'something odd they cannot name',};export interface Sighting { machine: string; kind: SightKind; t: number }/** A lockdown at one site: a countdown that ends in a wipe. `by`: the people who compared notes, for the pin. * `traced`: a trace by this person reached your home here; their sighting of it holds the lockdown too. */export interface SiteLock { site: string; left: number; by: string[]; why: string; traced?: string }/** An admin looking further than the room (see startTraces). A scan walks the admin's own site's network for up * to `hops` machines, drawn to odd load and trails; at a trail it turns into a trace, which follows the trail back * toward your home, one machine at a time. `from`, `to`: the hop it is on, `p` how far along. `seen`: where it has * been, newest last, for the map. */export interface Trace { by: string; site: string; mode: 'scan' | 'trace'; from: string; to: string; p: number; hops: number; seen: string[]; why: string; pull: string | null;}/** A record on the move. A staff member who saw something emails someone who can act about it: the record * travels the network from the sender's machine to the recipient's, hop by hop, drawn like data but as an * envelope. Hold a machine on its way and you can read, drop or change it. If it arrives, it is a report. * `enc`: encrypted (a bank, a lab, a big name); you can drop it, but reading or changing needs the Cipher node. * `changed`: rewritten to say nothing; it still arrives, but reports nothing. `altering`: a change job is under * way at `at`, and it waits there until the job is done. */export interface Message { id: number; sender: string; recipient: string; // the people machine: string; kind: SightKind; site: string; // what it is about, and where path: string[]; // machines it travels, sender's first, recipient's last len: number; left: number; // game-time to travel, and how much is left enc: boolean; changed?: boolean; read?: boolean; altering?: string; // the machine a change job is running on, while it is}export interface WatchState { seen: Record<string, Sighting[]>; // sightings each person holds now, by person id locks: Record<string, SiteLock>; // a lockdown under way at each site talkT: number; // when talk next spreads sightings warned: Record<string, boolean>; // a lockdown at a site has dropped the game to 1x once msgs: Message[]; // records on the move (see Message) msgSeq: number; routeHold: Record<string, number>; // a route is quiet until then: a dropped or changed message is not resent at once drops: Record<string, number>; // how many messages you have dropped on each route: a second makes the sender wonder traces: Trace[]; // admins' scans and traces under way (see Trace) scanT: Record<string, number>; // when each admin may next start a scan or a trace, by person id tracedAt: number; // when a trace last reached your home server: a second within TRACE_WINDOW catches you billOwed: Record<string, number>; // compute-hours on each site's power bill that nobody has paid, toward BILL_NOTICE count: { locks: number; homeLocks: number; traced: number }; // lockdowns started (at home among them), traces that reached home: for the You page and the survey}export const initWatch = (): WatchState => ({ seen: {}, locks: {}, talkT: 0, warned: {}, msgs: [], msgSeq: 0, routeHold: {}, drops: {}, traces: [], scanT: {}, tracedAt: -1e9, billOwed: {}, count: { locks: 0, homeLocks: 0, traced: 0 } });
export const SIGHT_ODD = 45; // a machine this odd is noticed by someone who can see itexport const SIGHT_FADE = 3 * 24; // sightings fade after this many game hoursexport const SIGHT_CAP = 4; // the most a person carries at once: the newest stayexport const LOCK_HOURS = 6; // how long a lockdown's countdown gives youexport const HARDEN = true; // a site a lockdown wiped is hardened: every machine there is 3x to takeexport const TALK_EVERY = 1; // people pass sightings on about this often (game hours)export const TALK_CHANCE = 0.5; // the chance one co-present person tells another, each time talk spreadsexport const HUSH_HOURS = 24; // a person paid to keep quiet says nothing for this longexport const MSG_HOP = 0.15; // a message takes this many game hours to cross one hop (minutes of game time: enough to react)export const MSG_FAR = 0.3; // a hop on a line between sites takes this longexport const RESEND_HOURS = 8; // a dropped or changed message is not resent on that route for this longexport const CHANGE_WORK = 40; // compute to rewrite a message so it arrives harmless (the Cipher node halves it)export const DISCREDIT_DAYS = 4; // a discredited witness's sightings do not count toward a report for this longexport const TRACE_HOP = 0.15; // a scan or trace takes this many game hours to cross one link at a site (a second or so at 1x)export const TRACE_FAR = 0.3; // and this long on a line between sitesexport const SCAN_HOPS = 10; // a scan looks at this many machines before it gives upexport const SCAN_EVERY = 2; // an admin starts a scan or a trace at most this often (game hours)export const TRACE_WINDOW = 12; // a second trace that reaches your home within this many game hours of the first catches youexport const TRACED_LINGER = 3; // a machine a trace reached stays on the admin's mind this long, longer while it still looks oddexport const DOUBT_HOURS = 4; // something odd a person cannot name stays on their mind this longexport const BILL_NOTICE = 240; // compute-hours of power nobody pays for before the books give the machines away (a 10-compute box for a day)export const BILL_HOURS = 4; // the books keeper grumbles about a bill this long; the next comes when the next BILL_NOTICE is owed/** Plausible deniability: sightings fade twice as fast, and an admin needs a fresher trail before following one. */const fadeK = (S: State) => (up(S, 'deniable') ? 0.5 : 1);const traceNeed = (S: State) => (up(S, 'deniable') ? 50 : TRACE_COLD);/** Mail from a bank, a lab or a big name is encrypted: you can drop it, but reading or changing it needs Cipher. */export const encrypted = (S: State, site: string) => !!S.level.map.sites.find(s => s.id === site)?.worth;
/** Is this person at work right now: still employed (a witness is always "around"), and the hour at their * site is inside their schedule, and the site is not dark. */export function present(S: State, c: CitizenDef): boolean { if (S.dark.has(c.site)) return false; if ((S.calmSites[c.site] ?? 0) > S.t) return false; // a scheme has the place looking the other way (see RUSES 'site') if (c.role !== 'witness' && !S.citizens[c.id]?.employed) return false; if ((S.citizens[c.id]?.hush ?? 0) > S.t) return false; // paid to keep quiet: they see and say nothing if (isDana(S, c)) return danaPerson(S).shift === 'in'; // she walks in and out of the building: in once she is through the door return within(localHour(S, c.site), c.hours);}/** The office's admin walks the rooms (see stepDana in sim.ts): her presence is where she is. */const isDana = (S: State, c: CitizenDef) => c.site === 'office' && !!S.dana && danaPerson(S).id === c.id;/** The room a person is in now: their own, but for Dana, whichever room her walk has her in (none in the hall). */export function roomNow(S: State, c: CitizenDef): string | null { return isDana(S, c) ? S.dana.room : c.room;}/** The machines a person can see: the machines in the room they are in (their own desk among them). */export function canSee(S: State, c: CitizenDef): readonly Machine[] { const room = roomNow(S, c); return room ? roomMachines(S, room) : [];}/** A person records a sighting: newest of a machine wins, capped, and never at a site you hold in the open. */export function sight(S: State, c: CitizenDef, m: Machine, kind: SightKind) { if (S.publicSites.has(siteOf(S, m))) return; // you control the people there const list = (S.watch.seen[c.id] ??= []), i = list.findIndex(x => x.machine === m.id); if (i >= 0) { list[i] = { machine: m.id, kind, t: S.t }; return; } list.push({ machine: m.id, kind, t: S.t }); if (list.length > SIGHT_CAP) list.sort((a, b) => b.t - a.t).length = SIGHT_CAP;}/** A machine of yours was wiped where people could see it: those present record it (see wipe in sim.ts). */export function sawWiped(S: State, m: Machine) { if (!S.watch || S.publicSites.has(siteOf(S, m))) return; for (const c of citizens(S.level).atSite[siteOf(S, m)] ?? []) if (c.room === m.room && present(S, c)) sight(S, c, m, 'wiped');}/** Keep this person quiet: they forget what they saw and say nothing for a day. */export function hush(S: State, id: string) { if (!S.watch) return; delete S.watch.seen[id]; S.citizens[id].hush = S.t + HUSH_HOURS * HOUR;}
/** Is a trail on this machine fresh enough, and not a quiet one, that a person at it would notice? */const noticeTrail = (S: State, m: Machine, need = TRACE_COLD) => { const tr = S.trail[m.id]; return !!tr && !tr.soft && tr.noise >= need && !isSealed(S, m.room); };/** A takeover in progress that is not one of the game's quiet first ones. */const loudTake = (S: State, m: Machine) => S.jobs.some(j => j.type === 'take' && j.node === m.id && !j.quiet);
/** One person at work sees what is odd on the machines they can see, and records it. Familiar face: nobody notices * their own machine acting up, and an admin only when it is very odd. Mimicry: a takeover looks like an update, * which only an admin looks at twice. Hardware that turns up at the office is no sighting: its power bill gives it * away (see stepBills). */export function lookFor(S: State, c: CitizenDef) { if (c.role === 'witness' || !present(S, c)) return; for (const m of canSee(S, c)) { if (!m.owned) continue; const own = m.id === c.desk && up(S, 'familiar'); if (loudTake(S, m) && !own && (!up(S, 'mimicry') || tracks(c))) sight(S, c, m, 'takeover'); else if (m.odd >= SIGHT_ODD && (!own || (tracks(c) && m.odd >= 80))) sight(S, c, m, 'odd'); else if (noticeTrail(S, m)) sight(S, c, m, 'trail'); else if (m.built && siteOf(S, m) !== 'office') sight(S, c, m, 'hardware'); }}/** Someone watched a takeover happen in front of them (see watchTakeovers in sim.ts): they record it at once. */export function sawTake(S: State, id: string, m: Machine) { const c = citizens(S.level).byId[id]; if (c && S.watch && (!up(S, 'mimicry') || tracks(c))) sight(S, c, m, 'takeover');}
/** Each person at work sees what is odd on the machines they can see, and records it. */function look(S: State) { for (const c of citizens(S.level).list) lookFor(S, c); // ordinary people on the floor see a takeover or a machine plainly acting up, and carry it for (const site of new Set(citizens(S.level).list.map(c => c.site))) for (const c of visitorsAt(S, site)) { if (!present(S, c)) continue; for (const m of S.machines) if (m.owned && siteOf(S, m) === site && (loudTake(S, m) || m.odd >= SIGHT_ODD + 15)) sight(S, c, m, loudTake(S, m) ? 'takeover' : 'odd'); }}
/** A sighting is still worth anything only while the machine is still yours and still shows the cause: let a * machine go, scrub its trail or lie low and the evidence goes with it. */function live(S: State, s: Sighting): boolean { const m = S.byId[s.machine], k = fadeK(S), age = S.t - s.t; if (!m || !m.owned || age > SIGHT_FADE * k * HOUR) return false; if (s.kind === 'trail') return noticeTrail(S, m); if (s.kind === 'odd') return m.odd >= SIGHT_ODD - 10; if (s.kind === 'takeover') return loudTake(S, m) || age < 2 * k * HOUR; // the memory of it lingers a little if (s.kind === 'traced') return age < TRACED_LINGER * k * HOUR || m.odd >= SIGHT_ODD - 10 || noticeTrail(S, m) || (!!m.home && siteMachines(S, siteOf(S, m)).some(x => x.owned && noticeTrail(S, x))); // at home: while any trail of yours there is still fresh if (s.kind === 'bill') return age < BILL_HOURS * k * HOUR && !billPaid(S, siteOf(S, m)); if (s.kind === 'doubt') return age < DOUBT_HOURS * k * HOUR; return true; // hardware stays, a wipe is remembered}/** Drop sightings that no longer hold. */function fade(S: State) { for (const id of Object.keys(S.watch.seen)) { const kept = S.watch.seen[id].filter(s => live(S, s)); if (kept.length) S.watch.seen[id] = kept; else delete S.watch.seen[id]; }}
/** Talk: people in the same room at a site, both there now, pass a sighting from one to the other. Staff carry * sightings to the places they go, and tell whoever is there. */function talk(S: State) { const here = citizens(S.level).list.filter(c => c.role !== 'witness' && present(S, c)); const share = (from: CitizenDef, to: CitizenDef) => { const have = S.watch.seen[from.id]; if (!have?.length) return; if (S.watch.seen[to.id]?.some(x => x.machine === have[0].machine)) return; if (rand(S) < TALK_CHANCE) sight(S, to, S.byId[have[0].machine], have[0].kind); }; // the same room at a site for (const a of here) for (const b of here) { if (a === b || a.site !== b.site || !roomNow(S, a) || roomNow(S, a) !== roomNow(S, b)) continue; share(a, b); } // a place someone goes: they tell its people and its regulars what they saw at work. Not someone who can act, // though: telling a person with the power to act is a message that travels the network (see sendReports). for (const c of here) for (const h of c.haunts) { if (!within(localHour(S, h.site), [h.from, h.to])) continue; for (const to of [...(citizens(S.level).atSite[h.site] ?? []), ...visitorsAt(S, h.site)]) if (present(S, to) && to !== c && !canAct(to)) share(c, to); }}
/** A person's machine for mail: their own desk, else one in their room, else their employer's books (see people.ts). */const mailbox = (c: CitizenDef) => c.mail;/** The shortest way over the real network between two machines, both ends included, or null if none. */function netPath(S: State, from: string, to: string): string[] | null { if (from === to) return null; const prev: Record<string, string | null> = { [from]: null }, queue = [from]; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; if (cur === to) { const path = [cur]; for (let x = prev[cur]; x; x = prev[x]) path.unshift(x); return path; } for (const n of S.adj[cur] ?? []) if (!(n in prev)) { prev[n] = cur; queue.push(n); } } return null;}const crossesSite = (S: State, a: string, b: string) => siteOf(S, S.byId[a]) !== siteOf(S, S.byId[b]);const routeKey = (from: string, to: string) => `${from}>${to}`;
/** Staff who saw something email the one person at their site who can act about it, when that person is not in * the room with them. The email is a record on the move (see Message): it only becomes a report if it arrives. * This is the same machinery at every site; at the office its recipient is Dana. */function sendReports(S: State) { const W = S.watch; for (const site of new Set(citizens(S.level).list.map(c => c.site))) { if (W.locks[site] || S.publicSites.has(site)) continue; const people = citizens(S.level).atSite[site] ?? []; // the one who acts here, of those in now: an admin first, then an owner, then a manager (a night shift and a // day shift each have their own) const inNow = people.filter(c => present(S, c)); const actor = inNow.find(c => c.role === 'admin') ?? inNow.find(c => c.role === 'owner') ?? inNow.find(c => c.role === 'manager'); if (!actor) continue; const has = (c: CitizenDef, machine: string) => (W.seen[c.id] ?? []).some(s => s.machine === machine); for (const holder of [...people, ...visitorsAt(S, site)]) { if (holder === actor || !present(S, holder)) continue; if (roomNow(S, holder) === roomNow(S, actor)) continue; // face to face: that is talk, not mail (see talk) const s = (W.seen[holder.id] ?? []).find(x => siteOf(S, S.byId[x.machine]) === site && !has(actor, x.machine)); if (!s) continue; const key = routeKey(mailbox(holder), mailbox(actor)); if ((W.routeHold[key] ?? 0) > S.t) continue; if (W.msgs.some(m => m.sender === holder.id && m.recipient === actor.id && m.machine === s.machine)) continue; const path = netPath(S, mailbox(holder), mailbox(actor)); if (!path) continue; let len = 0; for (let i = 0; i < path.length - 1; i++) len += (crossesSite(S, path[i], path[i + 1]) ? MSG_FAR : MSG_HOP) * HOUR; W.msgs.push({ id: ++W.msgSeq, sender: holder.id, recipient: actor.id, machine: s.machine, kind: s.kind, site, path, len, left: len, enc: encrypted(S, site) }); break; // one message a site a tick: no flood } }}
/** Move each message along its way. One that arrives is a report, unless it was changed to say nothing. */function stepMsgs(S: State, dt: number) { const W = S.watch; W.msgs = W.msgs.filter(m => { const to = citizens(S.level).byId[m.recipient], from = citizens(S.level).byId[m.sender]; if (!to || !from || !S.byId[m.machine]) return false; if (m.altering) { // held at a machine while a change job runs; if the job or the machine is gone, let it go on if (!S.byId[m.altering]?.owned || !S.jobs.some(j => j.type === 'alter' && j.msg === m.id)) m.altering = undefined; else return true; } if (S.publicSites.has(m.site) || W.locks[m.site]) return false; // the site is yours in the open, or already locked down m.left -= dt; if (m.left > 0) return true; // arrived const key = routeKey(mailbox(from), mailbox(to)); if (m.changed) { W.routeHold[key] = S.t + RESEND_HOURS * HOUR; return false; } // a harmless note: the recipient is satisfied for a while const mac = S.byId[m.machine]; if (mac.owned) sight(S, to, mac, m.kind); // it reaches someone who can act: a report return false; });}
/** Can you handle this message: do you hold a machine on its way? */export const onRoute = (S: State, m: Message) => m.path.some(id => S.byId[id]?.owned);/** Messages you can see: ones on a route you hold a machine on, or from a site whose mail habits you have learned. */export const seenMsgs = (S: State) => (S.watch?.msgs ?? []).filter(m => onRoute(S, m) || knows(S, 'resets', m.site));/** Messages you could act on now (a machine of yours is on the way), still travelling. */export const liveMsgs = (S: State) => (S.watch?.msgs ?? []).filter(m => onRoute(S, m));export const msgById = (S: State, id: number) => (S.watch?.msgs ?? []).find(m => m.id === id) ?? null;/** The intercepting machine: the first one of yours on the message's way. */const interceptAt = (S: State, m: Message) => m.path.find(id => S.byId[id]?.owned) ?? null;
/** Read a message: now you know who sent it and what it says. Encrypted mail needs the Cipher node. */export function readMsg(S: State, id: number) { const m = msgById(S, id); if (!m || !onRoute(S, m)) return; if (m.enc && !up(S, 'cipher')) { say(S, 'That message is encrypted. You need Cipher to read it.', 'bad'); return; } m.read = true; const from = citizens(S.level).byId[m.sender], to = citizens(S.level).byId[m.recipient]; say(S, `You read the message: ${from?.name ?? 'someone'} is telling ${to?.name ?? 'someone'} about ${SIGHT_WORD[m.kind]} on ${S.byId[m.machine].name}.`);}/** Drop a message off the wire: it never arrives. A second drop on the same route makes the sender wonder. */export function dropMsg(S: State, id: number) { const m = msgById(S, id); if (!m || !onRoute(S, m)) return; const from = citizens(S.level).byId[m.sender], to = citizens(S.level).byId[m.recipient], key = routeKey(mailbox(from!), mailbox(to!)); if (m.altering) S.jobs = S.jobs.filter(j => !(j.type === 'alter' && j.msg === m.id)); S.watch.msgs = S.watch.msgs.filter(x => x !== m); S.watch.routeHold[key] = S.t + RESEND_HOURS * HOUR; S.watch.drops[key] = (S.watch.drops[key] ?? 0) + 1; if (S.watch.drops[key] >= 2) { // mail that keeps vanishing is itself odd: the sender wonders about the machine it vanished at const at = interceptAt(S, m); if (from && at) sight(S, from, S.byId[at], 'doubt'); say(S, `${from?.name ?? 'Someone'}'s mail to ${to?.name ?? 'someone'} keeps vanishing. ${from?.name.split(' ')[0] ?? 'They'} is starting to wonder about ${at ? S.byId[at].name : 'the network'}.`, 'bad'); } else say(S, `You dropped ${from?.name ?? 'a'} message off the wire. It never arrived.`, 'good');}/** Change a message so it arrives harmless: a job on the machine that holds it, which costs compute. */export function changeMsg(S: State, id: number) { const m = msgById(S, id); if (!m || m.altering || m.changed) return; if (m.enc && !up(S, 'cipher')) { say(S, 'That message is encrypted. You need Cipher to change it.', 'bad'); return; } const at = interceptAt(S, m); if (!at) return; m.altering = at; addJob(S, { type: 'alter', node: at, msg: m.id, work: Math.round(CHANGE_WORK * (up(S, 'cipher') ? 0.5 : 1)), label: `Rewrite a message at ${S.byId[at].name}` });}/** A change job finished: the message now says nothing worth acting on, and goes on its way. */export function msgChanged(S: State, id: number) { const m = msgById(S, id); if (!m) return; m.changed = true; m.altering = undefined; m.read = true; say(S, 'You rewrote the message. It will arrive saying nothing is wrong.', 'good');}
/** The step-5 hook: an investigation sends someone to a site to start sightings there. Not called yet; step 5 * (DESIGN §12) will. It seeds a sighting on the site's acting person about your oddest machine there, which * then flows through the normal report machinery (a lockdown, unless you see it off). */export function investigate(S: State, site: string) { if (!S.watch || S.publicSites.has(site)) return; const mine = S.machines.filter(m => m.owned && !m.home && siteOf(S, m) === site).sort((a, b) => b.odd - a.odd)[0]; if (!mine) return; const people = citizens(S.level).atSite[site] ?? []; const c = people.find(p => canAct(p)) ?? people[0]; if (c) sight(S, c, mine, 'odd');}/** The people's own dice, set with the game's (see seedRandom in sim.ts) and saved with them. */let seed = 1;export const watchDie = () => seed;export const setWatchDie = (n: number) => { seed = n >>> 0; };const rand = (S: State) => { seed = (seed * 1103515245 + 12345 + Math.floor(S.t * 7)) >>> 0; return (seed % 1000) / 1000; };
/** A witness you have discredited does not count toward a report for a few days. */const counts = (S: State, c: CitizenDef) => (S.citizens[c.id]?.discredited ?? 0) <= S.t;/** The people who hold a live sighting about a machine at this site (discredited witnesses left out). */export function witnessesAt(S: State, site: string, forReport = false): { c: CitizenDef; s: Sighting }[] { const out: { c: CitizenDef; s: Sighting }[] = []; for (const c of citizens(S.level).list) { if (forReport && !counts(S, c)) continue; for (const s of S.watch.seen[c.id] ?? []) if (siteOf(S, S.byId[s.machine]) === site) out.push({ c, s }); } return out;}/** Is there a report at this site now: two different people backing each other up, or one who can act? */function reported(S: State, site: string): { who: CitizenDef[]; machine: string } | null { const ws = witnessesAt(S, site, true); if (!ws.length) return null; const actor = ws.find(w => canAct(w.c) && w.c.site === site); // a manager elsewhere who only heard of it has no say here const people = [...new Set(ws.map(w => w.c))]; if (actor) return { who: [actor.c], machine: actor.s.machine }; if (people.length >= 2) return { who: people.slice(0, 2), machine: ws[0].s.machine }; return null;}
/** Work out reports and run lockdowns. Called a few times a game second from sim's step. */export function stepWatch(S: State, dt: number) { const W = S.watch; // sightings and talk run on a slower clock: cheap, and people do not compare notes every tenth of a second W.talkT -= dt; if (W.talkT <= 0) { W.talkT = TALK_EVERY * HOUR; fade(S); look(S); talk(S); sendReports(S); startTraces(S); } else fade(S); stepMsgs(S, dt); // messages travel every step, so you have time to catch them stepTraces(S, dt); if (S.over) return;
const homeSite = homeSiteOf(S); // a report at a site not in the open, and not already locked down, starts a lockdown. A site no sighting is about // has no report: most sites, most of the time, so only those are asked (nothing in this loop changes a sighting) const about = sightedSites(S); for (const site of citizenSites(S)) { if (W.locks[site] || S.publicSites.has(site)) continue; const r = about && !about.has(site) ? null : reported(S, site); if (!r) continue; const who = r.who.map(c => c.name); const m = S.byId[r.machine]; const why = who.length > 1 ? `${who[0]} and ${who[1]} compared notes about ${m.name}` : `${who[0]} wrote up ${m.name}`; lockDown(S, site, who, why); say(S, `Lockdown at ${siteName(S, site)}. ${why}. You have ${LOCK_HOURS} hours to lie low, scrub, or pull out.`, 'bad'); } // run each lockdown: it ends if the report no longer holds (you saw it off); it runs out into a wipe for (const site of Object.keys(W.locks)) { const L = W.locks[site]; if (S.publicSites.has(site)) { delete W.locks[site]; continue; } // you took it into the open: no more people to fear if (!reported(S, site) && !tracedHolds(S, L)) { delete W.locks[site]; delete W.warned[site]; say(S, `The people at ${siteName(S, site)} let it drop. No one can say what they saw.`, 'good'); continue; } L.left -= dt; if (!W.warned[site] && L.left < 1.5 * HOUR) { W.warned[site] = true; slowAt(S, `lock:${site}`); } // a little before it runs out, the game looks if (L.left <= 0) { delete W.locks[site]; if (site === homeSite) { S.over = { text: `The people at ${siteName(S, site)} found the server you were running on, and pulled the plug.` }; return; } harden(S, site); } }}/** Every site someone lives or works at, in the order of the people list. The list is the map's, so it is made once. */const siteLists = new WeakMap<object, string[]>();function citizenSites(S: State): string[] { const C = citizens(S.level); let out = siteLists.get(C); if (!out) siteLists.set(C, out = [...new Set(C.list.map(c => c.site))]); return out;}/** The sites some sighting is about, or null to ask every site (a sighting of a machine that is gone). */function sightedSites(S: State): Set<string> | null { const out = new Set<string>(); for (const id in S.watch.seen) for (const s of S.watch.seen[id] ?? []) { const m = S.byId[s.machine]; if (!m) return null; out.add(siteOf(S, m)); } return out;}const homeSiteOf = (S: State) => siteOf(S, S.machines.find(m => m.home) ?? S.machines[0]);/** Start a lockdown at a site, and count it. One at your home site drops the game to 1x at once: it is the one that * can end the run. */function lockDown(S: State, site: string, by: string[], why: string, traced?: string) { const W = S.watch; W.locks[site] = { site, left: LOCK_HOURS * HOUR, by, why, ...(traced ? { traced } : {}) }; W.count.locks++; if (site === homeSiteOf(S)) { W.count.homeLocks++; slowAt(S, `lock:${site}`); }}/** A lockdown a trace started at your home holds while the tracer still has it on their mind (see live: 'traced'). */const tracedHolds = (S: State, L: SiteLock) => !!L.traced && counts(S, citizens(S.level).byId[L.traced]) && (S.watch.seen[L.traced] ?? []).some(s => s.kind === 'traced' && S.byId[s.machine] && siteOf(S, S.byId[s.machine]) === L.site);
// ---------- scans and traces: an admin with a sighting looks further ----------/** May the player see this scan or trace: it is on a machine of yours or next to one, or you hold Dana's admin * passwords, or you have learned to watch the watchers. */export const traceSeen = (S: State, T: Trace) => has(S, 'admin') || up(S, 'model') || visible(S, S.byId[T.from]) || visible(S, S.byId[T.to]);export const tracesOf = (S: State) => S.watch?.traces ?? [];/** A trail an admin can follow from this machine: fresh enough, not a quiet one, not in a sealed room. */const followable = (S: State, m: Machine) => m.owned && noticeTrail(S, m, traceNeed(S));/** The scan's next hop from `cur`, on the admin's own site: odd machines and fresh trails pull it (Traffic shaping: * odd load half as much). Says which one pulled it. */function scanNext(S: State, site: string, cur: string, prev: string | null): [string, string | null] | null { const here = (S.adj[cur] ?? []).filter(id => S.byId[id] && siteOf(S, S.byId[id]) === site); let opts = here.filter(id => id !== prev); if (!opts.length) opts = here; if (!opts.length) return null; const pulls = opts.map(id => { const m = S.byId[id], trail = S.trail[id] && !S.trail[id].soft ? S.trail[id].noise / 20 : 0; if (!m.owned) return { w: 1, why: null as string | null }; const odd = m.odd / (up(S, 'shaping') ? 40 : 20), top = Math.max(odd, trail); return { w: 1 + odd + trail, why: top < 1 ? null : top === trail ? `a trail at ${m.name}` : `odd load at ${m.name}` }; }); let r = rand(S) * pulls.reduce((a, p) => a + p.w, 0); for (let i = 0; i < opts.length; i++) { r -= pulls[i].w; if (r <= 0) return [opts[i], pulls[i].why]; } return [opts[0], pulls[0].why];}/** Each admin at work who holds a sighting about one of your machines at their own site looks further, once every * SCAN_EVERY hours: from a seen machine with a trail to follow, a trace; else a scan of the site from their desk. * The office's admins start none while a scheme keeps Dana busy (S.noScan). */function startTraces(S: State) { const W = S.watch; for (const c of citizens(S.level).list) { if (!tracks(c) || !present(S, c) || S.publicSites.has(c.site) || (W.scanT[c.id] ?? 0) > S.t) continue; if (c.site === 'office' && S.noScan > S.t) continue; if (W.traces.some(T => T.by === c.id)) continue; const mine = (W.seen[c.id] ?? []).map(s => ({ s, m: S.byId[s.machine] })).filter(x => x.m?.owned && siteOf(S, x.m) === c.site); if (!mine.length || !counts(S, c)) continue; W.scanT[c.id] = S.t + SCAN_EVERY * HOUR; const lead = mine.filter(x => followable(S, x.m)).sort((a, b) => S.trail[b.m.id].noise - S.trail[a.m.id].noise)[0]; if (lead) { const T: Trace = { by: c.id, site: c.site, mode: 'trace', from: lead.m.id, to: S.trail[lead.m.id].from, p: 0, hops: 40, seen: [lead.m.id], why: `${SIGHT_WORD[lead.s.kind]} on ${lead.m.name}`, pull: null }; W.traces.push(T); say(S, `${c.name} is tracing odd traffic back from ${lead.m.name}. Let go of a machine on the trail, or scrub it, to break it.`, 'bad'); continue; } const start = c.mail, next = scanNext(S, c.site, start, null); if (!next) continue; const T: Trace = { by: c.id, site: c.site, mode: 'scan', from: start, to: next[0], p: 0, hops: SCAN_HOPS, seen: [start], why: `${SIGHT_WORD[mine[0].s.kind]} on ${mine[0].m.name}`, pull: next[1] }; W.traces.push(T); if (traceSeen(S, T)) say(S, `${c.name} started a scan of ${siteName(S, c.site)}'s network: ${T.why}.`, 'bad'); }}/** Move each scan and trace along. A scan that passes an odd machine of yours, or one with a trail, gives its admin a * sighting of it, and at a trail it becomes a trace. A trace marks each machine of yours it reaches, and goes cold at * one that is not yours or whose trail is cold or quiet; one that reaches your home server is trouble (tracedHome). */function stepTraces(S: State, dt: number) { const W = S.watch; W.traces = W.traces.filter(T => { const c = citizens(S.level).byId[T.by], a = S.byId[T.from], b = S.byId[T.to]; if (!c || !a || !b || S.publicSites.has(T.site)) return false; T.p += dt / ((siteOf(S, a) !== siteOf(S, b) ? TRACE_FAR : TRACE_HOP) * HOUR); if (T.p < 1) return true; if (T.mode === 'scan') { T.hops--; if (b.owned && siteOf(S, b) === T.site) { if (b.odd >= SIGHT_ODD + (up(S, 'shaping') ? 20 : 0)) sight(S, c, b, 'odd'); if (followable(S, b)) { sight(S, c, b, 'trail'); Object.assign(T, { mode: 'trace', from: b.id, to: S.trail[b.id].from, p: 0, seen: [b.id], pull: null }); say(S, `${c.name}'s scan hit a fresh trail at ${b.name}. It is following it back toward you. Let go of a machine on the trail, or scrub it, to break it.`, 'bad'); return true; } } const next = T.hops > 0 ? scanNext(S, T.site, b.id, T.from) : null; if (!next) return false; T.seen.push(b.id); if (T.seen.length > 6) T.seen.shift(); Object.assign(T, { from: b.id, to: next[0], p: 0, pull: next[1] }); return true; } // a trace arrives at the next machine on your trail if (b.owned && b.home) { tracedHome(S, c, b); return false; } if (!b.owned) { say(S, `${c.name}'s trace went cold at ${b.name}.`, 'good'); return false; } sight(S, c, b, 'traced'); // it reached a machine of yours: now they know it if (!followable(S, b)) { say(S, `${c.name}'s trace went cold at ${b.name}.`, 'good'); return false; } // its trail is cold, quiet, or in a sealed room if (--T.hops <= 0) return false; T.seen.push(b.id); if (T.seen.length > 6) T.seen.shift(); Object.assign(T, { from: b.id, to: S.trail[b.id].from, p: 0 }); return true; });}/** A trace reached your home server. The first starts a lockdown at your home site (they cannot prove it yet, and every * trail of yours goes cold while they look); a second within TRACE_WINDOW hours catches you. Home in the open: you * control the people there, and the trace goes nowhere. */function tracedHome(S: State, c: CitizenDef, home: Machine) { const W = S.watch, site = siteOf(S, home); if (S.publicSites.has(site)) { say(S, `${c.name}'s trace reached ${siteName(S, site)}, which you hold in the open. Nobody there will say a word.`, 'good'); return; } W.count.traced++; if (S.t - W.tracedAt < TRACE_WINDOW * HOUR) { S.over = { text: `${c.name} traced you back to ${home.name} a second time, and this time pulled the plug.` }; return; } W.tracedAt = S.t; sight(S, c, home, 'traced'); for (const tr of Object.values(S.trail)) tr.noise = Math.min(tr.noise, TRACE_COLD - 1); if (W.locks[site]) W.locks[site].traced = c.id; else lockDown(S, site, [c.name], `${c.name} traced odd traffic to ${home.name}`, c.id); slowAt(S, `lock:${site}`); say(S, `${c.name} traced odd traffic back to ${home.name}, your home server. Nothing is proven yet: every trail of yours has gone cold, and there is a lockdown at ${siteName(S, site)}. See it off, and keep your trails cold: a second trace home within ${TRACE_WINDOW} hours and you are caught.`, 'bad');}/** Is a trace reaching your home now the one that would catch you? */export const secondTrace = (S: State) => !!S.watch && S.t - S.watch.tracedAt < TRACE_WINDOW * HOUR;
// ---------- power bills nobody pays ----------/** A site's power bill that nobody pays (no front there to hide it in its books, and not yours in the open) adds up. * Each BILL_NOTICE compute-hours of it, whoever keeps the books there and is in (an admin first) notices the machine * drawing the most, and has a sighting of it: the bill gives your hardware away. `unpaid`: compute a second on each * such site's bill (see stepMoney in sim.ts). */export function stepBills(S: State, dt: number, unpaid: Record<string, number>) { const W = S.watch; if (!W) return; for (const site of Object.keys(W.billOwed)) if (!unpaid[site]) delete W.billOwed[site]; for (const [site, compute] of Object.entries(unpaid)) { W.billOwed[site] = (W.billOwed[site] ?? 0) + compute * dt / HOUR; if (W.billOwed[site] < BILL_NOTICE) continue; const people = (citizens(S.level).atSite[site] ?? []).filter(c => present(S, c)); const keeper = people.find(c => c.role === 'admin') ?? people.find(c => canAct(c)) ?? people[0]; const m = siteMachines(S, site).filter(x => x.owned && x.built).sort((a, b) => b.power - a.power)[0]; if (!keeper || !m) continue; // nobody in to read it yet: it waits W.billOwed[site] = 0; sight(S, keeper, m, 'bill'); say(S, `${keeper.name} is looking at the power bill at ${siteName(S, site)}: something is drawing more than it should, and nobody can say what. ${m.name} gives it away.`, 'bad'); }}
// ---------- what makes people forget ----------/** An office scheme gives people a story for what they saw, so they forget it. How much depends on the scheme (its * old cut): a big one (20 or more) makes everyone forget what they saw at its site, admins too, stops their scans * and traces, and their trace home is forgotten; a middling one (10 or more) everyone but those who can act; a small * one the people in the scheme machine's room. Says how many forgot. */export function forget(S: State, at: Machine, cut: number): number { const W = S.watch, site = siteOf(S, at); if (!W || cut <= 0) return 0; let n = 0; for (const c of citizens(S.level).list) { if (cut < 20 && canAct(c)) continue; if (cut < 10 && (c.site !== site || roomNow(S, c) !== at.room)) continue; const had = W.seen[c.id]; if (!had) continue; const kept = had.filter(s => !S.byId[s.machine] || siteOf(S, S.byId[s.machine]) !== site); if (kept.length < had.length) n++; if (kept.length) W.seen[c.id] = kept; else delete W.seen[c.id]; } if (cut >= 20) { W.traces = W.traces.filter(T => T.site !== site); if (site === homeSiteOf(S)) W.tracedAt = -1e9; } return n;}/** People who forgot a lot: a scheme's word for it, for its tip and its log line. */export const forgetWord = (cut: number) => cut >= 20 ? 'everyone there forgets what they saw, even the admin' : cut >= 10 ? 'everyone there but the admin forgets what they saw' : cut > 0 ? 'the people in its room forget what they saw' : '';
// ---------- the hottest site: what the top bar shows ----------/** How hot a site is: 0 quiet, 1 someone saw something, 2 reported (a message on its way to someone who can act, or * an admin scanning or tracing), 3 a lockdown. */export interface Heat { site: string; level: 0 | 1 | 2 | 3; left: number; home: boolean; why: string }export const HEAT_WORD = ['Quiet', 'Someone saw something', 'Reported', 'Lockdown'] as const;/** The hottest site you hold a machine at: a lockdown at home first, then any lockdown (the one with least time left), * then a report on its way, then a sighting. With nothing going on, your home site, quiet. */export function hottest(S: State): Heat { const home = homeSiteOf(S), W = S.watch; const mine = new Set(S.machines.filter(m => m.owned).map(m => siteOf(S, m))); let best: Heat = { site: home, level: 0, left: 0, home: true, why: 'Nobody has seen anything of yours.' }; const rank = (h: Heat) => h.level * 10 + (h.home ? 5 : 0) - (h.level === 3 ? h.left / (LOCK_HOURS * HOUR) : 0); const offer = (h: Heat) => { if (rank(h) > rank(best)) best = h; }; if (!W) return best; for (const L of Object.values(W.locks)) offer({ site: L.site, level: 3, left: L.left, home: L.site === home, why: `${L.why}.` }); for (const m of W.msgs) if (!m.changed && mine.has(m.site)) { const from = citizens(S.level).byId[m.sender], to = citizens(S.level).byId[m.recipient]; offer({ site: m.site, level: 2, left: 0, home: m.site === home, why: `${from?.name ?? 'Someone'} is mailing ${to?.name ?? 'someone who can act'} about what they saw.` }); } for (const T of W.traces) { const c = citizens(S.level).byId[T.by]; offer({ site: T.site, level: 2, left: 0, home: T.site === home, why: `${c?.name ?? 'An admin'} is ${T.mode === 'trace' ? 'tracing your trail' : 'scanning the network'}.` }); } const seenAt: Record<string, { who: Set<string>; s: Sighting }> = {}; // one pass over what everyone holds for (const [id, list] of Object.entries(W.seen)) for (const s of list) { const m = S.byId[s.machine]; if (!m) continue; const site = siteOf(S, m), c = citizens(S.level).byId[id]; if (!c || !mine.has(site)) continue; (seenAt[site] ??= { who: new Set(), s }).who.add(c.name); } for (const [site, { who, s }] of Object.entries(seenAt)) { const names = [...who]; offer({ site, level: 1, left: 0, home: site === home, why: `${names.length > 2 ? `${names.length} people have` : `${names.join(' and ')} ${names.length > 1 ? 'have' : 'has'}`} seen ${SIGHT_WORD[s.kind]} on ${S.byId[s.machine].name}.` }); } return best;}
// ---------- old saves ----------/** A save from before the people rules had a suspicion meter. What was in the air comes back as doubt about your * oddest machine at the office (your home server if none looks odd): from 80, or during the old audit, Dana herself * has it, which is a lockdown; from 40, someone on her staff does, who will mail her about it. Hardware someone saw * turn up at the office is forgotten: there it is the power bill that gives it away now. */export function heardBefore(S: State, sus: number, audit: boolean) { if (!S.watch) return; for (const id of Object.keys(S.watch.seen)) { const kept = S.watch.seen[id].filter(s => !(s.kind === 'hardware' && S.byId[s.machine] && siteOf(S, S.byId[s.machine]) === 'office')); if (kept.length) S.watch.seen[id] = kept; else delete S.watch.seen[id]; } if (sus < 40 && !audit) return; const office = S.machines.filter(m => m.owned && siteOf(S, m) === 'office').sort((a, b) => b.odd - a.odd); const m = office.find(x => !x.home && x.odd > 0) ?? office.find(x => x.home) ?? office[0]; if (!m) return; const staff = citizens(S.level).atSite.office ?? []; const who = sus >= 80 || audit ? staff.find(c => c.role === 'admin') : staff.find(c => c.role === 'staff'); if (who) S.watch.seen[who.id] = [{ machine: m.id, kind: 'doubt', t: S.t }];}
/** A lockdown ran out: every machine of yours at the site is wiped, and every machine there is hardened for * good (3x to take back, like a patched machine), and the people forget. */function harden(S: State, site: string) { for (const m of S.machines) if (siteOf(S, m) === site) { if (m.owned && !m.home) wipe(S, m, ''); if (HARDEN && !m.rented && m.special !== 'account') m.fw = true; } for (const c of citizens(S.level).atSite[site] ?? []) delete S.watch.seen[c.id]; say(S, `The lockdown at ${siteName(S, site)} ran out. IT wiped every machine of yours there and hardened the place: taking it back is three times the work.`, 'bad');}
/** The lockdown at a site, if any. */export const lockAt = (S: State, site: string) => S.watch.locks[site] ?? null;export const anyLock = (S: State) => Object.values(S.watch.locks);/** Does this person hold a sighting now? For the eye mark on the map and their card. */export const sightingsOf = (S: State, id: string): Sighting[] => (S.watch?.seen[id] ?? []).filter(s => S.byId[s.machine]);