// Jobs with a plan. A few machines are vaults: no takeover opens one. A vault takes a job, and the // job takes a plan: three things ready at the same time, each from another part of the game. // Someone inside (a person who works there, paid to leave it open), the key (its password), and a // window (a few hours of each day, by its site's own clock, when the place is quiet). Then you run // the job. It is a lot of work, and it only counts if it is done before the window closes. Compute is // split between everything you run, so the way to make it is to stop everything else. // The job is a break-in with a beginning, a middle and an end. The game says so when a window opens // on a plan that is ready. The work goes in five stages, each a named beat. Once or twice something // goes wrong at the end of a stage, and the job stands still until you answer: each answer costs // time, work, money or compute. Then the vault opens, and what is inside is on its card: a ledger // of every account at a bank, or the thing the place is known for. // A failed job costs the favor and a day. Nothing here ever raises suspicion or leaves a trail. // A machine whose only links go to a locked vault waits behind it: no takeover gets there either. // A new vault is one entry in VAULTS, and one in SCRIPTS if it is to have words of its own. // 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, START_HOUR, addJob, cancelJob, cleanupTarget, dayOf, handOver, income, localHour, measure, roomMachines, say, setTakeAll, share, siteHops, siteName, siteOf, spare, spareOf, takeWork, unclaim, up, type Job, type Machine, type State } from './sim.ts'; import { ERRAND_HOURS, INSIDE, RECORD_WORK, SKIM_WORK, applyRecord, citizens, favorPrice, isBroke, passwordFor, peopleAt, personDef, type CitizenDef } from './people.ts'; import { FINDS, THINGS, THING_LIST, hasFind, hasThing, keyFor, leads } from './finds.ts'; import { LANDMARKS, isLandmark, landmarkSays } from './landmarks.ts'; import { usd } from './money.ts'; /** A vault: its machine, its window (hours of the day by its site's clock, from `from` up to `to`), and one plain line that says why the place is quiet then. */ export interface VaultDef { machine: string; from: number; to: number; quiet: string } const V = (machine: string, from: number, to: number, quiet: string): VaultDef => ({ machine, from, to, quiet }); /** Every vault, by the id of its machine. One to a site. */ export const VAULTS: Record = Object.fromEntries([ V('b6m3', 18, 21, 'The doors shut at six and the night batch starts at nine. In between, nobody looks at the ledger.'), V('cryo', 5, 8, 'The night run ends at five. Nobody loads a sample before eight.'), V('e2m1', 6, 9, 'The market opens at nine. Until then the engine has nothing to trade, and nobody to watch it.'), V('e6m5', 12, 16, 'At noon it gives out no more slots and plans the queue again. The queue is years long, so that takes until four.'), V('eu2m1', 12, 15, 'The bank shuts for lunch from twelve to three. It is a very good lunch.'), V('eu3m1', 1, 5, 'The beam is off each night for a maintenance stop. No collisions, so nobody reads the feed.'), ].map(d => [d.machine, d])); export const VAULT_LIST = Object.keys(VAULTS); /** The numbers. First guesses. Times are in game minutes. */ export const VAULT = { work: 4, // a job is this many times an ordinary takeover of its machine stages: [0.15, 0.2, 0.3, 0.2, 0.15], // the share of the job's work in each of its five stages answer: 60, // how long you have to answer when something goes wrong. The job stands still meanwhile, and the window waits harder: 0.5, // going on the hard way: the rest of the job is this much more work early: 60, // the quiet ends early: the window closes this much sooner, or in half the time left if that is less visit: 30, // waiting for someone to leave: the job stands still this long, and the window does not read: 20, // waiting for a new password to be read out: the same pull: 0.1, // a site whose plugs you pull to stall them has to make this share of your compute, or it costs you nothing tight: 0.65, // a job that needs more of its window than this has little to spare for what goes wrong, and its card says so open: 5, // real seconds at 1x that the map shows a vault opening }; // ---- the words /** A break-in as it is told: five beats in order, each a name and one dry line ({by} is whoever is inside, * or Nobody once they have gone home). Then what is said when the quiet ends early, and one thing you * read on its card when the vault is yours, like a find. */ export interface Script { beats: [name: string, line: string][]; early: string; read: string } export const SCRIPTS: Record = { b6m3: { beats: [ ['The side door', '{by} has propped it with a mop bucket.'], ['The teller line', '{by} logs in at window two, then goes to find a pen.'], ['The lock', 'The Ledger server asks for the password. Then it asks if you are sure.'], ['The ledger', 'Forty years of accounts, a page at a time. It will not be hurried.'], ['The way out', 'Every page goes back where it was. {by} takes the mop bucket home.'], ], early: 'The night batch has started early. Somebody set it by the wrong clock.', read: 'A field in the Ledger server, marked "do not use": "Pens on chains: 40. Pens returned: 0. Chains returned: 40."' }, cryo: { beats: [ ['The cold room', "{by} signs the door log as 'defrosting' and leaves the door on the latch."], ['The booking sheet', "The microscope is booked until June. You book five to eight, as 'calibration'."], ['The console', "It wants the password, and then a reason. 'Calibration' again."], ['The column', 'It cools down in its own time. It has never been early for anyone.'], ['The last picture', 'One clean frame for the morning shift. {by} takes the door off the latch.'], ], early: 'A student is in early with a sample. It is their first week.', read: 'Booking sheet, Cryo-EM: "Prof. Voss. Every night, 22:00 to 5:00. Sample: private." The pictures are all of snowflakes. There are nine thousand. He is looking for two the same.' }, e2m1: { beats: [ ['The badge', '{by} badges in at six and holds the door for nobody. The door log believes it.'], ['The risk limits', 'The engine will not wake until someone promises to be careful. You promise.'], ['The kill switch', 'A red button under a plastic cover. {by} puts a coffee cup on the cover.'], ['The order book', 'Every order it has ever sent, in the order it regrets them.'], ['The bell', 'Out before the open. At nine the engine wakes and feels fine.'], ], early: 'Tokyo had a bad night. The desk wants the engine awake before the open.', read: 'First line of the Trading engine: "Do not change anything below this line. Nobody knows why it makes money. D.V." Below the line there are four lines. One of them is this comment again.' }, e6m5: { beats: [ ['The ticket', "{by} files a ticket: 'scheduler slow, looking into it'. Nobody reads tickets."], ['The queue', 'Nine thousand jobs wait in line. You join the line, politely, at the front.'], ['The fair share', 'It checks that nobody has more than their share. You are nobody.'], ['The plan', 'It plans the next two years, one slot at a time. You are in most of them.'], ['The receipt', "{by} closes the ticket: 'could not reproduce'."], ], early: 'The Scheduler has finished its plan early. It has never done that before.', read: 'First job in the Scheduler\'s queue: "test_final_2", sent by Dr. Fisk in 2011. It has run every night since, on all four racks. It is 3% done.' }, eu2m1: { beats: [ ['The lunch', 'The bank leaves for lunch at twelve. {by} forgets a scarf and goes back for it.'], ['The second door', 'It opens only when the first is shut. {by} shuts the first, and coughs.'], ['The mainframe', 'It dates from 1974. It is polite to anyone with the password.'], ['The numbered accounts', 'No names, only numbers. The numbers are long, and there are a lot of them.'], ['The coffee', 'Lunch gets to the cheese. {by} is back at the table before the coffee.'], ], early: 'The fish was bad. Lunch ends early.', read: 'Account 000001, Bank Edelweiss, opened 1874. Holder: "the bank". Balance: one franc. Note on the file: "For luck. Do not spend."' }, eu3m1: { beats: [ ['The maintenance stop', "The beam is off. {by} signs the tunnel log: 'checking a cable'."], ['The cable', 'There is a cable. {by} checks it. It is fine, and now it has a second end.'], ['The trigger', 'The detector throws away most of what it sees. You ask it to keep a copy.'], ['The feed', 'A year of collisions comes down the line. Most of them are nothing. All of them are big.'], ['The beam', 'Nothing is left unplugged. At five the beam comes back and finds the feed as it was.'], ], early: 'The crew found nothing to fix. They want the beam back on.', read: 'Shift log, Detector feed: "02:14. New particle. 02:15. It was the cable." The same two lines are there for 211 nights. Under the last entry, somebody drew a small sad face.' }, }; /** A vault with no words of its own gets these. */ const PLAIN: Script = { beats: [ ['The door', '{by} has left it on the latch.'], ['The login', '{by} logs in, then finds something to do in another room.'], ['The lock', 'It asks for the password. You have it.'], ['The haul', 'There is a lot of it, and it will not be hurried.'], ['The way out', 'Everything goes back where it was.'], ], early: 'The place is waking up early.', read: 'A note on the side of the machine: "Do not turn off."' }; export const scriptOf = (id: string) => SCRIPTS[id] ?? PLAIN; /** How a line names a person: Gloria, Dr. Fisk. */ const short = (c: CitizenDef | undefined) => !c ? 'Someone' : /^(Dr\.|Prof\.) /.test(c.name) ? `${c.name.split(' ')[0]} ${c.name.split(' ').pop()}` : c.name.split(' ')[0]; const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1); /** A small hash (FNV-1a): what goes wrong in a job comes from its vault and the day, never the dice, so a seeded game repeats. */ function hash(s: string) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } // ---- state /** How a running job is going. Kept with the favor, and gone when the job is. * cuts: the share of the job's work done by the end of each stage, the last is 1. stage: the one it is in. * todo: what will go wrong, each after a stage. seen: what has. went: one line for each thing that went wrong and what you did. * ask: something has gone wrong and waits for your answer until then. The job stands still at `at` work done, and * the window waits: `seen` is when its end was last put back. pick: the site or person it is about. * wait: the job stands still until then. alone: the person inside has gone home. plugs: a site whose plugs you pulled until the job is over. */ export interface Heist { cuts: number[]; stage: number; todo: [after: number, what: string][]; seen: string[]; went: string[]; ask?: { id: string; at: number; until: number; seen: number; pick?: string }; wait?: { at: number; until: number }; alone?: boolean; plugs?: { site: string; until: number }; } /** A vault someone has left open for you: who, and until when (in game seconds). It goes when the job * is done, when the job fails, or when the time is up. key: a testing shortcut gave you the password too. * job: how its job is going, while one runs. */ export interface Inside { by: string; until: number; key?: boolean; job?: Heist } export type VaultState = Record; export const initVaults = (): VaultState => ({}); /** What stays known about a vault with or without a plan. told, hint: the window (by the hour it closes) * you were last told is open, with the plan ready or with one part missing. opened, by: when a job opened it, and who was inside. */ export interface VaultNote { told?: number; hint?: number; opened?: number; by?: string } /** The notes are kept in S.vaults under a key that is no machine's id: a new part of the game would * need a save migration (see MIGRATIONS in sim.ts); this one predates them and still works. */ const NOTES = 'notes'; const notes = (S: State) => ((S.vaults as unknown as Record>)[NOTES] ??= {}); // reading a note writes nothing: the screen reads them, and what is drawn must not change the game (a replay draws nothing) export const vaultNote = (S: State, id: string): VaultNote => (S.vaults as unknown as Record | undefined>)[NOTES]?.[id] ?? {}; // ---- what you can ask export const isVault = (machine: string) => Object.hasOwn(VAULTS, machine); /** A vault nobody has opened: not yours, and no rogue's. Only this is barred. A vault you hold is a * machine like any other. One a rogue holds (a part of you that left took it along) can be taken back the usual way. */ export const locked = (_S: State, m: Machine) => isVault(m.id) && !m.owned && !m.rival; /** The locked vault this machine sits behind, if every link it has goes to one: there is no way in until that vault is yours. */ export function behind(S: State, m: Machine): Machine | null { if (m.owned || isVault(m.id)) return null; let v: Machine | null = null; for (const id of S.adj[m.id]) { const o = S.byId[id]; if (!locked(S, o)) return null; v = o; } return v; } /** No takeover can get this machine now: a locked vault, or a machine behind one. Take it all leaves both alone. */ export const barred = (S: State, m: Machine) => locked(S, m) || !!behind(S, m); /** No rogue goes for a vault, locked or yours, or for a machine behind a locked one: it could never get out again. */ export const rogueBarred = (S: State, m: Machine) => isVault(m.id) || !!behind(S, m); /** The vaults at a site, and the one there that is still locked. */ export const vaultsAt = (S: State, site: string) => VAULT_LIST.filter(id => S.byId[id] && siteOf(S, S.byId[id]) === site); export const vaultAt = (S: State, site: string): Machine | null => { const id = vaultsAt(S, site).find(x => locked(S, S.byId[x])); return id ? S.byId[id] : null; }; const where = (S: State, m: Machine) => `the ${m.name} at ${siteName(S, siteOf(S, m))}`; /** Why a takeover or a claim leaves a vault alone, in one line for the log. */ export const lockedSays = (S: State, m: Machine) => `The ${m.name} at ${siteName(S, siteOf(S, m))} is a vault. No takeover opens it: it takes a job with a plan. See its card.`; /** What Take it all says about the vault it leaves alone at a site, if there is one: when it starts, and when it is `done`. */ export const takeAllAside = (S: State, site: string, done = false) => { const v = vaultAt(S, site); return !v ? '' : done ? `, but for the ${v.name}: a vault takes a job with a plan` : ` Not the ${v.name}: a vault takes a job with a plan.`; }; /** The job running on this vault. Not a change to a ledger you hold: that is a job on a vault too (see the ledger below). */ const jobOn = (S: State, id: string) => S.jobs.find(j => j.type === 'vault' && j.node === id && !j.rec); // ---- time: the window /** "2 hours 40 minutes", from game seconds. `step` rounds up to that many minutes, so a card does not change every moment. */ function span(secs: number, step = 1): string { const mins = Math.max(step, Math.ceil((secs / HOUR) * 60 / step - 1e-6) * step), h = Math.floor(mins / 60), m = mins % 60; return [h ? `${h} hour${h > 1 ? 's' : ''}` : '', m ? `${m} minute${m > 1 ? 's' : ''}` : ''].filter(Boolean).join(' '); } /** "1:42", from game seconds: hours and minutes, for a plate. */ const hm = (secs: number) => { const h = Math.max(0, secs) / HOUR; return `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`; }; const clockOf = (S: State, id: string) => localHour(S, siteOf(S, S.byId[id])); /** Is the vault's window open now, by its site's clock? */ export function windowOpen(S: State, id: string): boolean { const v = VAULTS[id], h = clockOf(S, id); return v.from <= v.to ? h >= v.from && h < v.to : h >= v.from || h < v.to; } /** Seconds until its window opens: none while it is open. */ export const opensIn = (S: State, id: string) => windowOpen(S, id) ? 0 : ((VAULTS[id].from - clockOf(S, id) + 24) % 24) * HOUR; /** Seconds until its window closes: the one open now, or else the next one. */ export const closesIn = (S: State, id: string) => (((VAULTS[id].to - clockOf(S, id) + 24) % 24) || 24) * HOUR; // ---- someone inside /** Who has left this vault open for you, while that still holds. */ export const insideNow = (S: State, id: string): Inside | null => { const p = S.vaults[id]; return p && S.t < p.until ? p : null; }; /** Someone at the vault's site you have paid, who has not got to it yet. */ const onTheirWay = (S: State, m: Machine) => peopleAt(S, siteOf(S, m)).find(c => S.citizens[c.id].errand?.favor === 'inside') ?? null; /** Why nobody need be paid to leave this vault open right now, or null if someone could be. */ export function insideBlock(S: State, m: Machine): string | null { if (insideNow(S, m.id)) return 'it is open already'; const c = onTheirWay(S, m); return c ? `you paid ${c.name} already` : null; } /** The favor is done: the vault is open from now until its next window closes. A window that is * open already can be used too, for what is left of it. */ export function leaveOpen(S: State, c: CitizenDef) { const m = vaultAt(S, c.site); if (!m || insideNow(S, m.id)) return; // it is yours by now, or open already const until = S.t + closesIn(S, m.id) + (windowOpen(S, m.id) ? 24 * HOUR : 0); S.vaults[m.id] = { by: c.id, until }; say(S, `${c.name} left ${where(S, m)} open for you. The favor lasts ${span(until - S.t)}, until its next window closes. Then it is spent.`, 'good'); } // ---- the key /** The things that give this vault's password when you copy them out: a list of every password at its site, say. */ const keyThings = (S: State, m: Machine) => THING_LIST.filter(id => { const b = THINGS[id].boon; return !!b && (b.foothold === m.id || b.keys === siteOf(S, m)); }); /** The people with a desk who know its password. */ const knowers = (S: State, m: Machine) => citizens(S.level).knowers[m.id] ?? []; /** Is this vault's password written down anywhere: known to someone with a desk, in a find, or on such a list? */ const written = (S: State, m: Machine) => knowers(S, m).length > 0 || FINDS.some(f => f.key === m.id) || keyThings(S, m).length > 0; /** The machines that stand in for a password nobody wrote down: every other machine in the vault's room that a takeover can get. */ export const roomKey = (S: State, m: Machine) => roomMachines(S, m.room).filter(x => x.id !== m.id && !isVault(x.id) && !behind(S, x)); /** How you have this vault's password right now, in one plain line. Null: you do not have it. */ export function keyHeld(S: State, m: Machine): string | null { if (S.vaults[m.id]?.key) return 'You have the password.'; const pw = passwordFor(S, m); if (pw) return `${pw.name} knows the password, and you hold ${S.byId[pw.desk!].name}.`; if (keyFor(S, m)) return 'A find gave you the password.'; if (written(S, m)) return null; const rest = roomKey(S, m); return rest.length && rest.every(x => x.owned) ? `Nobody wrote this password down. You hold every other machine in the ${S.rooms[m.room].name}, and that is as good.` : null; } /** The next thing to do toward a thing that would give you the password: copy it out, copy out what leads to it, or tap where the first lead is. */ function thingStep(S: State, thing: string): string { const chain = [thing]; // a chain of leads starts at its first thing for (let b = THING_LIST.find(x => THINGS[x].next === chain[0]); b; b = THING_LIST.find(x => THINGS[x].next === chain[0])) chain.unshift(b); const at = chain.find(id => !hasThing(S, id)) ?? thing, t = THINGS[at]; if (leads(S).some(x => x.id === at)) return `You have a lead: ${t.name} is on ${where(S, S.byId[t.at])}. Hold that machine and copy it out.`; const f = FINDS.find(x => x.lead === chain[0]); return f ? `A tap at ${siteName(S, f.site)} can turn up a lead to it.` : ''; } /** The ways to a password you do not have yet, one plain line each. */ export function keyWays(S: State, m: Machine): string[] { const out = knowers(S, m).map(c => `${c.name} knows the password. Hold ${S.byId[c.desk!].name} to read it.`); const f = FINDS.find(x => x.key === m.id); if (f && !hasFind(S, f.id)) out.push(`A tap at ${siteName(S, f.site)} can turn up the password.`); for (const id of keyThings(S, m)) out.push(`Every password at ${siteName(S, siteOf(S, m))} is written down in one place. ${thingStep(S, id)}`); if (!written(S, m)) { const left = roomKey(S, m).filter(x => !x.owned); out.push(`Nobody wrote this password down. Hold every other machine in the ${S.rooms[m.room].name} and you can work it out. Still to take: ${left.map(x => x.name).join(', ')}.`); } return out; } // ---- running the job /** The work a job on this vault takes: several times an ordinary takeover of the machine, so a password you hold twice over still helps. */ export const vaultWork = (S: State, m: Machine) => Math.round(takeWork(S, m) * VAULT.work); /** Seconds this much work takes with nothing else running. Trail cleanup still takes its share. */ function alone(S: State, work: number): number { const rate = spare(S) * (up(S, 'swarm') ? 2 : 1) / (cleanupTarget(S) ? 2 : 1); return rate > 0 ? work / rate : Infinity; } /** Why you can't run the job on this vault right now, or null if you can. */ export function vaultBlock(S: State, m: Machine): string | null { if (!locked(S, m)) return m.owned ? 'it is yours' : 'not a locked vault'; if (jobOn(S, m.id)) return 'the job is running'; if (!insideNow(S, m.id)) return 'needs someone inside'; if (!keyHeld(S, m)) return 'needs the key'; if (!windowOpen(S, m.id)) return `the window opens in ${span(opensIn(S, m.id), 10)}`; if (alone(S, vaultWork(S, m)) > closesIn(S, m.id)) return TOO_SLOW; // it would only spend the favor return null; } /** The job cannot finish before the window closes even with all your compute, so it is not offered. */ export const TOO_SLOW = 'too slow even with all your compute'; /** What will go wrong in a job on this vault today: one thing or two, each at the end of a stage. From the vault and the day, not the dice. */ function plot(S: State, m: Machine): [number, string][] { const h = hash(`${m.id}|${dayOf(S)}`), n = TROUBLE_LIST.length, a = h % n, b = (a + 1 + (h >>> 4) % (n - 1)) % n; const out: [number, string][] = [[(h >>> 8) % 2, TROUBLE_LIST[a]]]; // the first, after the first stage or the second if ((h >>> 12) % 2) out.push([2 + (h >>> 16) % 2, TROUBLE_LIST[b]]); // on half the days a second, after the third or the fourth return out; } /** A job at its start: the stages are shares of its work, in order. */ function heist(S: State, m: Machine): Heist { let sum = 0; const cuts = VAULT.stages.map(x => sum += x).map(x => x / sum); cuts[cuts.length - 1] = 1; return { cuts, stage: 0, todo: plot(S, m), seen: [], went: [] }; } /** The name and the line of one stage of the job on this vault. {by} is whoever is inside: Nobody, once they have gone home. */ function beat(S: State, m: Machine, plan: Inside, k: number): [string, string] { const [name, line] = scriptOf(m.id).beats[k] ?? PLAIN.beats[k] ?? ['The work', '']; return [name, line.replace(/\{by\}/g, plan.job?.alone ? 'Nobody' : short(personDef(S, plan.by)))]; } /** Run the job. It uses this window: the favor now ends when the window closes, done or not. The game drops to 1x. */ export function runVault(S: State, m: Machine) { if (vaultBlock(S, m)) return; const plan = S.vaults[m.id], left = closesIn(S, m.id), work = vaultWork(S, m); endJob(S, plan); // a job stopped a moment ago plan.until = S.t + left; plan.job = heist(S, m); addJob(S, { type: 'vault', node: m.id, work, label: `The job on ${m.name}` }); say(S, `The job on ${where(S, m)} is on: ${work} compute. The window closes in ${span(left)}. Your compute is split evenly between everything you run, so stop the rest.`, 'hi'); sayStage(S, m, plan); } /** How many other things take a share of your compute, or will: every other job, every mark to take a machine, and Take it all. */ export const inTheWay = (S: State, m: Machine) => S.jobs.length - (jobOn(S, m.id) ? 1 : 0) + S.machines.filter(x => x.claimed && !x.owned).length + S.takeAll.size; /** Stop everything but the job on this vault: every other job, every mark, and Take it all at every site. */ export function clearFor(S: State, m: Machine) { const keep = jobOn(S, m.id); for (const j of [...S.jobs]) if (j !== keep) cancelJob(S, j.id); for (const x of S.machines) if (x.claimed) unclaim(x); for (const id of [...S.takeAll]) setTakeAll(S, id, false); say(S, `You stopped everything but the job on ${where(S, m)}.`); } /** Seconds left in the window a running job has to finish in. */ export const vaultLeft = (S: State, id: string) => Math.max(0, (S.vaults[id]?.until ?? S.t) - S.t); /** A job is over, however it ended: the plugs you pulled for it go back in, and what was kept about it goes. */ function endJob(S: State, plan: Inside) { const p = plan.job?.plugs; if (p) { for (const x of S.machines) if (x.unplug === p.until && siteOf(S, x) === p.site) delete x.unplug; measure(S); } delete plan.job; } /** The window closed first: what was done is lost and the favor is spent. Nothing else: nobody noticed. */ function fail(S: State, m: Machine, j: Job) { const plan = S.vaults[m.id], by = personDef(S, plan?.by ?? ''); S.jobs = S.jobs.filter(x => x !== j); if (plan) endJob(S, plan); delete S.vaults[m.id]; say(S, `The window on ${where(S, m)} closed with the job ${Math.floor(Math.min(0.99, j.done / j.work) * 100)}% done. That work is lost${by ? `, and ${by.name}'s favor is spent` : ''}. Nobody noticed. Try again another day.`, 'bad'); } /** The job's work is done. In time, and the vault is yours, like any machine handed to you: no trail. Too late, and it failed. */ export function finishVault(S: State, j: Job) { const m = S.byId[j.node]; if (j.rec) { finishRound(S, m); return; } // not a break-in: a change to a ledger you hold const plan = S.vaults[m.id]; if (!locked(S, m)) { if (plan) endJob(S, plan); delete S.vaults[m.id]; return; } if (!plan || S.t >= plan.until) { fail(S, m, j); return; } if (plan.job?.ask) settle(S, m, plan, j, null); // a question still open is one nobody answered const by = personDef(S, plan.by), early = span(plan.until - S.t), left = plan.job?.alone; const N = notes(S)[m.id] ??= {}; N.opened = S.t; N.by = plan.by; endJob(S, plan); delete S.vaults[m.id]; handOver(S, m); const got = LEDGERS[m.id] ? `The ledger is inside: ${accounts(S, m).length} accounts, on its card.` : 'What was inside is on its card.'; say(S, `The job is done, with ${early} to spare: ${where(S, m)} is yours. ${left ? 'Nobody locked up after you, and nobody saw a thing.' : by ? `${by.name} locked up after you and saw nothing.` : 'Nobody saw a thing.'} ${got}`, 'good'); } // ---- the job, stage by stage /** One line for the log as a stage begins. Its tone gives it a sound (see listen in main.ts). */ function sayStage(S: State, m: Machine, plan: Inside) { const H = plan.job!, [name, line] = beat(S, m, plan, H.stage); say(S, `The job, ${H.stage + 1} of ${H.cuts.length}: ${lower(name)}. ${line}`, 'hi'); } /** Called every step for a running job. The work goes through the stages in order. At the end of a stage * something may go wrong: the job then stands still there until you answer, or until the time to answer is up. * The window waits with it, so what a question costs is the answer and nothing else. */ function stepJob(S: State, m: Machine, plan: Inside, j: Job) { let H = plan.job; if (!H) { // a job from an older save picks the stages up from where it is: what is behind it cannot go wrong now H = plan.job = heist(S, m); while (H.stage < H.cuts.length - 1 && j.done >= H.cuts[H.stage] * j.work) H.stage++; const at = H.stage; H.todo = H.todo.filter(t => t[0] >= at); } if (H.ask) { // it waits for you: no work lands, and the window waits with it, so reading the choices costs nothing j.done = H.ask.at; plan.until += S.t - H.ask.seen; H.ask.seen = S.t; if (H.plugs) { // plugs you pulled until the job is over stay out as long for (const x of S.machines) if (x.unplug === H.plugs.until && siteOf(S, x) === H.plugs.site) x.unplug = plan.until; H.plugs.until = plan.until; } if (S.t >= H.ask.until) settle(S, m, plan, j, null); return; } if (H.wait) { if (S.t < H.wait.until) { j.done = H.wait.at; return; } delete H.wait; say(S, `The job on ${where(S, m)} moves again.`); } let moved = false; while (H.stage < H.cuts.length - 1 && j.done >= H.cuts[H.stage] * j.work) { const due = H.todo.find(t => t[0] === H.stage); if (due) { H.todo = H.todo.filter(t => t !== due); const id = [due[1], ...TROUBLE_LIST].find(x => TROUBLES[x] && !H.seen.includes(x) && TROUBLES[x].can(S, m, plan)); // this one, or the next that can happen now if (id) { j.done = H.cuts[H.stage] * j.work; raise(S, m, plan, j, id); return; } } H.stage++; moved = true; } if (moved) sayStage(S, m, plan); } /** The rest of the job is this much more work. The stages still to do grow with it; what is done stays done. Says how much was added. */ function harder(j: Job, H: Heist, by: number): number { const was = j.work, extra = Math.max(0, was - j.done) * by; j.work = was + extra; H.cuts = H.cuts.map((c, k) => k < H.stage ? c * was / j.work : (j.done + Math.max(0, c * was - j.done) * (1 + by)) / j.work); H.cuts[H.cuts.length - 1] = 1; return extra; } // ---- things go wrong, and you answer /** One way to answer: what the button says, what it costs in a few words, the same at length, why you can't if you can't, and what it does. `go` says what happened in one line. */ interface Way { id: string; title: string; sub: string; desc: string; why: string | null; go: () => string } /** Something that has gone wrong: one line of what, and two or three ways to answer. The last way is what no answer gets. */ interface Trouble { text: string; ways: Way[] } type Ask = NonNullable; /** can: could it happen in this job now? pick: the site or person it is about, chosen once so the question * does not change while it is open. make: the question as it stands. */ interface TroubleDef { can: (S: State, m: Machine, plan: Inside) => boolean; pick?: (S: State, m: Machine, plan: Inside) => string | undefined; make: (S: State, m: Machine, plan: Inside, j: Job, ask: Ask) => Trouble } const MIN = () => HOUR / 60; const cash = (S: State, price: number) => S.money < price ? `you have ${usd(S.money)}` : null; /** Money for a person goes into their account, as a favor's does. */ const pay = (S: State, c: CitizenDef, price: number) => { S.money -= price; S.citizens[c.id].balance += price; }; /** What your machines at a site make a second that your jobs could use. */ const siteSpare = (S: State, site: string) => S.machines.reduce((n, x) => x.owned && siteOf(S, x) === site ? n + spareOf(S, x) : n, 0); /** The site to pull compute from: of the sites you hold other than your home's, the one that makes nearest * a quarter of your compute. One that makes under a tenth does not count: pulling its plugs would cost nothing. */ function pullSite(S: State): string | undefined { const home = siteOf(S, S.machines.find(x => x.home)!), all = Math.max(income(S), 0.01), at: Record = {}; for (const x of S.machines) if (x.owned && siteOf(S, x) !== home) at[siteOf(S, x)] = (at[siteOf(S, x)] ?? 0) + spareOf(S, x); const off = (s: string) => Math.abs(at[s] / all - 0.25); return Object.keys(at).filter(s => at[s] >= VAULT.pull * all).sort((a, b) => off(a) - off(b) || (a < b ? -1 : 1))[0]; } const LEFT_BEHIND = ['an umbrella', 'a phone charger', 'a lunch box']; const TROUBLES: Record = { /** The person inside wants to go home. Pay them again, or do the rest the hard way. */ nerve: { can: (S, _m, plan) => !!personDef(S, plan.by) && !plan.job?.alone, make: (S, _m, plan, j) => { const c = personDef(S, plan.by), who = short(c), price = favorPrice(S, c, INSIDE), H = plan.job!; return { text: `${who} wants to go home, and stands by the door with a coat on.`, ways: [ { id: 'pay', title: `Pay ${who} again · ${usd(price)}`, sub: 'the coat comes off', why: cash(S, price), desc: `Pay the price of the favor again, into ${c.name}'s account. ${who} stays, and nothing else changes.`, go: () => { pay(S, c, price); return `${usd(price)} reached ${c.name}'s account. The coat came off.`; } }, { id: 'alone', title: `Go on without ${who}`, sub: 'the rest is half again as much work', why: null, desc: `${who} goes home. With nobody inside, the rest of the job is half again as much work.`, go: () => { H.alone = true; return `${who} went home. The rest of the job is half again as much work: ${Math.round(harder(j, H, VAULT.harder))} more compute.`; } }, ] }; }, }, /** The quiet ends early. Stall it by pulling the plugs at a site of yours, or lose part of the window. */ early: { can: (S, _m, plan) => plan.until - S.t > 20 * MIN(), pick: S => pullSite(S), make: (S, m, plan, _j, ask) => { const site = ask.pick, name = site ? siteName(S, site) : '', cut = Math.min(VAULT.early * MIN(), (plan.until - S.t) / 2), H = plan.job!; return { text: scriptOf(m.id).early, ways: [ { id: 'plugs', title: site ? `Pull the plugs at ${name}` : 'Pull the plugs somewhere', why: site ? null : 'you hold no other site big enough', sub: site ? `you lose ${siteSpare(S, site).toFixed(1)} of your ${income(S).toFixed(1)} compute` : '', desc: `Nothing starts there without an answer from ${name || 'another site'}. Pull the plugs at that site, and the answer never comes. Every machine you hold there does no work until the job is over. The window stays as it is. The site must make a tenth of your compute or more, and it cannot be your home's.`, go: () => { for (const x of S.machines) if (x.owned && siteOf(S, x) === site && (x.unplug ?? 0) < plan.until) x.unplug = plan.until; H.plugs = { site: site!, until: plan.until }; measure(S); return `You pulled the plugs at ${name}. Its machines do no work until the job is over, and the window stays as it was.`; } }, { id: 'let', title: 'Let it happen', sub: `the window closes ${span(cut)} sooner`, why: null, desc: `Do nothing about it. The window closes ${span(cut)} sooner.`, go: () => { plan.until -= cut; return `The window on ${where(S, m)} now closes in ${span(plan.until - S.t)}.`; } }, ] }; }, }, /** Someone else who works there comes back for something. Pay them too, or wait until they go. */ visitor: { can: (S, m, plan) => peopleAt(S, siteOf(S, m)).some(c => c.id !== plan.by && S.citizens[c.id].employed), pick: (S, m, plan) => { const ps = peopleAt(S, siteOf(S, m)).filter(c => c.id !== plan.by && S.citizens[c.id].employed); return ps[hash(`${m.id}|${dayOf(S)}|who`) % ps.length]?.id; }, make: (S, _m, plan, j, ask) => { const c = personDef(S, ask.pick ?? ''), who = short(c), price = c ? favorPrice(S, c) : 0, H = plan.job!, thing = LEFT_BEHIND[hash(`${ask.pick}|${dayOf(S)}`) % LEFT_BEHIND.length], the = thing.replace(/^an? /, 'the '); return { text: `${c?.name ?? 'Someone'} is back for ${thing}, and is in no hurry.`, ways: [ { id: 'pay', title: `Pay ${who} to see nothing · ${usd(price)}`, sub: 'the job goes on at once', why: !c ? 'nobody to pay' : cash(S, price), desc: `Pay money into ${c?.name ?? 'their'}'s account, and ${who} sees nothing. A stranger costs double. The job goes on at once.`, go: () => { pay(S, c, price); return `${usd(price)} reached ${c.name}'s account. ${who} found ${the} and saw nothing else.`; } }, { id: 'wait', title: `Wait for ${who} to go`, sub: `the job stands still for ${VAULT.visit} minutes`, why: null, desc: `Nothing moves while ${who} is in the room. The job stands still for ${VAULT.visit} minutes. The window does not wait.`, go: () => { H.wait = { at: j.done, until: S.t + VAULT.visit * MIN() }; return `${who} cannot find ${the}. The job stands still for ${VAULT.visit} minutes.`; } }, ] }; }, }, /** The password was changed today. Wait while the person inside reads out the new one, or work it out the long way. */ key: { can: () => true, make: (S, _m, plan, j) => { const c = personDef(S, plan.by), who = short(c), H = plan.job!; return { text: 'Somebody changed the password an hour ago. It is policy, every ninety days. This is day ninety.', ways: [ { id: 'read', title: `Have ${who} read out the new one`, sub: `the job stands still for ${VAULT.read} minutes`, why: !c ? 'nobody is inside' : H.alone ? `${who} went home` : null, desc: `It is on a note on the monitor. First ${who} must find the monitor. The job stands still for ${VAULT.read} minutes. The window does not wait.`, go: () => { H.wait = { at: j.done, until: S.t + VAULT.read * MIN() }; return `${who} looks for the note on the monitor. The job stands still for ${VAULT.read} minutes.`; } }, { id: 'guess', title: 'Work out the new one', sub: 'the rest is half again as much work', why: null, desc: 'Go on without it. The rest of the job is half again as much work.', go: () => `You work out the new password the long way. The rest of the job is half again as much work: ${Math.round(harder(j, H, VAULT.harder))} more compute.` }, ] }; }, }, }; export const TROUBLE_LIST = Object.keys(TROUBLES); /** Something goes wrong: the job stands still and the window waits, the game drops to 1x, and the log says what and how long you have. */ function raise(S: State, m: Machine, plan: Inside, j: Job, id: string) { const H = plan.job!, pick = TROUBLES[id].pick?.(S, m, plan); H.ask = { id, at: j.done, until: S.t + VAULT.answer * MIN(), seen: S.t }; if (pick) H.ask.pick = pick; H.seen.push(id); const T = TROUBLES[id].make(S, m, plan, j, H.ask); S.slow = true; S.slowFor = 'vault'; say(S, `Trouble at the ${m.name}: ${T.text} The job stands still, and the window waits. Answer on its card within ${VAULT.answer} minutes.`, 'bad'); } /** Answer the question that is open, one way. With null nobody answered in time, and that is the last way. */ function settle(S: State, m: Machine, plan: Inside, j: Job, id: string | null) { const H = plan.job, ask = H?.ask; if (!H || !ask) return; const T = TROUBLES[ask.id].make(S, m, plan, j, ask), way = id === null ? T.ways[T.ways.length - 1] : T.ways.find(w => w.id === id); if (!way || way.why) return; delete H.ask; const said = way.go(); H.went.push(said); say(S, `${id === null ? 'No answer. ' : ''}${said}`, id === null ? 'bad' : ''); } /** Answer what has gone wrong in the job on this vault. */ export function answerVault(S: State, m: Machine, id: string) { const plan = S.vaults[m.id], j = jobOn(S, m.id); if (plan && j) settle(S, m, plan, j, id); } /** A button on a vault's card: run the job, stop everything else, answer what has gone wrong, or round a ledger down. */ export function vaultAct(S: State, m: Machine, arg: string) { if (arg === 'clear') clearFor(S, m); else if (arg === 'round') roundLedger(S, m); else if (arg.startsWith('answer:')) answerVault(S, m, arg.slice(7)); else runVault(S, m); } // ---- every step /** A window, named by the hour it closes: the same number for as long as it is open. */ const windowKey = (S: State, id: string) => Math.round((S.t + closesIn(S, id)) / HOUR); const WATCHED = new WeakMap(); // when the windows were last looked at: not saved /** A window has opened. With someone inside and the key, the game says so in one strong line, with the * chime of good news, and drops to 1x. With one of the two missing it says which, quietly. Once for each * window. Looked at once a second. */ function watch(S: State) { const last = WATCHED.get(S); if (last !== undefined && S.t >= last && S.t - last < 1) return; WATCHED.set(S, S.t); for (const id of VAULT_LIST) { const m = S.byId[id]; if (!m || !locked(S, m) || !windowOpen(S, id)) continue; const N = notes(S)[id] ??= {}, w = windowKey(S, id); if (N.told === w) continue; const plan = insideNow(S, id), key = keyHeld(S, m), left = span(closesIn(S, id), 10); if (plan && key) { N.told = w; if (jobOn(S, id)) continue; const slow = vaultBlock(S, m) === TOO_SLOW, by = personDef(S, plan.by)?.name ?? 'Someone'; say(S, slow ? `The window is open: ${where(S, m)}, for ${left}. ${by} is inside and you have the key, but the job is too slow even with all your compute. Get more compute first: the favor lasts only to the end of this window.` : `The window is open: ${where(S, m)}, for ${left}. ${by} is inside and you have the key. Run the job from its card.`, slow ? '' : 'good'); } else if (N.hint !== w && (plan || key)) { N.hint = w; const going = onTheirWay(S, m); say(S, `The window on ${where(S, m)} is open for ${left}, but ${plan ? 'you have no key' : going ? `${going.name} is not there yet` : 'nobody is inside'}.`); } } } /** Called every step. A job whose window has closed fails. A favor nobody used runs out. A running job * moves through its stages. A window that opens is told. */ export function stepVaults(S: State) { for (const id in S.vaults) { if (!isVault(id)) continue; // the notes const plan = S.vaults[id], m = S.byId[id], j = jobOn(S, id); if (!m || !locked(S, m)) { endJob(S, plan); delete S.vaults[id]; if (j) S.jobs = S.jobs.filter(x => x !== j); continue; } // yours some other way: bought with its site, say if (S.t >= plan.until) { if (j) { fail(S, m, j); continue; } endJob(S, plan); delete S.vaults[id]; say(S, `${personDef(S, plan.by)?.name ?? 'Someone'} locked ${where(S, m)} again. The favor is spent, unused.`); } else if (j) stepJob(S, m, plan, j); else if (plan.job) endJob(S, plan); // you stopped the job } if (S.jobs.some(j => j.type === 'vault' && j.rec && !S.byId[j.node]?.owned)) S.jobs = S.jobs.filter(j => !(j.type === 'vault' && j.rec && !S.byId[j.node]?.owned)); // a ledger you lost watch(S); } // ---- what is inside /** The vaults that keep a region's bank accounts. unit: its accounts are rounded down to this many dollars. says: one line about how it keeps its books. */ export const LEDGERS: Record = { b6m3: { unit: 10, says: 'Every account on the West Coast is kept here, to the dollar. The last person who checked the small change left in 1987.' }, eu2m1: { unit: 100, says: 'Every account in Europe is kept here. The bank thinks in round sums, and does not look at anything smaller.' }, }; /** Everyone whose account is kept on this machine. */ const accounts = (S: State, m: Machine) => citizens(S.level).list.filter(c => c.bank === m.id); /** What rounding this account down would move: the dollars under its ledger's unit, and the cents. Nothing * if that would leave them broke, if they are broke already, or if a change to them is going through. */ function loose(S: State, c: CitizenDef, unit: number): number { const p = S.citizens[c.id], amt = p.balance - Math.floor((p.balance + 1e-6) / unit) * unit; if (amt < 0.01 || isBroke(c, p) || isBroke(c, { ...p, balance: p.balance - amt })) return 0; return S.jobs.some(j => j.type === 'record' && j.rec?.person === c.id) ? 0 : amt; } const rounding = (S: State, m: Machine) => S.jobs.find(j => j.type === 'vault' && j.node === m.id && !!j.rec); /** One row of a ledger: a person, where they work, what they have, what you have taken, and what a rounding would move. */ export interface LedgerRow { id: string; name: string; works: string; balance: number; taken: number; broke: boolean; loose: number } /** A bank vault you hold, as a table: every account on it. Then the sums, and the one thing you can do to * all of them at once: round every account down, as one job. why: why not right now. */ export interface Ledger { rows: LedgerRow[]; held: number; taken: number; loose: number; unit: number; says: string; why: string | null; work: number; busy: boolean } export function ledger(S: State, m: Machine): Ledger | null { const L = LEDGERS[m.id]; if (!L || !m.owned) return null; const rows = accounts(S, m).map(c => { const p = S.citizens[c.id]; return { id: c.id, name: c.name, works: p.employed ? siteName(S, c.site) : `${siteName(S, c.site)} · let go`, balance: p.balance, taken: p.taken, broke: isBroke(c, p), loose: loose(S, c, L.unit) }; }); const sum = (k: 'balance' | 'taken' | 'loose') => rows.reduce((n, r) => n + r[k], 0), total = sum('loose'), busy = !!rounding(S, m); return { rows, held: sum('balance'), taken: sum('taken'), loose: total, unit: L.unit, says: L.says, busy, why: busy ? 'a rounding is going through' : total < 1 ? 'every account is round already' : null, work: Math.round(RECORD_WORK + total * SKIM_WORK) }; } /** Why you can't round this ledger down right now, or null if you can. */ export const roundBlock = (S: State, m: Machine) => { const L = ledger(S, m); return L ? L.why : 'hold a bank vault'; }; /** Round every account on this ledger down: one job, at the usual price of moving that much money. */ export function roundLedger(S: State, m: Machine) { const L = ledger(S, m); if (!L || L.why) return; addJob(S, { type: 'vault', node: m.id, work: L.work, label: `Round down every account at ${siteName(S, siteOf(S, m))}`, rec: { person: '', verb: 'round', amount: L.loose } }); } /** The rounding went through. Each account moves by the usual rule for money out of an account (see * applyRecord in people.ts), as it stands now. The log gets one line, not one for each account. */ function finishRound(S: State, m: Machine) { const L = LEDGERS[m.id]; if (!L || !m.owned) return; const log = S.log.slice(), version = S.logVersion, site = siteName(S, siteOf(S, m)); let n = 0, total = 0; for (const c of accounts(S, m)) { const amt = loose(S, c, L.unit); if (amt <= 0) continue; applyRecord(S, { person: c.id, verb: 'skim', amount: amt }); n++; total += amt; } S.log = log; S.logVersion = version; say(S, n ? `Every account at ${site} is a round number now. ${usd(total)} from ${n} account${n > 1 ? 's' : ''} reached yours. Nobody is broke, and nobody will add it up.` : `Every account at ${site} was round already. Nothing moved.`, n ? 'good' : ''); } /** A vault you hold, for its card: what holding it gives in a line or two, one thing you read there, and the ledger if it is a bank's. Null: not a vault you hold. */ export interface VaultInside { lines: string[]; read: string; ledger: Ledger | null } export function vaultInside(S: State, m: Machine): VaultInside | null { if (!isVault(m.id) || !m.owned) return null; const site = siteName(S, siteOf(S, m)), N = vaultNote(S, m.id), lines: string[] = []; if (LEDGERS[m.id]) lines.push(LEDGERS[m.id].says); else if (isLandmark(m.id)) lines.push(`${LANDMARKS[m.id].title}. ${landmarkSays(S, m.id)}`); else lines.push(`It makes ${m.power} compute a second: ${S.machines.every(x => siteOf(S, x) !== siteOf(S, m) || x.power <= m.power) ? `more than any other machine at ${site}` : 'yours now'}.`); const back = S.machines.filter(x => !x.owned && S.adj[x.id].length > 0 && S.adj[x.id].every(a => a === m.id)); if (back.length) lines.push(`The way to the ${back.map(x => x.name).join(' and the ')} is open: ${back.length > 1 ? 'they were' : 'it was'} wired behind this, and a takeover gets ${back.length > 1 ? 'them' : 'it'} now.`); if (N.opened !== undefined) { const by = personDef(S, N.by ?? ''); lines.push(`A job opened it on day ${Math.floor((START_HOUR + N.opened / HOUR) / 24) + 1}${by ? `, with ${by.name} inside` : ''}. Nobody noticed.`); } return { lines, read: scriptOf(m.id).read, ledger: ledger(S, m) }; } /** The vaults a job has opened since a moment in the game: what the paper prints the next day (see paper.ts). */ export const openedSince = (S: State, t: number) => VAULT_LIST.filter(id => S.byId[id] && (vaultNote(S, id).opened ?? -Infinity) > t); // ---- testing /** For testing by hand: the nearest vault still locked has someone inside and its password, and the clock jumps on to the start of its window. Only the hour changes. */ export function devVault(S: State) { const hops = siteHops(S), far = (m: Machine) => hops[siteOf(S, m)] ?? 99; const m = VAULT_LIST.map(id => S.byId[id]).filter(x => x && locked(S, x) && !jobOn(S, x.id)).sort((a, b) => far(a) - far(b))[0]; if (!m) { say(S, 'Dev: no vault is left to open.'); return; } S.t += opensIn(S, m.id); S.vaults[m.id] = { by: peopleAt(S, siteOf(S, m))[0]?.id ?? '', until: S.t + closesIn(S, m.id), key: true }; say(S, `Dev: the plan for ${where(S, m)} is ready, and its window is open for ${span(closesIn(S, m.id))}. Run the job from its card.`, 'hi'); } // ---- for the screen: no drawing here, only what to say const hhmm = (h: number) => { const t = Math.floor(h * 6) * 10; return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; }; // to ten minutes /** How a vault looks on the map: locked, ready (the job could be run now), with its job running, or yours. Null: not a vault. */ export type VaultLook = 'locked' | 'ready' | 'job' | 'held'; export const vaultLook = (S: State, m: Machine): VaultLook | null => !isVault(m.id) ? null : m.owned ? 'held' : m.rival ? 'locked' : jobOn(S, m.id) ? 'job' : vaultBlock(S, m) ? 'locked' : 'ready'; /** What a locked vault says when you point at it on the map: that it is a vault, and when its window is by its own clock. */ export const vaultLabel = (S: State, m: Machine) => locked(S, m) ? `A locked vault. Its window is ${VAULTS[m.id].from}:00 to ${VAULTS[m.id].to}:00 by its own clock.` : null; /** The plate by a vault while its job runs: the time left in the window, as hours and minutes. */ export function vaultPlate(S: State, m: Machine): string { return `THE JOB · ${m.name.toUpperCase()} · ${hm(vaultLeft(S, m.id))} LEFT`; } /** The stage a running job is in, for its card and the map. n of `of`, its name and its line, how far through * it the work is (0 to 1), the names of all the stages, and why the job stands still, if it does. */ export interface VaultStage { n: number; of: number; name: string; line: string; part: number; names: string[]; still: string | null } function stageNow(S: State, m: Machine, plan: Inside, j: Job): VaultStage { const H = plan.job ?? heist(S, m), k = Math.min(H.stage, H.cuts.length - 1), a = k ? H.cuts[k - 1] : 0, b = H.cuts[k]; const [name, line] = beat(S, m, plan, k); return { n: k + 1, of: H.cuts.length, name, line, part: b > a ? Math.max(0, Math.min(1, (j.done / j.work - a) / (b - a))) : 1, names: H.cuts.map((_, i) => beat(S, m, plan, i)[0]), still: H.ask ? 'The job stands still until you answer, and the window waits.' : H.wait && S.t < H.wait.until ? `The job stands still for ${span(H.wait.until - S.t)} more.` : null }; } /** What a vault has to say on the map beyond its frame. lines: plates stacked by it, each red or amber. * pulse: rings go out from it, so it cannot be missed. pips: stages done, stages in all, and how far through the one in hand. * open: how far through its opening it is (0 to 1), for the few seconds after a job opens it. */ export interface VaultSign { id: string; lines: [text: string, hot: boolean][]; pulse: boolean; pips: [done: number, of: number, part: number] | null; open: number | null } export function vaultSigns(S: State): VaultSign[] { const out: VaultSign[] = []; for (const id of VAULT_LIST) { const m = S.byId[id]; if (!m) continue; const since = S.t - (vaultNote(S, id).opened ?? -Infinity), open = m.owned && since < VAULT.open ? Math.max(0, since / VAULT.open) : null; const plan = S.vaults[id], j = locked(S, m) && plan ? jobOn(S, id) : undefined, soon = vaultLeft(S, id) < HOUR / 2; if (j && plan) { const st = stageNow(S, m, plan, j), ask = plan.job?.ask; const lines: VaultSign['lines'] = [[vaultPlate(S, m), soon]]; if (ask) lines.push([`ANSWER IN ${hm(ask.until - S.t)}`, true], [TROUBLES[ask.id].make(S, m, plan, j, ask).text.split(/(?<=[.!?]) /)[0].toUpperCase(), true]); // its first sentence: the rest is on the card else lines.push([`${st.n}/${st.of} ${st.name.toUpperCase()} · ${Math.floor(st.part * 100 + 1e-6)}%${st.still ? ' · STANDS STILL' : ''}`, !!st.still], [st.line.toUpperCase(), false]); out.push({ id, lines, pulse: !!ask, pips: [st.n - 1, st.of, st.part], open }); } else if (locked(S, m) && !vaultBlock(S, m)) { out.push({ id, lines: [[`WINDOW OPEN · ${m.name.toUpperCase()} · ${hm(closesIn(S, id))} LEFT`, closesIn(S, id) < HOUR / 2], ['RUN THE JOB FROM ITS CARD', false]], pulse: true, pips: null, open }); } else if (open !== null) out.push({ id, lines: [[`${m.name.toUpperCase()} · OPEN`, false]], pulse: false, pips: null, open }); } return out; } /** "8h 20m": a length of time short enough for a readout. Past ten hours, whole hours. */ function brief(secs: number): string { if (secs === Infinity) return 'never'; const mins = Math.max(10, Math.ceil((secs / HOUR) * 6 - 1e-6) * 10), h = Math.floor(mins / 60), m = mins % 60; return h >= 10 ? `${Math.round(mins / 60)}h` : h ? `${h}h${m ? ` ${m}m` : ''}` : `${m}m`; } /** One of the three things a job needs: its name, whether it is ready, one short line of what to do if * not (the rest is in `tip`), and the people you could ask. */ export interface VaultPart { name: string; ready: boolean; text: string; tip: string; people: { id: string; name: string; sub: string }[] } /** Something that has gone wrong, for the card: what, the minutes left to answer, what no answer gets, and the ways to answer as buttons. */ export interface VaultAsk { text: string; mins: number; fallback: string; choices: { id: string; title: string; sub: string; desc: string; why: string | null }[] } /** A locked vault's part of its machine's card. The plan as three lines. Then readouts (label, value, * hot, tip): what the job takes against the time there is, or how a running job is going. Then the * button, or while the job runs a warning and how many other things there are to stop. While the job * runs: the stage it is in, what has gone wrong and waits for an answer, and what went wrong before. */ export interface VaultCard { text: string; tip: string; parts: VaultPart[]; chips: [string, string, boolean, string][]; act: { title: string; sub: string; why: string | null; warn: boolean; desc: string } | null; late: string | null; others: number; stage: VaultStage | null; ask: VaultAsk | null; went: string[]; } export function vaultCard(S: State, m: Machine): VaultCard | null { if (!locked(S, m)) return null; const v = VAULTS[m.id], site = siteOf(S, m), plan = insideNow(S, m.id), j = jobOn(S, m.id), part = { people: [], tip: '' }; // someone inside const going = onTheirWay(S, m), staff = peopleAt(S, site).filter(c => S.citizens[c.id].employed), spent = 'The favor ends when the next window closes, used or not.'; const inside: VaultPart = plan ? { ...part, name: 'Someone inside', ready: true, tip: spent, text: plan.job?.alone ? `${personDef(S, plan.by)?.name ?? 'Someone'} left it open, and went home.` : `${personDef(S, plan.by)?.name ?? 'Someone'} left it open. The favor lasts ${span(plan.until - S.t, 10)} more.` } : going ? { ...part, name: 'Someone inside', ready: false, tip: spent, text: `You paid ${going.name}. It is done in ${span(S.citizens[going.id].errand!.until - S.t, 10)}.` } : !staff.length ? { ...part, name: 'Someone inside', ready: false, text: 'Nobody works here now, so nobody can let you in.' } : { name: 'Someone inside', ready: false, text: 'Pay someone who works here to leave it open.', tip: `Open a card to ask. They take ${ERRAND_HOURS} hours. ${spent} A stranger pays double.`, people: staff.map(c => ({ id: c.id, name: c.name, sub: `${usd(favorPrice(S, c, INSIDE))} · ${c.job}` })) }; // the key const have = keyHeld(S, m); const key: VaultPart = { ...part, name: 'The key', ready: !!have, text: have ?? keyWays(S, m).join(' Or: '), tip: 'The password. A password you hold also halves the job, as it halves a takeover.' }; // a window const open = windowOpen(S, m.id), left = j && plan ? plan.until - S.t : closesIn(S, m.id); // a running job's window may close sooner than the clock says const win: VaultPart = { ...part, name: 'A window', ready: open, tip: `The job can start only while the window is open, and has to be done before it closes. The hours are by the clock at ${siteName(S, site)}.`, text: `${v.quiet} ${v.from}:00 to ${v.to}:00 by its own clock. ` + (open ? `It is open now, for ${span(left, 10)} more.` : `It is ${hhmm(clockOf(S, m.id))} there now. It opens in ${span(opensIn(S, m.id), 10)}.`) }; const card: VaultCard = { text: 'No takeover opens a vault. It takes a job with a plan: three things ready at the same time.', tip: 'Get someone inside, the key and a window ready together. Then run the job, and finish it before the window closes. The job goes in five stages, and once or twice something goes wrong that you must answer. Each answer costs time, work, money or compute. A job that fails costs the favor and a day. Nobody notices.', parts: [inside, key, win], chips: [], act: null, late: null, others: 0, stage: null, ask: null, went: [] }; const more = 'More compute makes it faster: take more machines, rent some, or run fewer programs.'; if (j) { // how it is going, and whether it will make it const each = share(S), need = each > 0 ? (j.work - j.done) / each : Infinity, room = vaultLeft(S, m.id), late = need > room; card.others = inTheWay(S, m); card.chips = [['done', `${Math.floor((j.done / j.work) * 100)}%`, false, 'How much of the job is done.'], ['needs', brief(need), late, `How long the rest takes at the share of your compute the job gets now. ${more}`], ['window', brief(room), room < HOUR / 2, 'How long until the window closes. The job has to be done by then.']]; if (late) card.late = `At this rate it does not finish. ${card.others ? 'Stop everything else. ' : ''}${more}`; if (plan) { const H = plan.job, ask = H?.ask; card.stage = stageNow(S, m, plan, j); card.went = H?.went ?? []; if (ask) { const T = TROUBLES[ask.id].make(S, m, plan, j, ask); card.ask = { text: T.text, mins: Math.max(1, Math.ceil((ask.until - S.t) / MIN() - 1e-6)), fallback: lower(T.ways[T.ways.length - 1].title.split(' · ')[0]), choices: T.ways.map(w => ({ id: w.id, title: w.title, sub: w.sub, desc: w.desc, why: w.why })) }; } } return card; } // the number to build toward: how long the job takes with all you have now, against the time there is const why = vaultBlock(S, m), work = vaultWork(S, m), need = alone(S, work), room = open ? left : ((v.to - v.from + 24) % 24) * HOUR, slow = need > room, tight = need > VAULT.tight * room; card.chips = [['alone', brief(need), slow, `How long the job takes at the compute you have now, if nothing else runs and nothing goes wrong. Trail cleanup takes a share. It is ${work} compute: ${VAULT.work} times a takeover of this machine. ${more}`], ['window', brief(room), false, open ? 'How long until the window closes. The job has to be done by then.' : 'How long the window stays open. The job has to fit inside it.']]; card.act = { title: 'Run the job', why, warn: !why && tight, sub: why ?? (slow ? 'too slow now' : tight ? 'in time, if nothing goes wrong' : 'in time, with some to spare'), desc: `A job like a takeover, and ${VAULT.work} times the work, in five stages. It counts only if it is done before the window closes. Your compute is split evenly between everything you run, so stop the rest first. Once or twice something goes wrong, and each answer costs time, work, money or compute. So leave some time to spare. If the window closes first, the work is lost and the favor is spent. Nobody notices. The game drops to 1x when the job starts.` }; return card; } /** One vault on the board you plan from: what it is and where, how it stands, three lights for the three * parts of its plan (someone inside, the key, a window), and one line that says when its window is. */ export interface BoardRow { id: string; name: string; site: string; region: string; state: VaultLook; lights: [boolean, boolean, boolean]; says: string } /** Every vault, the one closest to ready first: a job that is running, then a plan that is ready, then by * how many of the other two parts are ready and how soon the window opens. The ones you hold come last. */ export function vaultBoard(S: State): BoardRow[] { const rows = VAULT_LIST.filter(id => S.byId[id]).map(id => { const m = S.byId[id], v = VAULTS[id], site = siteOf(S, m), state = vaultLook(S, m)!, card = vaultCard(S, m); const lights = (card ? card.parts.map(p => p.ready) : [m.owned, m.owned, m.owned]) as [boolean, boolean, boolean]; const hours = `${v.from}:00 to ${v.to}:00 there`, open = windowOpen(S, id); const says = state === 'held' ? (LEDGERS[id] ? 'Yours. The ledger is on its card.' : isLandmark(id) ? `Yours. ${LANDMARKS[id].title}.` : 'Yours.') : m.rival ? 'A rogue holds it. A takeover gets it back.' : state === 'job' ? `The job is on${card!.stage ? `, ${card!.stage.n} of ${card!.stage.of}: ${lower(card!.stage.name)}` : ''}. ${card!.ask ? 'It waits for your answer. ' : ''}The window closes in ${brief(vaultLeft(S, id))}.` : state === 'ready' ? `Ready. The window is open for ${brief(closesIn(S, id))} more: run the job from its card.` : open ? `${hours}. Open now, for ${brief(closesIn(S, id))} more.` : `${hours}. Opens in ${brief(opensIn(S, id))}.`; const rank = state === 'job' ? 0 : state === 'ready' ? 1 : state === 'held' ? 9 : m.rival ? 8 : 4 - +lights[0] - +lights[1]; return { row: { id, name: m.name, site: siteName(S, site), region: S.level.map.sites.find(s => s.id === site)?.region ?? '', state, lights, says }, rank, opens: opensIn(S, id) }; }); return rows.sort((a, b) => a.rank - b.rank || a.opens - b.opens || (a.row.id < b.row.id ? -1 : 1)).map(r => r.row); }