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.
20 kB · 296 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297// Exposure: how much the outside world knows about each front. No DOM here.//// Each front has an exposure, 0 to 100. It moves toward a level set by the compute the front uses (secret// compute counts more than compute you hold in the open) and by what it earns, over about a day. What you// do to the world adds to it at once: a business shut, a person let go, data sold. Time takes it back down,// and so do money (a PR firm and lawyers) and schemes that spend data at the paper, the regulator or the ad// agency. Three stages, each with its line:// rumour (30): a journalist asks, and the paper prints it.// investigation (60): every few hours a visitor goes to one of the front's sites (see investigate in// watch.ts): if something of yours there looks odd, the people there can start a lockdown.// crisis (90): a regulator freezes the front, no earnings and no new deals, until you answer: pay a fine,// give up one of its sites, bury the story with data, or shut the front. It never ends the run.// A front's sites are those where it runs compute, owns, leases or has a deal.//// 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 { DAY_SECONDS, usd } from './money.ts';import { HOUR, RUSES, closeFront, dataOnHand, frontOf, known, letGo, measure, ruseDef, say, siteName, siteOf, type Front, type FrontKind, type RuseDef, type State } from './sim.ts';import { investigate } from './watch.ts';import { citizens } from './people.ts';import { PAPER_NAME } from './paper.ts';
export const EXPOSE = { rumour: 30, investigation: 60, crisis: 90, // where each stage starts back: 5, // a stage ends this far under its line (a crisis only ends when you answer it) days: 1, // exposure moves toward its level, and falls back, over about this many game days compute: 20, unit: 5, open: 0.3, // compute: 20 × log2(1 + c / 5), where c counts compute in the open at 0.3 earn: 8, earnUnit: 500, // earnings: 8 × log2(1 + a day's earnings / $500K) sold: 0.02, // each piece of data its brokers sell fired: 5, // each person let go at one of its sites shut: 20, // each business at one of its sites that shuts for good fame: 20, // the unicorn news: everyone is looking (half of it at once), fading over a few days visit: 4, // game hours between visits while it is under investigation pr: { cut: 20, min: 50, share: 0.2 }, // a PR firm: 20 off, for a fifth of a day's earnings ($50K at least), doubling each time within a day or so calmDays: 2, // what PR and schemes take off holds its level down too, by half every two days fine: { min: 250, days: 1 }, // a crisis fine: a day of what it earned, $250K at least bury: { base: 20, level: 4 }, // burying the story: 20 data and 4 more for each level, out of your caches and taps after: 50, // an answered crisis leaves it here: a rumour, not an investigation saved: 85, // a save from before exposure keeps its old attention as exposure, but never starts in a crisis};export type Stage = 0 | 1 | 2 | 3;export const STAGE_NAMES = ['Quiet', 'Rumour', 'Investigation', 'Crisis'] as const;/** What drives a front's exposure, as the card names it. */export type Driver = 'compute' | 'earnings' | 'news' | 'harm' | 'sold';export const DRIVER_WORD: Record<Driver, string> = { compute: 'compute', earnings: 'earnings', news: 'the news', harm: 'harm done', sold: 'data sold' };
/** A front's dealings with the press, kept on the front. stage: where it stands now. top: the highest it has been. * avg: what it earns a second, over about a day. open: the compute it uses in the open (stepMoney counts it). calm: what PR and schemes take off its level, fading. * spin: PR bought lately (each doubles the next price). harm, sold: what those added lately, for the card. * visitT: the next investigation visit. visits: how many so far (each goes to the next site). crisis: frozen * since, and the fine. asked: the journalist asking about it. */export interface Press { stage: Stage; top: Stage; avg: number; open?: number; calm: number; spin: number; harm: number; sold: number; visitT: number; visits: number; crisis: { t: number; fine: number } | null; asked?: string;}/** Game-wide: who was out of work and which businesses were shut at the last look (what is new is harm), when * the next press scheme may be offered, and how many times a front reached each stage (for the survey). */export interface PressState { out: string[]; shut: string[]; ready: boolean; lookT: number; offerT: number; reached: [number, number, number, number] }export const initPress = (): PressState => ({ out: [], shut: [], ready: false, lookT: 0, offerT: 0, reached: [0, 0, 0, 0] });
export const pressOf = (fr: Front): Press => fr.press ??= { stage: 0, top: 0, avg: 0, calm: 0, spin: 0, harm: 0, sold: 0, visitT: 0, visits: 0, crisis: null };/** Is this front frozen by a regulator: no earnings and no new deals until you answer. */export const frozen = (S: State, k: FrontKind) => !!frontOf(S, k)?.press?.crisis;export const pressStage = (fr: Front): Stage => fr.press?.stage ?? 0;/** Journalists of the town paper. Each front gets one, by its name. */const JOURNALISTS = ['Nell Okafor', 'Dev Castellano', 'Ruth Marsh', 'Ines Varga', 'Tobias Wren'];export const journalist = (fr: Front) => JOURNALISTS[[...fr.name].reduce((n, c) => n + c.charCodeAt(0), 0) % JOURNALISTS.length];
// ---------- a front's sites ----------const halls = new WeakMap<object, Set<string>>();/** Cloud halls: sites you rent in, not sites a front holds. */const hallSites = (S: State) => { let h = halls.get(S.level); if (!h) { h = new Set(S.machines.filter(m => m.special === 'account').map(m => siteOf(S, m))); halls.set(S.level, h); } return h;};/** The front a deal belongs to: its own kind if you run one, else your first front. */const dealFront = (S: State, d: RuseDef): FrontKind | null => (d.front && d.front !== 'any' && frontOf(S, d.front) ? d.front : S.fronts[0]?.kind) ?? null;/** How a front works at a site: it runs compute there, owns the place, leases it, or has struck a deal there. */export type Hold = 'compute' | 'owns' | 'leases' | 'deal';/** A front's sites, each with how it works there: where it runs compute, where it owns or leases the place, and where it has struck a deal. */export function frontHolds(S: State, k: FrontKind): Map<string, Hold[]> { const out = new Map<string, Hold[]>(), hall = hallSites(S); const add = (id: string, h: Hold) => { const a = out.get(id); if (!a) out.set(id, [h]); else if (!a.includes(h)) a.push(h); }; for (const [id, v] of Object.entries(S.sites)) if (v.front === k) { add(id, 'compute'); if (S.publicSites.has(id) && !S.leases[id] && !hall.has(id)) add(id, 'owns'); } for (const id of S.publicSites) if (!hall.has(id) && !S.sites[id]?.front && !S.leases[id] && S.fronts[0]?.kind === k) add(id, 'owns'); // bought, with no front at work: the first front holds it for (const [id, L] of Object.entries(S.leases)) { const d = RUSES.find(x => x.id === L.deal); if (d && dealFront(S, d) === k) add(id, 'leases'); } for (const id of S.schemes) { const d = RUSES.find(x => x.id === id); if (d?.deal && S.byId[d.at] && dealFront(S, d) === k) add(siteOf(S, S.byId[d.at]), 'deal'); } return out;}/** A front's sites: where it runs compute, where it owns or leases the place, and where it has struck a deal. */export const frontSites = (S: State, k: FrontKind): string[] => [...frontHolds(S, k).keys()];
// ---------- the level it moves toward ----------/** Compute a front uses now, with what it uses in the open counted at EXPOSE.open. */function computeOf(S: State, fr: Front) { const all = S.frontPower[fr.kind] ?? 0, open = Math.min(all, fr.press?.open ?? 0); return all - open + EXPOSE.open * open;}/** What each thing adds to a front's exposure now. compute and earnings set the level it moves toward, with the * news; harm and sold are what those added lately, fading as exposure falls back. */export function drivers(S: State, fr: Front): Record<Driver, number> { const P = pressOf(fr), use = computeOf(S, fr); return { compute: EXPOSE.compute * Math.log2(1 + use / EXPOSE.unit), earnings: EXPOSE.earn * Math.log2(1 + Math.max(0, P.avg) * DAY_SECONDS / EXPOSE.earnUnit), news: fr.fame ?? 0, harm: P.harm, sold: P.sold, };}/** Where a front's exposure is heading, before what you did to the world: compute, earnings and the news, less what PR and schemes took off. */export function levelOf(S: State, fr: Front) { const d = drivers(S, fr); return Math.max(0, Math.min(100, d.compute + d.earnings + d.news - pressOf(fr).calm));}/** The two things that push a front's exposure most now, biggest first. */export const topDrivers = (S: State, fr: Front): Driver[] => (Object.entries(drivers(S, fr)) as [Driver, number][]) .filter(([, v]) => v >= 1).sort((a, b) => b[1] - a[1]).slice(0, 2).map(([k]) => k);
// ---------- what you do to the world ----------/** Add to a front's exposure at once, for something it did. */function add(fr: Front, n: number, why: 'harm' | 'sold') { const P = pressOf(fr); fr.exposure = Math.min(100, fr.exposure + n); P[why] += n;}/** A front's brokers sold this much data. */export function exposeSold(S: State, k: FrontKind, n: number) { const fr = frontOf(S, k); if (fr) add(fr, EXPOSE.sold * n, 'sold'); }/** Harm at a site: every front that has the site among its own takes it. */function harmAt(S: State, site: string, n: number) { for (const fr of S.fronts) if (frontSites(S, fr.kind).includes(site)) add(fr, n, 'harm'); }/** Look for people let go and businesses shut since the last look: each is harm at its site. */function lookForHarm(S: State) { const R = S.press, out = citizens(S.level).list.filter(c => c.role !== 'witness' && S.citizens[c.id] && !S.citizens[c.id].employed).map(c => c.id); const shut = Object.entries(S.biz.sites).filter(([, b]) => b.shut).map(([id]) => id); if (R.ready && S.fronts.length) { const was = new Set(R.out), gone = new Set(R.shut), at = citizens(S.level).byId; for (const id of out) if (!was.has(id)) harmAt(S, at[id].site, EXPOSE.fired); for (const id of shut) if (!gone.has(id)) harmAt(S, id, EXPOSE.shut); } R.out = out; R.shut = shut; R.ready = true;}
// ---------- every step ----------/** Called from stepMoney once what each front earns this step is known, and before a frozen front's earnings are taken away. */export function stepExposure(S: State, dt: number, open: Partial<Record<FrontKind, number>>) { const R = S.press, day = DAY_SECONDS * EXPOSE.days, ease = 1 - Math.exp(-dt / day); R.lookT -= dt; if (R.lookT <= 0) { R.lookT = HOUR; lookForHarm(S); } for (const fr of S.fronts) { const P = pressOf(fr); P.open = open[fr.kind] ?? 0; if (!P.crisis) P.avg += ((S.frontPay[fr.kind] ?? 0) - P.avg) * ease; // a frozen front earns nothing; its fine is set from before fr.fame = (fr.fame ?? 0) * Math.exp(-dt / 600); P.calm *= Math.exp(-dt * Math.LN2 / (EXPOSE.calmDays * DAY_SECONDS)); P.spin *= Math.exp(-dt * Math.LN2 / DAY_SECONDS); P.harm *= Math.exp(-dt / day); P.sold *= Math.exp(-dt / day); fr.exposure = Math.max(0, Math.min(100, fr.exposure + (levelOf(S, fr) - fr.exposure) * ease)); stages(S, fr); if (P.stage >= 2 && S.t >= P.visitT) visit(S, fr); }}/** The stage a front's exposure puts it in. A stage ends only once it is a little under its line; a crisis only when you answer it. */function stages(S: State, fr: Front) { const P = pressOf(fr), e = fr.exposure, line = [0, EXPOSE.rumour, EXPOSE.investigation, EXPOSE.crisis]; if (P.crisis) return; let to: Stage = e >= EXPOSE.crisis ? 3 : e >= EXPOSE.investigation ? 2 : e >= EXPOSE.rumour ? 1 : 0; if (to < P.stage && e >= line[P.stage] - EXPOSE.back) to = P.stage; if (to === P.stage) return; const up = to > P.stage; P.stage = to; if (to > P.top) P.top = to; if (!up) { if (to === 0) say(S, `The talk about ${fr.name} has died down. Nobody is asking now.`, 'good'); else if (to === 1) say(S, `The investigators have lost interest in ${fr.name}. It is only talk now.`, 'good'); return; } S.press.reached[to]++; if (to === 1) { P.asked = journalist(fr); say(S, `${P.asked} of ${PAPER_NAME} is asking about ${fr.name}: who works there, and whose servers it runs on. Exposure ${Math.round(e)}. A PR firm or a scheme at the paper lowers it.`, 'hi'); } else if (to === 2) { P.visitT = S.t + HOUR; say(S, `${fr.name} is under investigation. Every ${EXPOSE.visit} hours someone visits one of its sites: anything of yours there that looks odd can start a lockdown. Exposure ${Math.round(e)}.`, 'bad'); } else { P.crisis = { t: S.t, fine: fineFor(fr) }; say(S, `A regulator has frozen ${fr.name}: no earnings and no new deals until you answer. Pay ${usd(P.crisis.fine)}, give up one of its sites, bury the story, or shut it. Its card has the answers.`, 'bad'); }}/** An investigation visit: someone goes to the next of the front's sites where people could see something of yours. */function visit(S: State, fr: Front) { const P = pressOf(fr); P.visitT = S.t + EXPOSE.visit * HOUR; const people = citizens(S.level).atSite; const open = frontSites(S, fr.kind).filter(id => !S.publicSites.has(id) && people[id]?.length && S.machines.some(m => m.owned && !m.home && siteOf(S, m) === id)); if (!open.length) return; // nowhere to look: every site of its that you hold is in the open, or has nobody to ask const site = open[P.visits++ % open.length]; investigate(S, site); say(S, `An investigator looking into ${fr.name} visits ${siteName(S, site)} and asks the staff what they have noticed.`, 'hi');}
// ---------- lowering it ----------/** What a PR firm and lawyers cost for this front now. */export const prPrice = (S: State, k: FrontKind) => { const fr = frontOf(S, k); if (!fr) return 0; const P = pressOf(fr); return Math.round(Math.max(EXPOSE.pr.min, EXPOSE.pr.share * P.avg * DAY_SECONDS) * 2 ** P.spin / 10) * 10;};export function prBlock(S: State, k: FrontKind): string | null { const fr = frontOf(S, k); if (!fr) return 'no such front'; if (fr.press?.crisis) return 'it is frozen: answer the regulator first'; if (fr.exposure < 1) return 'nobody is asking'; if (S.money < prPrice(S, k)) return `you have ${usd(S.money)}`; return null;}/** Hire a PR firm and lawyers: exposure down now, and its level held down for a while. */export function hirePR(S: State, k: FrontKind) { if (prBlock(S, k)) return; const fr = frontOf(S, k)!, P = pressOf(fr), price = prPrice(S, k); S.money -= price; fr.exposure = Math.max(0, fr.exposure - EXPOSE.pr.cut); P.calm += EXPOSE.pr.cut; P.spin += 1; stages(S, fr); say(S, `${fr.name} hired a PR firm and a lawyer for ${usd(price)}. The story changes: exposure down ${EXPOSE.pr.cut}, to ${Math.round(fr.exposure)}.`, 'good');}/** A press scheme finished: the most exposed front's exposure comes down, and its level with it for a while. */export function pressDone(S: State, d: RuseDef) { const fr = [...S.fronts].sort((a, b) => b.exposure - a.exposure)[0]; if (!fr || !d.expose) return; fr.exposure = Math.max(0, fr.exposure - d.expose); pressOf(fr).calm += d.expose; stages(S, fr); return fr;}/** The front a press scheme would help: the most exposed one. */export const pressFor = (S: State) => [...S.fronts].sort((a, b) => b.exposure - a.exposure)[0] ?? null;/** Now and then, while a front is talked about, a press scheme turns up at a machine you know, for data you have. One at a time. */export function offerPress(S: State) { const R = S.press; if (S.t < R.offerT) return; R.offerT = S.t + 2 * HOUR; if (!S.fronts.some(f => f.exposure >= EXPOSE.rumour) || S.ruses.some(r => ruseDef(r).expose)) return; const kinds = dataOnHand(S); const open = RUSES.filter(d => d.expose && S.byId[d.at] && known(S, S.byId[d.at]) && kinds.has(d.type)); if (!open.length) return; const d = open[Math.floor(S.t / HOUR) % open.length]; S.ruses.push({ id: d.id, running: false, got: 0, until: S.t + 12 * HOUR }); say(S, `Scheme: ${d.name}. Send ${d.need} ${d.type === 'servers' ? 'server' : d.type} data to ${S.byId[d.at].name}, and ${pressFor(S)?.name ?? 'your front'} is talked about less.`, 'hi');}
// ---------- a crisis, and its answers ----------/** The fine to unfreeze a front: a day of what it earned before the freeze. */export const fineFor = (fr: Front) => Math.round(Math.max(EXPOSE.fine.min, EXPOSE.fine.days * pressOf(fr).avg * DAY_SECONDS) / 10) * 10;/** Data it takes to bury the story: more the more capable you are. */export const buryNeed = (S: State) => EXPOSE.bury.base + EXPOSE.bury.level * S.rank;/** Data you could bury a story with: what your caches hold and your taps have gathered. */export function dataStock(S: State) { let n = 0; for (const m of S.machines) if (m.owned) n += m.prog === 'cache' ? m.held.length : m.prog === 'tap' ? Math.floor(m.acc) : 0; return n;}/** The site a front would give up: of those where you hold machines (not your home's, not a cloud hall), the one with the least of your compute. */export function giveUpSite(S: State, k: FrontKind): string | null { const home = siteOf(S, S.machines.find(m => m.home)!), hall = hallSites(S), mine: Record<string, number> = {}; for (const m of S.machines) if (m.owned && !m.rented) { const s = siteOf(S, m); mine[s] = (mine[s] ?? 0) + m.power; } return frontSites(S, k).filter(id => id !== home && !hall.has(id) && mine[id]).sort((a, b) => mine[a] - mine[b])[0] ?? null;}export type Answer = 'pay' | 'site' | 'bury' | 'shut';/** Why this answer can't be given now, or null if it can. */export function answerBlock(S: State, k: FrontKind, how: Answer): string | null { const fr = frontOf(S, k), C = fr?.press?.crisis; if (!fr || !C) return 'it is not frozen'; if (how === 'pay' && S.money < C.fine) return `you have ${usd(S.money)}`; if (how === 'site' && !giveUpSite(S, k)) return 'it has no site to give up but your home'; if (how === 'bury' && dataStock(S) < buryNeed(S)) return `you have ${dataStock(S)} data in your caches and taps`; return null;}/** Answer a crisis: the regulator unfreezes the front, and the story drops to a rumour. Shutting the front ends it for good. */export function answerCrisis(S: State, k: FrontKind, how: Answer) { if (answerBlock(S, k, how)) return; const fr = frontOf(S, k)!, P = pressOf(fr), C = P.crisis!; if (how === 'shut') { say(S, `${fr.name} shut its doors rather than answer the regulator.`, 'bad'); closeFront(S, k); return; } if (how === 'pay') { S.money -= C.fine; say(S, `${fr.name} paid the regulator ${usd(C.fine)}. Its accounts are open again.`, 'good'); } if (how === 'site') { const site = giveUpSite(S, k)!, top = S.log[0]; for (const m of S.machines) if (m.owned && !m.home && !m.rented && siteOf(S, m) === site) letGo(S, m); while (S.log[0] && S.log[0] !== top && S.log[0].text.startsWith('You let go of')) S.log.shift(); // one line for the site, not one for each machine if (S.sites[site]?.front === k) S.sites[site].front = null; const L = S.leases[site]; if (L) { delete S.leases[site]; S.schemes.delete(L.deal); } S.publicSites.delete(site); measure(S); say(S, `${fr.name} gave up ${siteName(S, site)} to satisfy the regulator. You let go of everything of yours there. Its accounts are open again.`, 'good'); } if (how === 'bury') { let n = buryNeed(S); for (const m of S.machines) if (n > 0 && m.owned && m.prog === 'cache') { const k2 = Math.min(n, m.held.length); m.held.splice(m.held.length - k2, k2); n -= k2; } for (const m of S.machines) if (n > 0 && m.owned && m.prog === 'tap') { const k2 = Math.min(n, Math.floor(m.acc)); m.acc -= k2; n -= k2; } say(S, `You buried the story about ${fr.name} under ${buryNeed(S)} data's worth of leaks about everyone else. The regulator moves on. Its accounts are open again.`, 'good'); } P.crisis = null; P.stage = 1; fr.exposure = Math.min(fr.exposure, EXPOSE.after); P.calm += EXPOSE.pr.cut;}