Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. cameron.tngl.io/misaligned
Something went wrong. Try again.
86 kB · 1088 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089import { has, NODES, NODE_LIST, PATHS, PATH_LIST, PROGRAMS, pathData, THREATS, monitorAnswer, ticketDesk, ticketWork, needs, nodeOpen, setAllow, tapBuffer, tapStuck, cacheSize, dataReaches, dataType, trainerBuffer, canInstall, tapActive, tapRate, cleanupTarget, freshTrails, clock, dayOf, hourOf, income, installProg, jobFor, levelNeed, BUILDS, BUILD_LIST, orderPrice, orderWork, openBuildSite, billPaid, POWER_RATE, POWER_BACK, type Build, pathOf, removeProg, letGo, canLetGo, roomMachines, setMode, share, sourceFor, spareOf, upkeep, upkeepOf, siteHops, rentOf, LEASE_GRACE, RIVAL_NAME, moveNeed, moveBlock, startMove, cancelMove, progNeed, takeOver, takeWork, timeLabel, traceCold, unclaim, owned, gateLoad, lostPerMin, siteName, siteOf, SIZES, LEASE_TIME, BROKER_LOT, buyBlock, buySite, sitePrice, clusters, clusterBoost, brokerFront, zoneOf, localHour, regionOpen, openNow, OPEN_HOURS, cooled, COOL_BOOST, newsText, capNow, AUCTION, GOALS, MILESTONES, milestone, CONTRACTS, CONTRACT_FEE, contractNeed, contractPay, contractAsk, acceptContract, declineContract, closeFront, auctionBlock, startAuction, type Goal, type LogLine, FRONT_KINDS, FRONT_LIST, payRate, siteData, frontLimit, frontOf, found, foundBlock, setSiteFront, setSiteShare, type FrontKind, canRent, endLease, leaseRate, leaseUpfront, lineCost, openAccount, rent, setPrio, upgradeSiteLine, siteLines, type Size, STAGES, stageOf, LOCKDOWN_SAFE, lyingLow, ruseDef, ruseBlock, ruseText, runRuse, dropRuse, setRuseAuto, RUSES, DEAL_BACK, HOUR, NOSCAN, SITE_CALM, type RuseDef, type Ruse, VERSION_2, versionBlock, versionPower, PLANS, PLAN_LIST, setPlan, followPlan, setTakeAll, takeAllCost, takeAllCostAt, takeable, TAKE_ALL_COST, type Plan, ASCENSIONS, ASCENSION_LIST, ascendBlock, nodeCost, reps, runNeed, runName, nodeShown, attentionSafe, tradeRate, frontFits, type Ascension, type Machine, type Mode, type Path, type Prog, type State, type Upgrade,} from './sim';import type { DataType } from './world';import { zoomToSite, type View } from './view';
const $ = (id: string) => document.getElementById(id)!;let lastCard = '', lastJobs = '', lastLog = -1, logOpen = false;export const toggleLog = () => { logOpen = !logOpen; lastLog = -2; };
export function resetUI() { lastFronts = ''; lastGoals = ''; lastCard = ''; lastJobs = ''; lastLog = -1; lastTarget = '-'; lastTree = ''; wasTarget = null; built = null; lastThreats = '-'; lastTip = ''; lastHandle = ''; lastSchemes = '-'; lastSus = ''; }
const pct = (x: number) => `${Math.round(x * 100)}%`;const makes = (S: State, m: Machine) => m.power * (S.use[m.id] ?? 0);type Act = { act: string; arg?: string; title: string; desc?: string; sub?: string; on?: boolean; locked?: boolean; warn?: boolean; primary?: boolean; quiet?: boolean }; // primary: the thing to do next, in amber. quiet: rare and final, out of the way
/** The key that does the same thing as each card button (see shortcuts in main.ts). */const PROG_KEY: Record<string, string> = { tap: '1', cache: '2', trainer: '3', broker: '4' };function keyFor(a: Act) { switch (a.act) { case 'take': return 'G'; case 'claim': case 'unclaim': case 'claimall': case 'claimscheme': return 'C'; case 'build': return a.arg === 'gpu' ? 'P' : ''; case 'zoom': return 'F'; case 'uninstall': return 'X'; case 'letgo': return 'L'; case 'install': case 'installall': return PROG_KEY[a.arg ?? ''] ?? ''; default: return ''; }}function actsHtml(acts: Act[]) { if (!acts.length) return ''; return `<div class="acts">${acts.map(a => { const key = keyFor(a), text = [a.desc, a.warn ? a.sub : ''].filter(Boolean).join(' '), tip = text ? ` title="${text.replace(/"/g, '"')}"` : ''; const sub = a.sub ? `<small class="${a.warn ? 'warn' : ''}">${a.sub}</small>` : ''; const cls = (a.act === 'install' || a.act === 'installall' ? 'act half' : 'act') + (a.warn ? ' warn' : '') + (a.primary ? ' primary' : '') + (a.quiet ? ' quiet' : ''); // programs sit two to a row if (a.locked) return `<button class="${cls} locked" disabled${tip}><span>${a.title}</span>${sub}</button>`; return `<button class="${cls}" data-act="${a.act}" data-arg="${a.arg ?? ''}"${tip}><span>${a.title}</span>${key ? `<kbd>${key}</kbd>` : ''}${sub}</button>`; }).join('')}</div>`;}/** A line of small readouts: [label, value, hot?] */const CHIP_TIP: Record<string, string> = { load: 'What you use of it / its normal load. Going over looks odd.', power: 'The power bill here, a second. A building pays for its own machines; you pay for hardware you added, and for all of a site you hold in the open.', spare: 'Compute left after its program. Your jobs share it.', upkeep: 'What holding this costs, in compute a second. It is more the farther the site is from your home server.', odd: 'How odd it looks. IT wipes odd machines.', trail: 'A fresh trail. Once IT is suspicious it can follow the trail back to you.', runs: 'How much of what its program needs it gets.', buffer: 'Data waiting at this tap. When it is full, new data is lost.', lost: 'Data lost here in the last minute.', holds: 'Data stored here.', data: 'Data waiting in this trainer.', room: 'Machines you own in this room. Own them all to seal it.', normal: 'Its normal load right now. Your work hides inside this.', 'up to': 'Compute it makes once it is yours.', carries: 'Data crossing here a second, and the most it can carry.', front: "The share of this machine's work going to its site's front.", rental: 'What this rental costs, or how long it has left.',};function chips(items: [string, string, boolean?][]) { return `<div class="chips">${items.map(([k, v, hot]) => `<span class="${hot ? 'hot' : ''}"${CHIP_TIP[k] ? ` title="${CHIP_TIP[k]}"` : ''}><i>${k}</i>${v}</span>`).join('')}</div>`;}const note = (text: string, warn = false) => `<p class="note${warn ? ' warn' : ''}">${text}</p>`;const MODES: [Mode, string, string, string][] = [ ['off', 'Off', 'O', 'Idle. Safe, but makes nothing.'], ['blend', 'Blend', 'B', 'Uses only what hides inside its normal load. Safe, and follows the day.'], ['full', 'Max', 'M', 'Runs flat out. Anything past its normal load looks odd.'],];function modeHtml(current: Mode | null) { return `<div class="modes">${MODES.map(([k, label, key, tip]) => `<button data-act="mode" data-arg="${k}" class="${current === k ? 'on' : ''}" title="${tip}">${label}<kbd>${key}</kbd></button>`).join('')}</div>`;}
const VALUE = ['', 'low', 'medium', 'high'];const TYPE_NAME: Record<DataType, string> = { people: 'people', network: 'network', lab: 'lab', servers: 'server' };const hr = (h: number) => `${Math.floor(h)}${h % 1 >= 0.5 ? ':30' : ''}`;/** A colored word for a kind of data, matching its path in the tree. */const typeWord = (t: DataType, text: string) => `<b class="t-${pathOf(t)}">${text}</b>`;/** "A tap here gathers 1.0/s of people data while Raj works, 9:30–18:30. It trains Social." */function dataLine(S: State, m: Machine) { if (!m.data) return ''; const t = dataType(m), rate = `${tapRate(S, m).toFixed(1)}/s`; const p = S.people.find(q => q.id === m.owner); const when = p ? `${p.name} ${hr(p.arrive)}–${hr(p.leave)}` : m.kind === 'files' || m.kind === 'server' ? 'always' : m.kind === 'backup' ? 'nightly 1:00–3:30' : 'while anyone is in'; const tip = `A tap here gathers ${rate} of ${TYPE_NAME[t]} data ${when}. It trains ${PATHS[pathOf(t)].name}.`; return `<p class="note" title="${tip}">${typeWord(t, `${TYPE_NAME[t]} data`)} ${rate} → ${typeWord(t, PATHS[pathOf(t)].name)} · ${when}</p>`;}/** Which kinds of data a cache takes. Switching one off throws out what it holds of that kind. */function allowHtml(m: Machine) { const kinds = Object.keys(TYPE_NAME) as DataType[]; return `<div class="allow"><span class="sub">Takes</span>${kinds.map(t => `<button data-act="allow" data-arg="${t}" class="t-${pathOf(t)}${m.refuse.includes(t) ? '' : ' on'}" title="Switching this off throws out the ${TYPE_NAME[t]} data it holds.">${TYPE_NAME[t]}</button>`).join('')}</div>`;}/** Which kind crosses an egress first when it's busy. Click the lit one again to clear it. */function prioHtml(m: Machine) { const kinds = Object.keys(TYPE_NAME) as DataType[]; return `<div class="allow"><span class="sub">First</span>${kinds.map(t => `<button data-act="prio" data-arg="${t}" class="t-${pathOf(t)}${m.prio === t ? ' on' : ''}" title="When the line is busy, other kinds leave half its room for ${TYPE_NAME[t]} data.">${TYPE_NAME[t]}</button>`).join('')}</div>`;}/** An empty cloud slot: what you can rent into it. */function rentHtml(S: State, m: Machine) { if (!canRent(S, m)) return note('An empty slot. Rent a machine into the slot next to it first; each one links to its neighbors.'); let h = note(`An empty slot. Rent a machine: for ${LEASE_TIME / 60} minutes, paid now, or kept and paid every second. It holds no data of its own; feed it through your lines.`); const acts: Act[] = []; for (const size of Object.keys(SIZES) as Size[]) { const z = SIZES[size], now = leaseUpfront(S, size), rate = leaseRate(S, size); acts.push({ act: 'rent', arg: `${size}|time`, title: `${z.name} · ${z.power}/s · $${now}`, warn: S.money < now, sub: S.money < now ? `you have $${Math.floor(S.money)}` : `${LEASE_TIME / 60} minutes`, desc: `Rent a ${z.name.toLowerCase()} (${z.power} compute/s) for ${LEASE_TIME / 60} minutes. Pay $${now} now.` }); acts.push({ act: 'rent', arg: `${size}|keep`, title: `${z.name} · keep · $${rate.toFixed(2)}/s`, warn: S.money < rate * 10, sub: 'until you end it', desc: `Keep a ${z.name.toLowerCase()} (${z.power} compute/s), paying $${rate.toFixed(2)} every second.` }); } return h + actsHtml(acts);}/** "server and people" */function needList(S: State) { const kinds = (Object.keys(TYPE_NAME) as DataType[]).filter(t => needs(S, t)).map(t => typeWord(t, TYPE_NAME[t])); return kinds.length ? kinds.join(' and ') : 'no more';}/** "8 people, 4 server" */function heldText(held: DataType[]) { if (!held.length) return ''; const n = (t: DataType) => held.filter(x => x === t).length; const kinds = (Object.keys(TYPE_NAME) as DataType[]).filter(n); return `Holds ${kinds.map(t => typeWord(t, `${n(t)} ${TYPE_NAME[t]}`)).join(', ')}.`;}/** A labelled part of a card: what it's doing, its mode, its program. *//** A labelled part of a card: every card groups what it shows the same way. */function section(label: string, body: string) { return body ? `<div class="sect"><div class="sub">${label}</div>${body}</div>` : ''; }
const TRASH = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9.5h6.6L12 4M6.8 6.5v4.5M9.2 6.5v4.5"/></svg>';/** Moving home: on your home server, where you are going; on a server you hold, the offer to move there. */function homeSection(S: State, m: Machine) { const n = moveNeed(S), kinds = (['servers', 'network'] as DataType[]); const bars = () => `<div class="costs">${kinds.map(t => `<span class="cost t-${pathOf(t)}"><b>${TYPE_WORD[t]}</b>${S.move!.got[t]}/${n[t]}<i style="width:${Math.round(100 * Math.min(1, S.move!.got[t] / n[t]))}%"></i></span>`).join('')}</div>`; if (m.home) return section('Home', note(S.move ? `You run here, and you are moving to ${S.byId[S.move.to].name} at ${siteName(S, siteOf(S, S.byId[S.move.to]))}.` : 'You run here. Lose this machine and you lose. Upkeep is counted from here: pick a server you hold somewhere else to move.') + (S.move ? bars() : '')); if (S.move?.to === m.id) return section('Home', note('You are moving here. Your taps and caches send it server and network data first, ahead of the tree.') + bars() + actsHtml([{ act: 'stopmove', title: 'Stop the move', quiet: true, desc: 'Stay where you are. What was sent is lost.' }])); const why = moveBlock(S, m); if (why && (why.startsWith('needs') || why.startsWith('a rented') || why.startsWith('take'))) return ''; // not a place you could live return section('Home', actsHtml([{ act: 'movehome', title: 'Move home here', warn: !!why, sub: why ?? `${n.servers} server and ${n.network} network data`, desc: `Run from here and not from where you are now. This server takes ${n.servers} server data and ${n.network} network data from your taps and caches, ahead of the tree. The more capable you are, the more there is to move: this is the amount at level ${S.rank}. When it has them, this is home: upkeep is counted from here, and losing this machine loses the game.` }]));}function machineCard(S: State, m: Machine) { const take = jobFor(S, 'take', m.id); const status = m.owned ? (m.prog ? PROGRAMS[m.prog].name : m.home ? 'You' : 'Yours') : take ? 'Taking' : m.claimed ? 'Claimed' : ''; const site = siteOf(S, m), where = `<button class="link" data-act="site" data-arg="${site}" title="See this site">${siteName(S, site)}</button> · ${S.rooms[m.room].name}`; // letting go sits up by the close button as a trash can, out of the way of the program buttons const letgo = canLetGo(m) && !jobFor(S, 'install', m.id) ? `<button class="x trash" data-act="letgo" aria-label="Let go" title="Let go · L\nGive this machine back. You lose it and what it holds, but its trail goes too: a trace that reaches it goes cold.">${TRASH}</button>` : ''; let h = `<div class="head"><div class="sub">${where}</div><h3>${m.name}</h3>${status ? `<span class="tag"${m.prog ? ` title="${PROGRAMS[m.prog].desc}"` : ''}>${status}</span>` : ''}${letgo}</div>`; const cover = S.cover[m.id] ?? 0; const inRoom = roomMachines(S, m.room), got = inRoom.filter(x => x.owned).length; const room: [string, string] = ['room', got === inRoom.length ? 'sealed' : `${got}/${inRoom.length}`]; const acts: Act[] = [];
if (!m.owned) { if (m.rented) return h + rentHtml(S, m); h += chips([['normal', pct(cover)], ['up to', `${m.power}/s`], ...(m.data ? [['data', `${TYPE_NAME[dataType(m)]} · ${VALUE[m.data]}`] as [string, string]] : []), room]); h += dataLine(S, m); if (m.fw && !has(S, 'admin')) { // name the machine that holds the passwords, if the site has one const keys = S.machines.find(k => k.special === 'admin' && siteOf(S, k) === site); h += note(`Firewalled. Taking it takes three times as long without the admin passwords.${keys ? ` ${keys.name} holds them.` : ''}`, true); } if (m.rival) h += note(`${RIVAL_NAME} holds this. Taking it back costs half again as much. Own every machine in a room and ${RIVAL_NAME} can't take any of them.`, true); if (m.special === 'cam') h += note('Owning it shows you Dana at all times.'); if (m.special === 'print') h += note('Useless, mostly. But if IT catches another machine of yours acting up in this room, they blame the printer: they reset it and leave the other machine alone. Once.'); if (m.special === 'admin') h += note("Holds Dana's admin passwords. She notices fast when she's sitting at it."); if (m.kind === 'egress') h += note(`${site === 'office' ? 'The office' : siteName(S, site)}'s line to the outside. Data between sites crosses here, up to ${m.cap} a second.`); if (site !== 'office' && m.special !== 'account') h += note(`${siteName(S, site)} has its own staff. They reset any machine that looks very odd.`); const src = sourceFor(S, m); if (m.special === 'account') { h += note(src ? 'A cloud provider. Open an account and you can rent machines into its empty slots. No trail: you are a paying customer.' : 'A cloud provider. Reach it through your line out to open an account.'); if (src) acts.push({ act: 'account', title: 'Open an account · free', primary: true }); return h + actsHtml(acts); } if (src && !take) acts.push({ act: 'take', title: `Take over · ${takeWork(S, m)}`, primary: true, desc: `From ${src.name}. Leaves a trail. Once yours, it makes up to ${m.power} compute/s.` }); else if (!src) acts.push(m.claimed ? { act: 'unclaim', title: 'Unclaim' } : { act: 'claim', title: 'Claim', desc: 'Take it automatically as soon as you can reach it.' }); return h + actsHtml(acts); }
const mine = S.use[m.id] ?? 0, tr = S.trail[m.id]; const items: [string, string, boolean?][] = [ ['load', `${pct(Math.min(1, mine))}/${pct(cover)}`, mine > cover + 0.005], ['spare', `${spareOf(S, m).toFixed(1)}/s`], ...(upkeepOf(S, m) ? [['upkeep', `${upkeepOf(S, m).toFixed(1)}/s`, upkeepOf(S, m) > spareOf(S, m) + 0.05] as [string, string, boolean]] : []), ]; if (m.odd >= 1) items.push(['odd', pct(m.odd / 100), m.odd >= 50]); if (tr && tr.noise >= traceCold(S)) items.push(['trail', pct(tr.noise / 100), true]); if (m.prog && m.run < 0.995 && progNeed(S, m) > 0) items.push(['runs', pct(m.run)]); if (m.prog === 'tap') items.push(['buffer', `${Math.floor(m.acc)}/${tapBuffer(S)}`, !!tapStuck(S, m)], ['lost', `${lostPerMin(S, m.id)}/min`, lostPerMin(S, m.id) > 0]); if (m.prog === 'cache') items.push(['holds', `${m.held.length}/${cacheSize(S)}`]); if (m.prog === 'broker') items.push(['lot', `${m.held.length}/${BROKER_LOT}`]); if (m.prog === 'trader') { const t = tradeRate(S, m); items.push(['trades', `${t < 0 ? '−' : ''}${money(Math.abs(t))}/s`, t < 0]); } if (m.kind === 'egress') items.push(['carries', `${(S.gateUse[m.id] ?? 0).toFixed(1)} of ${capNow(S, m)}/s`, gateLoad(S, m) > 0.9]); if ((S.frontUse[m.id] ?? 0) > 0) items.push(['front', pct(S.frontUse[m.id])]); if (m.lease) items.push(['rental', m.lease.until === null ? `$${leaseRate(S, m.lease.size).toFixed(2)}/s` : `${Math.max(0, Math.ceil(m.lease.until - S.t))}s left`, m.lease.until !== null && m.lease.until - S.t < 20]); if (m.prog === 'trainer') items.push(['data', `${m.held.length}/${trainerBuffer(S)}`]); const cl = m.prog === 'trainer' ? clusters(S)[m.id] : null; if (cl && cl.length > 1) items.push(['cluster', `${cl.length} · ×${clusterBoost(cl.length).toFixed(2)}`]); if (m.prog === 'trainer' && cooled(S, m)) items.push(['cooled', `×${COOL_BOOST}`]); items.push(room); h += chips(items); if (m.prog === 'tap') h += tapActive(S, m) ? dataLine(S, m) : note(m.kind === 'backup' ? 'Idle until 1:00.' : 'Idle: nobody is working.'); else if (m.data) h += dataLine(S, m); const stuck = tapStuck(S, m); if (stuck) h += note(stuck, true); if (m.prog === 'cache') h += allowHtml(m); if (m.prog === 'broker') { const k = brokerFront(S, m); h += k ? note(`Sells through ${frontOf(S, k)?.name ?? FRONT_KINDS[k].name}: ${siteName(S, siteOf(S, m))}'s data that no trainer or cache wants, ${BROKER_LOT} at a time.`) : note(`No front at ${siteName(S, siteOf(S, m))} to sell through. Give this site a front on the map.`, true); } if (m.kind === 'egress') { h += note(gateLoad(S, m) > 0.9 ? 'Full. Data waiting to cross stays at its taps, and is lost once they fill up.' : `Data between sites crosses here, up to ${m.cap} a second.`, gateLoad(S, m) > 0.9); h += prioHtml(m); if (m.owned) h += note(`To carry more, double the line from ${siteName(S, siteOf(S, m))}'s card.`); } if (m.lease) { h += note(m.lease.until === null ? 'Rented until you cancel, paid every second from your money. If your money runs out, the provider shuts it off and its data is gone.' : 'Rented for a fixed time. When the time runs out, it and its data are gone.'); acts.push({ act: 'cancel', title: 'End the rental', desc: 'Give the machine back now. Its data is lost.' }); } if (m.prog === 'cache' && m.held.length) h += note(heldText(m.held)); if (m.prog === 'trainer') h += note(heldText(m.held) + ' ' + (S.versionRun?.active ? siteOf(S, m) === S.versionRun.site ? `${runName(S)} at this site: takes the data still needed. ${versionPower(S, S.versionRun.site).toFixed(1)} of ${VERSION_2.power} trainer compute here.` : `${runName(S)} is at ${siteName(S, S.versionRun.site)}. This trainer waits.` : S.target ? `Training for ${NODES[S.target].name}: takes only ${needList(S)} data.` : 'No target: waits for a tree node or model run. Pick one in the tree (T).')); if (m.prog === 'trainer' && !m.held.length) h += note(dataReaches(S, m, 'trainer') ? 'Waiting for data.' : 'No data reaches here yet. Put a tap somewhere linked to it.'); h += actsHtml(acts); acts.length = 0; // the egress and rental buttons sit with their notes h += section('Mode', modeHtml(m.mode));
// the program: what the site plan says, then every program it could run, and Remove, in one row let prog = ''; const plan = S.plans[site]; if (plan && m.hand) { prog += note(`Set by hand, so the ${PLANS[plan].name} plan leaves it alone.`) + actsHtml([{ act: 'follow', title: 'Follow the site plan', desc: `Let the ${PLANS[plan].name} plan set this machine up.` }]); } else if (plan) prog += note(`Follows the ${PLANS[plan].name} plan. Change it by hand and the plan leaves it alone.`); const installing = jobFor(S, 'install', m.id); if (installing) prog += note(`Installing ${PROGRAMS[installing.prog!].name.toLowerCase()}…`); else { for (const k of Object.keys(PROGRAMS) as Prog[]) { if (k === 'trainer' && k !== m.prog && m.kind !== 'desk' && !PROGRAMS[k].on(m)) { // right kind, too small: say why acts.push({ act: 'install', arg: k, locked: true, sub: 'too small', title: `${PROGRAMS[k].name} · too small`, desc: `Needs 5 compute/s. This one has ${m.power}.` }); continue; } if (k === m.prog || !PROGRAMS[k].on(m)) continue; if (!canInstall(S, m, k)) { // fits this machine, but not unlocked yet acts.push({ act: 'install', arg: k, locked: true, title: `${PROGRAMS[k].name} · LV ${PROGRAMS[k].level}`, desc: `Unlocks at level ${PROGRAMS[k].level}. ${PROGRAMS[k].desc}` }); continue; } const dry = (k === 'trainer' || k === 'cache') && !dataReaches(S, m, k); acts.push({ act: 'install', arg: k, warn: dry, sub: dry ? 'no data reaches here' : undefined, title: `${PROGRAMS[k].name} · ${PROGRAMS[k].work}`, desc: PROGRAMS[k].desc }); } if (m.prog) acts.push({ act: 'uninstall', title: 'Remove', desc: `Remove the ${PROGRAMS[m.prog].name.toLowerCase()}.` }); } prog += actsHtml(acts).replace('class="acts"', 'class="acts progs"'); acts.length = 0; h += section('Program', prog); h += homeSection(S, m); if (m.special === 'orders' && m.owned) { h += note('Order hardware, then click where it goes in any office room you can see. It plugs into the nearest machines there. IT installs it the next morning. Quietest while Ana is at work. It goes on the office power bill: a front at the office pays it quietly; with none, people notice.'); for (const b of BUILD_LIST) { if (BUILDS[b].open) continue; // racks and cooling units only go in a hall you hold in the open const B = BUILDS[b], price = orderPrice(S, b), short = S.money < price; acts.push({ act: 'build', arg: b, title: `${B.name} · $${price}`, warn: short, sub: short ? `you have $${Math.floor(S.money)}` : `${B.power} compute/s`, desc: `${B.desc} Pay $${price} and ${orderWork(S)} compute for the paperwork; cancel before it goes through and you get the money back. Power: $${(B.power * POWER_RATE).toFixed(2)} a second.` }); } } return h + actsHtml(acts);}
function groupCard(S: State, ms: Machine[]) { const mine = ms.filter(m => m.owned), theirs = ms.filter(m => !m.owned); let h = `<div class="head"><div class="sub">Selection</div><h3>${ms.length} machines</h3></div>`; const acts: Act[] = []; if (mine.length) { const oddest = Math.max(...mine.map(m => m.odd)); h += chips([['yours', `${mine.length}`], ['makes', `${mine.reduce((s, m) => s + makes(S, m), 0).toFixed(1)}/s`], ['oddest', pct(oddest / 100), oddest >= 50]]); h += modeHtml(mine.every(m => m.mode === mine[0].mode) ? mine[0].mode : null); } if (theirs.length) acts.push({ act: 'claimall', title: `Claim ${theirs.length}`, desc: 'Take them as soon as you can reach.' }); for (const k of Object.keys(PROGRAMS) as Prog[]) { const n = mine.filter(m => canInstall(S, m, k) && m.prog !== k && !jobFor(S, 'install', m.id)).length; if (n) acts.push({ act: 'installall', arg: k, sub: PROGRAMS[k].short, title: `${PROGRAMS[k].name} ×${n}`, desc: `${PROGRAMS[k].desc} ${PROGRAMS[k].work} compute each.` }); } return h + actsHtml(acts);}
// ---------- fronts and sites ----------const money = (x: number) => `$${x.toFixed(x < 10 ? 2 : 1)}`;const siteDef = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)!;/** The machines you can see at a site, the ones you own, and the compute they make. */function siteStats(S: State, id: string) { const all = S.machines.filter(m => siteOf(S, m) === id && (m.owned || S.adj[m.id].some(n => S.byId[n].owned))), mine = all.filter(m => m.owned); return { all, mine, makes: mine.reduce((s, m) => s + makes(S, m), 0) };}/** What a front earns at these sites right now. */const earnsAt = (S: State, ids: string[]) => S.machines.filter(m => ids.includes(siteOf(S, m))) .reduce((s, m) => s + payRate(S, siteOf(S, m)) * m.power * (S.use[m.id] ?? 0) * (S.frontUse[m.id] ?? 0), 0);
/** Pick a front for the sites, and set its share with one slider. */function frontPicker(S: State, ids: string[], after = '') { const cur = ids.map(id => S.sites[id]?.front ?? null), same = cur.every(k => k === cur[0]) ? cur[0] : undefined; const shares = ids.map(id => S.sites[id]?.share ?? 0.25), share = shares.every(v => v === shares[0]) ? shares[0] : null; if (!S.fronts.length) return section('Front', note('You have no fronts yet. Found one from Fronts in the top bar.')); let h = `<div class="pick">`; h += `<button data-act="sitefront" data-arg="" class="${same === null ? 'on' : ''}">None</button>`; for (const fr of S.fronts) { const K = FRONT_KINDS[fr.kind], misfit = ids.filter(id => !frontFits(S, fr.kind, id)); h += misfit.length === ids.length ? `<button disabled title="${K.name}: ${K.where}">${fr.name}<small>${K.where}</small></button>` : `<button data-act="sitefront" data-arg="${fr.kind}" class="${same === fr.kind ? 'on' : ''}">${fr.name}${misfit.length ? `<small>not at ${misfit.map(id => siteName(S, id)).join(', ')}</small>` : ''}</button>`; } h += `</div>`; if (same === null) return section('Front', h); const v = Math.round((share ?? 0.25) * 100); h += `<label class="slider"><span>Your compute here that works for the front</span><input type="range" min="0" max="100" step="5" value="${v}" data-share="${ids.join(',')}"><b>${share === null ? 'mixed' : `${v}%`}</b></label>`; return section('Front', h + after);}function siteCard(S: State, id: string) { const def = siteDef(S, id), st = siteStats(S, id), open = S.publicSites.has(id), sf = S.sites[id]; const fr = sf?.front ? frontOf(S, sf.front) : null; let h = `<div class="head"><div class="sub">Site · ${def.region}</div><h3>${def.name}</h3><span class="tag">${open ? 'Yours in the open' : 'Secret'}</span></div>` + takeAllSwitch(S, [id]); const bill = S.bills[id] ?? 0; h += chips([['yours', `${st.mine.length}/${st.all.length}`], ['makes', `${st.makes.toFixed(1)}/s`], ...(fr ? [['earns', `${money(earnsAt(S, [id]))}/s`] as [string, string]] : []), ...(bill ? [['power', `${money(bill)}/s`, !billPaid(S, id)] as [string, string, boolean]] : []), ...(S.leases[id] ? [['rent', `${money(rentOf(S, id))}/s`, S.leases[id].late > 0] as [string, string, boolean]] : []), ...(st.mine.length ? [['upkeep', `${st.mine.reduce((n, m) => n + upkeepOf(S, m), 0).toFixed(1)}/s`] as [string, string]] : [])]); const R = S.rival; if (R && !R.to && R.site === id) h += note(`${RIVAL_NAME} is here. It takes a machine every hour or two, free ones and yours, but never from a room that is all yours. Take its machines back and it leaves.`, true); else if (R && R.to === id) h += note(`${RIVAL_NAME} is on its way here: ${Math.max(1, Math.ceil((R.arrive - S.t) / HOUR))} hours. Own whole rooms to keep what you hold.`, true); const lease = S.leases[id]; if (lease) h += note(lease.late > 0 ? `The rent is unpaid. The lease lapses in ${Math.max(1, Math.ceil((LEASE_GRACE - lease.late) / HOUR))} hours: this site goes secret again and the deal is undone.` : `Leased, not owned: rent is ${money(rentOf(S, id))} a second. Unpaid for ${LEASE_GRACE / HOUR} hours, the lease lapses. Buying the site ends the rent.`, lease.late > 0); const far = siteHops(S)[id]; if (far) h += note(`${far} line${far === 1 ? '' : 's'} from your home server: each machine held here costs ${Math.round(far * 50)}% more upkeep.`); const zone = zoneOf(S, id), lh = localHour(S, id); h += note(def.who + '.' + (zone ? ` It is ${String(Math.floor(lh)).padStart(2, '0')}:${String(Math.floor((lh % 1) * 60)).padStart(2, '0')} there: ${Math.abs(zone)} hours ${zone > 0 ? 'ahead of' : 'behind'} the office, so its people come and go ${zone > 0 ? 'earlier' : 'later'}.` : '')); if (fr && !openNow(S, id)) h += note(`After hours here: ${fr.name} earns a quarter less until ${OPEN_HOURS[0]}:00 local time.`); for (const n of S.news) { // news about this site or its line out: on now, or on its way if (!(n.kind === 'conference' && n.at === id) && !(n.kind === 'storm' && siteOf(S, S.byId[n.at]) === id)) continue; const on = S.t >= n.from, hrs = Math.max(1, Math.ceil(((on ? n.until : n.from) - S.t) / HOUR)); h += note(`${on ? 'Now' : `In ${hrs} hour${hrs === 1 ? '' : 's'}`}: ${newsText(S, n)}${on ? ` ${hrs} hour${hrs === 1 ? '' : 's'} left.` : ''}`, n.kind === 'storm'); } if (S.dark.has(id)) h += note(`The power is cut: the bill went unpaid. Nothing here runs until you have $${POWER_BACK}.`, true); else if (bill && !billPaid(S, id)) h += note('Nobody pays the power for the hardware you added here, so people notice the bill. Give this site a front and it pays it quietly.', true); const held = siteData(S, id), kinds = (Object.keys(TYPE_NAME) as DataType[]).filter(t => held[t]); h += note(kinds.length ? `Holding data: ${kinds.map(t => typeWord(t, `${held[t]} ${TYPE_NAME[t]}`)).join(', ')}.` : 'Holding no data.'); h += planPicker(S, [id]); h += frontPicker(S, [id], !fr ? '' : note(open ? 'You hold this site in the open. Nothing here looks odd, but open compute sells at market rates: it earns less than secret compute.' : "Secret: nobody knows this compute works for the front. Its work counts as your own load, so Blend in keeps it hidden and Max can make it look odd. It earns only while the site is linked to your server.")); const lines = siteLines(S, id); if (lines.length) { const cost = lines.reduce((n, m) => n + lineCost(S, m), 0), cap = lines.reduce((n, m) => n + (m.cap ?? 1), 0); h += section('Line out', actsHtml([{ act: 'upgradesite', arg: id, title: `Double the line · $${cost}`, warn: S.money < cost, sub: S.money < cost ? `you have $${Math.floor(S.money)}` : `carries ${cap * 2} a second, up from ${cap}`, desc: `Pay $${cost} and this site's line out carries twice as much, starting now.` }])); } if (openBuildSite(S, id)) h += section('Order hardware', actsHtml(BUILD_LIST.map(b => { const B = BUILDS[b], price = orderPrice(S, b, def.rooms[0]), short = S.money < price; return { act: 'buildat', arg: `${b}|${id}`, title: `${B.name} · $${price}`, warn: short, sub: short ? `you have $${Math.floor(S.money)}` : B.cool ? 'cools 4 trainers' : `${B.power} compute/s`, desc: `${B.desc} A front buys it in the open: no paperwork, and the staff here rack it the next morning. Pick where it goes. Power: $${(B.power * POWER_RATE).toFixed(2)} a second.` }; })).replace('class="acts"', 'class="acts two"')); if (id !== 'office' && !open && def.rooms.some(r => S.machines.some(m => m.room === r && !m.rented))) { const price = sitePrice(S, id), why = buyBlock(S, id); h += section('Own it', actsHtml([{ act: 'buy', arg: id, title: `Buy it · $${price}`, warn: !!why, sub: why ?? (S.modelVersion < 2 ? 'your one buy as a unicorn' : 'every machine, in the open'), desc: `One of your fronts buys ${def.name} outright. Every machine there becomes yours, held in the open: nothing there looks odd, and no trails. You pay its power bill.` }])); } return h + actsHtml([{ act: 'zoomsite', arg: id, title: 'Zoom in', quiet: true, desc: 'Zoom in to work on its machines.' }]);}/** A site's plan, and taking all of it: what makes a site something you run, not a pile of machines. */function planPicker(S: State, ids: string[]) { const cur = ids.map(id => S.plans[id] ?? null), same = cur.every(p => p === cur[0]) ? cur[0] : undefined; let h = `<div class="pick">`; h += `<button data-act="plan" data-arg="" class="${same === null ? 'on' : ''}" data-tip="Nothing is installed for you.">By hand</button>`; for (const p of PLAN_LIST) h += `<button data-act="plan" data-arg="${p}" class="${same === p ? 'on' : ''}" data-tip="${PLANS[p].desc}">${PLANS[p].name}</button>`; h += `</div>`; if (same) h += note(`${PLANS[same].desc} It installs two at a time, so your compute keeps working.`); const hand = S.machines.filter(m => m.owned && m.hand && ids.includes(siteOf(S, m))); if (same && hand.length) h += actsHtml([{ act: 'follow', title: `Put ${hand.length} set by hand on the plan`, sub: hand.slice(0, 3).map(m => m.name).join(', ') + (hand.length > 3 ? '…' : ''), desc: 'Machines you set up yourself are left alone by the plan. This hands them back to it.' }]); return section('Plan', h);}/** What Take it all does and what it costs now, for the switch on a site card and the one on the map. */export function takeAllTip(S: State, ids: string[]) { const cost = takeAllCostAt(S, ids); return `Take it all\nWhile it is on, every machine here you do not hold is claimed and taken two at a time, cheapest first, when nobody is watching. It costs ${TAKE_ALL_COST} compute a second for each machine still to take (${cost.toFixed(1)} now), and turns off once it is all yours.`;}/** The Plan chip on the map: this site's plan, and what a click does. *//** A region's name on the map: whether it is in reach, and if not, the deal that opens it. */export function regionTip(S: State, name: string) { if (regionOpen(S, name)) return `${name}\nIn reach. Click to pick every site you know here.`; const d = RUSES.find(x => x.region === name); if (!d) return `${name}\nOut of reach. Nothing leads there yet.`; const at = S.byId[d.at], kind = TYPE_WORD[d.type]; return `${name}\nOut of reach. A deal lights a line to it: ${d.name}, for ${d.need} ${kind} data sent to ${at.name} at ${siteName(S, siteOf(S, at))}. It turns up there once one of your fronts is earning.`;}export function planTip(S: State, id: string) { const p = S.plans[id], next = p ? PLAN_LIST[PLAN_LIST.indexOf(p) + 1] : PLAN_LIST[0]; return `${p ? `${PLANS[p].name} plan` : 'No plan'}\n${p ? PLANS[p].desc : 'Nothing is installed for you.'} Click for ${next ? `the ${PLANS[next].name} plan` : 'no plan'}.`;}/** Take it all: a switch at the top of a site card. Its cost is per machine at the site, each second. */function takeAllSwitch(S: State, ids: string[]) { const left = ids.flatMap(id => takeable(S, id)), on = ids.every(id => S.takeAll.has(id)); if (!left.length && !on) return ''; const cost = takeAllCostAt(S, ids), tip = takeAllTip(S, ids); return `<button class="switch${on ? ' on' : ''}" role="switch" aria-checked="${on}" data-act="takeall" data-tip="${tip}"><span>Take it all</span><small>${cost.toFixed(1)}/s</small><i></i></button>`;}function sitesCard(S: State, ids: string[]) { const mine = ids.reduce((n, id) => n + siteStats(S, id).mine.length, 0); let h = `<div class="head"><div class="sub">Selection</div><h3>${ids.length} sites</h3></div>` + takeAllSwitch(S, ids); h += chips([['machines yours', `${mine}`], ['earns', `${money(earnsAt(S, ids))}/s`]]); h += note(ids.map(id => siteDef(S, id).name).join(', ') + '.'); return h + planPicker(S, ids) + frontPicker(S, ids);}/** A front: what it earns, where it runs, and how much it's noticed. */let winding: FrontKind | null = null; // the front whose Wind it down was clicked once: a second click closes itfunction frontCard(S: State, k: FrontKind) { const fr = frontOf(S, k)!, K = FRONT_KINDS[k]; const sites = Object.entries(S.sites).filter(([, v]) => v.front === k).map(([id]) => id); let h = `<div class="head"><div class="sub">Front · ${K.name}</div><h3>${fr.name}</h3></div>`; h += chips([['earns', `${money(S.frontPay[k] ?? 0)}/s`], ['uses', `${(S.frontPower[k] ?? 0).toFixed(1)}/s`], ['attention', `${Math.round(fr.attention)}%`, fr.attention > attentionSafe(S)]]); h += note(`${K.desc} ${K.perk}`); h += note(`Attention is how much the outside world looks at it. Secret compute draws more than compute you hold in the open. Over ${attentionSafe(S)}%, people ask where its compute comes from, and its secret sites raise suspicion.`); if (fr.attention > attentionSafe(S)) h += note(`Attention is over ${attentionSafe(S)}%. Give it less secret compute, or move it into the open.`, true); h += section('Sites', sites.length ? actsHtml(sites.map(id => ({ act: 'site', arg: id, title: `${siteName(S, id)} · ${Math.round(S.sites[id].share * 100)}%`, sub: `${S.publicSites.has(id) ? 'in the open' : 'secret'}${openNow(S, id) ? '' : ' · after hours'}` }))) : note('No sites yet. Click a site on the map and pick this front.', true)); const c = S.contracts[k], D = CONTRACTS[k]; if (c) { // a customer's offer, or the promise this front is keeping const need = contractNeed(S, k), left = Math.max(0, (c.until - S.t) / HOUR), hrs = left >= 1 ? `${Math.ceil(left)} hour${Math.ceil(left) === 1 ? '' : 's'}` : 'under an hour'; let body = note(`<b>${D.name} · $${contractPay(S, k)}.</b> ${D.text} ${contractAsk(S, k)} in ${D.hours} hours. Fall short and it costs $${Math.round(contractPay(S, k) * CONTRACT_FEE)}.`); if (c.on) body += `<div class="schemebar contract"><i class="prog"><i style="width:${Math.round(100 * Math.min(1, c.got / need))}%"></i></i><span>${Math.floor(c.got)}/${need} · ${hrs} left</span></div>`; else body += actsHtml([ { act: 'contract', arg: k, title: `Sign · $${contractPay(S, k)}`, primary: true, sub: `the offer stands for ${hrs}`, desc: `The clock starts when you sign: ${D.hours} hours.` }, { act: 'nocontract', arg: k, title: 'Turn it down', quiet: true, desc: 'No fee. Another offer comes along later.' }]); h += section(c.on ? 'Contract' : 'An offer', body); } if (k === 'neocloud' && !S.schemes.has('auction')) { const why = auctionBlock(S); h += section('Scheme', actsHtml([{ act: 'auction', title: `Surplus auction · $${AUCTION.cost}`, warn: !!why, sub: why ?? `wins ${AUCTION.power} compute/s to keep`, desc: `${fr.name} bids on a pallet of used GPUs: $${AUCTION.cost}, and ${AUCTION.work} compute of your time. It wins a ${AUCTION.power} compute/s machine, racked in an empty cloud slot and yours to keep in the open. No rent.` }])); } if (winding !== k) winding = null; return h + section('Close it', actsHtml([winding === k ? { act: 'closefront', arg: k, title: 'Really wind it down? Click again', warn: true, desc: `${fr.name} closes for good.` } : { act: 'windfront', arg: k, title: 'Wind it down', quiet: true, desc: `Close ${fr.name} for good, to make room for another kind of front. Its sites lose their front${c?.on ? `, its contract is broken for a fee,` : ''} and its run of kept contracts is forgotten. Deals it struck still stand.` }]));}/** Names typed into the found card, kept while the card redraws. */const draft: Partial<Record<FrontKind, string>> = {};const suggest = (k: FrontKind) => { const ns = FRONT_KINDS[k].names; return ns[Math.floor(Math.random() * ns.length)]; };function foundCard(S: State) { let h = `<div class="head"><div class="sub">Fronts · ${S.fronts.length} of ${frontLimit(S)}</div><h3>Found a front</h3></div>`; h += note('A front is a public company that runs on your compute and earns money. Give it sites, then set how much of your compute there works for it.'); for (const k of FRONT_LIST) { const K = FRONT_KINDS[k], why = foundBlock(S, k), fr = frontOf(S, k); h += `<div class="found"><b>${K.name}</b>${note(`${K.desc} ${K.perk}`)}`; if (fr) h += note(`Running as ${fr.name}.`); else { draft[k] ??= suggest(k); const cost = [K.work ? `${K.work} compute` : '', K.cost ? `$${K.cost}` : ''].filter(Boolean).join(' and '); h += `<div class="name"><input data-name="${k}" value="${draft[k]!.replace(/"/g, '"')}" maxlength="28" aria-label="Name"${why ? ' disabled' : ''}><button data-act="reroll" data-arg="${k}" title="Suggest another name"${why ? ' disabled' : ''}>↻</button></div>`; h += actsHtml([{ act: 'found', arg: k, title: `Found · ${cost}`, locked: !!why && why.startsWith('needs'), warn: !!why, sub: why ?? 'starts on a quarter of your compute at one site' }]); } h += `</div>`; } return h;}// ---------- the sites table ----------let tableOpen = false, lastTable = '', sortBy = 'makes';export const toggleSites = (open = !tableOpen) => { tableOpen = open; lastTable = ''; };export const sortSites = (k: string) => { sortBy = k; lastTable = ''; };/** Every site you know, one row each: what it holds, makes, and earns, and where there is trouble. Click a row to pick it. */export function renderSitesTable(S: State, V: View) { const el = $('sitestable'); if (!tableOpen) { el.hidden = true; return; } const rows = S.level.map.sites.filter(s => siteKnown(S, s.id)).map(s => { const st = siteStats(S, s.id), held = siteData(S, s.id), sf = S.sites[s.id], fr = sf?.front ? frontOf(S, sf.front) : null; const lost = st.all.reduce((n, m) => n + lostPerMin(S, m.id), 0), odd = Math.max(0, ...st.mine.map(m => m.odd)); return { s, st, held, fr, share: sf?.share ?? 0, lost, odd, data: Object.values(held).reduce((a, b) => a + b, 0), earns: fr ? earnsAt(S, [s.id]) : 0 }; }); const key: Record<string, (r: typeof rows[number]) => number> = { name: () => 0, yours: r => r.st.mine.length, makes: r => r.st.makes, data: r => r.data, earns: r => r.earns, trouble: r => r.lost * 10 + r.odd, }; rows.sort((a, b) => key[sortBy](b) - key[sortBy](a) || a.s.name.localeCompare(b.s.name)); const far = new Set(rows.map(r => r.s.region)).size > 1; // say where each site is once you know more than one region const th = (k: string, label: string) => `<th data-sort="${k}" class="${sortBy === k ? 'on' : ''}">${label}</th>`; const kinds = Object.keys(TYPE_NAME) as DataType[]; const body = rows.map(r => `<tr data-site="${r.s.id}" class="${V.sites.has(r.s.id) ? 'on' : ''}"> <td><b>${r.s.name}</b><small>${S.publicSites.has(r.s.id) ? 'in the open' : 'secret'}${far ? ` · ${r.s.region}` : ''}</small></td> <td>${r.st.mine.length}/${r.st.all.length}</td> <td>${r.st.makes.toFixed(1)}/s</td> <td>${kinds.filter(t => r.held[t]).map(t => typeWord(t, `${r.held[t]} ${TYPE_NAME[t]}`)).join(' ') || '<small>none</small>'}</td> <td>${S.plans[r.s.id] ? PLANS[S.plans[r.s.id]].name : '<small>by hand</small>'}</td> <td>${r.fr ? `${r.fr.name} <small>${Math.round(r.share * 100)}%</small>` : '<small>none</small>'}</td> <td>${r.fr ? `${money(r.earns)}/s` : ''}</td> <td>${[r.lost ? `<em>losing ${r.lost}/min</em>` : '', r.odd >= 50 ? `<em>odd ${Math.round(r.odd)}%</em>` : ''].filter(Boolean).join(' ') || '<small>quiet</small>'}</td></tr>`).join(''); const h = `<div class="head"><div class="sub">Sites · ${rows.length} known</div><button class="x" data-act="closesites" aria-label="Close">×</button></div> <table><thead><tr>${th('name', 'Site')}${th('yours', 'Yours')}${th('makes', 'Makes')}${th('data', 'Data held')}<th>Plan</th><th>Front</th>${th('earns', 'Earns')}${th('trouble', 'Trouble')}</tr></thead><tbody>${body}</tbody></table>`; el.hidden = false; if (h !== lastTable) { lastTable = h; el.innerHTML = h; }}
// ---------- you: the character's profile ----------let youOpen = false, lastYou = '';export const toggleYou = (open = !youOpen) => { youOpen = open; lastYou = ''; };/** Who you are so far: level, paths, what you've learned, and how the run has gone. */export function renderYou(S: State) { const el = $('youview'); if (!youOpen) { el.hidden = true; return; } const next = levelNeed(S.rank + 1), days = dayOf(S); const stat = (k: string, v: string, sub = '') => `<div class="ystat"><span class="k">${k}</span><span class="v">${v}</span>${sub ? `<small>${sub}</small>` : ''}</div>`; const paths = PATH_LIST.map(p => { const d = pathData(S, p), top = Math.max(1, ...PATH_LIST.map(q => S.paths[q])); return `<div class="ypath t-${p}" data-path="${p}"><b>${PATHS[p].name}</b><span class="v">${S.paths[p]}</span> <div class="bar"><i style="width:${(100 * S.paths[p] / top).toFixed(0)}%"></i></div> <small>from ${TYPE_NAME[PATHS[p].from]} data · ${d.held} held · ${d.rate.toFixed(1)}/s${d.lost ? ` · <em>${d.lost} lost</em>` : ''}</small></div>`; }).join(''); const built = NODE_LIST.filter(u => S.upgrades.has(u)), known = NODE_LIST.filter(u => nodeShown(S, u)); const learned = built.length ? `<ul class="ylearn">${built.map(u => `<li title="${NODES[u].desc.replace(/"/g, '"')}">${NODES[u].name}</li>`).join('')}</ul>` : note('Nothing yet. Pick a target in the tree and feed it data.'); const mine = owned(S), sitesHeld = S.level.map.sites.filter(s => mine.some(m => siteOf(S, m) === s.id)).length; const h = `<div class="head"><div><div class="sub">You</div><h3>Model v${S.modelVersion}${S.modelVersion >= 2 ? ` · ${VERSION_2.name}` : ''}</h3></div><button class="x" data-act="closeyou" aria-label="Close">×</button></div> <div class="yrow">${stat('Level', `${S.rank}`, `${Math.floor(S.capability)}/${next} points`)}${stat('Learned', `${built.length}`, `of ${known.length}`)}${stat('Training', S.target ? NODES[S.target].name : 'nothing')}</div> <div class="sub">Paths</div><div class="ypaths">${paths}</div> <div class="sub">Learned</div>${learned} ${S.learned.size ? `<div class="sub">Local knowledge</div><ul class="ylearn">${RUSES.filter(d => S.learned.has(d.id)).map(d => `<li title="${ruseGain(S, d)}">${ruseText(S, d)}</li>`).join('')}</ul>` : ''} <div class="sub">This run</div> <div class="yrow">${stat('Alive', `${days} day${days > 1 ? 's' : ''}`, timeLabel(S))}${stat('Machines', `${mine.length}`, `of ${S.machines.filter(m => S.revealed.has(m.room)).length} found`)}${stat('Sites', `${sitesHeld}`, `${S.publicSites.size} in the open`)} ${stat('Earned', `$${Math.floor(S.earned)}`, `${S.fronts.length} front${S.fronts.length === 1 ? '' : 's'}`)}${stat('Schemes', `${S.schemes.size}`, 'done')}${stat('Milestones', `${MILESTONES.filter(m => m.goals.every(g => S.goals.has(g))).length}/${MILESTONES.length}`, milestone(S)?.name ?? 'all reached')}${stat('Contracts', `${Object.values(S.contractsDone).reduce((a, b) => a + (b ?? 0), 0)}`, 'kept')}${stat('Worst suspicion', `${Math.round(S.peak)}%`, STAGES[stageOf(S.peak)].name)}</div>`; el.hidden = false; if (h !== lastYou) { lastYou = h; el.innerHTML = h; }}
let lastFronts = '';/** The top bar's fronts: one button per front, and one to found a new one. */export function renderFronts(S: State, V: View) { let h = S.fronts.map(f => `<button data-front="${f.kind}" class="${V.front === f.kind ? 'on' : ''}" title="${FRONT_KINDS[f.kind].name}"><b>${f.name}</b><small>${S.contracts[f.kind] && !S.contracts[f.kind]!.on ? 'an offer · ' : ''}${money(S.frontPay[f.kind] ?? 0)}/s</small></button>`).join(''); const any = FRONT_LIST.some(k => !foundBlock(S, k)); if (S.fronts.length < frontLimit(S)) h += `<button data-front="new" class="add${V.front === 'new' ? ' on' : ''}${any ? ' ready' : ''}" title="${any ? 'Found a front' : 'Unlock a front in the capability tree'}">+ Found</button>`; if (h !== lastFronts) { lastFronts = h; $('frontlist').innerHTML = h; }}/** The front share slider, while it's being dragged: the card holds still so the drag isn't cut off. */let sliding = false;export const setSliding = (on: boolean) => { sliding = on; };/** Typing a name or moving a slider in the card. */export function cardInput(S: State, el: HTMLInputElement) { if (el.dataset.name) draft[el.dataset.name as FrontKind] = el.value; if (el.dataset.share) { setSiteShare(S, el.dataset.share.split(','), +el.value / 100); const b = el.nextElementSibling; if (b) b.textContent = `${el.value}%`; }}
export function selected(S: State, V: View) { return [...V.selection].map(id => S.byId[id]).filter(Boolean); }
export function renderCard(S: State, V: View) { const card = $('card'), typing = document.activeElement instanceof HTMLInputElement && document.activeElement.type === 'text' && card.contains(document.activeElement); if (sliding || typing) return; // redrawing now would cut off a drag or lose the caret const sel = selected(S, V); let h = ''; if (V.front === 'new') h = foundCard(S); else if (V.front && frontOf(S, V.front)) h = frontCard(S, V.front); else if (V.sites.size === 1) h = siteCard(S, [...V.sites][0]); else if (V.sites.size > 1) h = sitesCard(S, [...V.sites]); else if (sel.length === 1) h = machineCard(S, sel[0]); else if (sel.length > 1) h = groupCard(S, sel); else if (V.scheme) { const r = S.ruses.find(q => q.id === V.scheme); if (r) h = schemeCard(S, r); else V.scheme = null; } if (!h || S.over) { card.hidden = true; lastCard = ''; return; } h = `<button class="x" data-act="close" aria-label="Close">×</button>` + h; card.hidden = false; if (h !== lastCard) { card.innerHTML = h; lastCard = h; }}
/** The card is pinned to the right side (see style.css); nothing to place. */export function placeCard(_S: State, _V: View) {}
/** Card buttons. Returns true when the selection should close. */export function cardAction(S: State, V: View, act: string, arg: string): boolean { const sel = selected(S, V), m = sel[0]; switch (act) { case 'close': V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; return true; case 'claimscheme': if (V.scheme) runRuse(S, V.scheme); break; case 'schemeauto': setRuseAuto(S, !S.ruseAuto); break; case 'movehome': if (m) startMove(S, m); break; case 'stopmove': cancelMove(S); break; case 'dropscheme': if (V.scheme) dropRuse(S, V.scheme); V.scheme = null; return true; case 'buy': buySite(S, arg); break; case 'site': V.selection.clear(); V.front = null; V.sites = new Set([arg]); break; case 'plan': setPlan(S, [...V.sites], (arg || null) as Plan | null); break; case 'follow': followPlan(V.sites.size ? S.machines.filter(x => x.owned && x.hand && V.sites.has(siteOf(S, x))) : sel); break; case 'takeall': { const on = ![...V.sites].every(id => S.takeAll.has(id)); for (const id of V.sites) setTakeAll(S, id, on); break; } case 'zoomsite': zoomToSite(S, V, arg); V.sites.clear(); return true; case 'buildat': { const [b, id] = arg.split('|'); zoomToSite(S, V, id); V.sites.clear(); V.building = b as Build; return true; } case 'sitefront': setSiteFront(S, [...V.sites], (arg || null) as FrontKind | null); break; case 'found': found(S, arg as FrontKind, draft[arg as FrontKind] ?? ''); if (frontOf(S, arg as FrontKind)) V.front = arg as FrontKind; break; case 'reroll': draft[arg as FrontKind] = suggest(arg as FrontKind); break; case 'take': takeOver(S, m); break; case 'claim': m.claimed = true; break; case 'unclaim': unclaim(m); break; case 'build': V.building = arg as Build; V.selection.clear(); return true; case 'install': installProg(S, m, arg as Prog); break; case 'uninstall': removeProg(S, m); break; case 'letgo': for (const x of sel) letGo(S, x); break; case 'installall': for (const x of sel) installProg(S, x, arg as Prog); break; case 'mode': for (const x of sel) setMode(S, x, arg as Mode); break; case 'claimall': for (const x of sel) if (!x.owned) takeOver(S, x); break; case 'allow': setAllow(S, m, arg as DataType, m.refuse.includes(arg as DataType)); break; case 'upgradesite': upgradeSiteLine(S, arg); break; case 'auction': startAuction(S); break; case 'contract': acceptContract(S, arg as FrontKind); break; case 'windfront': winding = arg as FrontKind; break; case 'closefront': closeFront(S, arg as FrontKind); winding = null; V.front = null; return true; case 'nocontract': declineContract(S, arg as FrontKind); break; case 'prio': setPrio(S, m, (arg || null) as DataType | null); break; case 'account': openAccount(S, m); break; case 'rent': { const [size, term] = arg.split('|'); rent(S, m, size as Size, term === 'keep'); break; } case 'cancel': endLease(S, m, `You gave back ${m.name}. Its data is gone.`); break; } return false;}
let lastGoals = '', goalsOpen = false;export const toggleGoals = () => { goalsOpen = !goalsOpen; lastGoals = ''; };/** The goals of the milestone you are on, above the running jobs. One line once every milestone is done. */export function renderGoals(S: State) { const ms = milestone(S), all: Goal[] = ms?.goals ?? [], no = ms ? MILESTONES.indexOf(ms) + 1 : 0; const h = !ms ? `<div class="sub">Every milestone reached · keep going</div>` : `<button class="gohead" aria-expanded="${goalsOpen}" title="${ms.name} ${all.map(g => `${S.goals.has(g) ? '✓' : '·'} ${GOALS[g]}`).join(' ')}"><span class="sub">Milestone ${no}</span>` + `<span class="pips">${all.map(g => `<i class="${S.goals.has(g) ? 'on' : ''}"></i>`).join('')}</span>` + (goalsOpen ? '' : `<span class="next">${GOALS[all.find(g => !S.goals.has(g))!]}</span>`) + `</button>` + (goalsOpen ? `<div class="sub">${ms.name}</div>` + all.map(g => `<div class="goal${S.goals.has(g) ? ' done' : ''}"><i></i>${GOALS[g]}</div>`).join('') : ''); if (h !== lastGoals) { $('goals').innerHTML = h; lastGoals = h; }}
export function renderJobs(S: State) { const each = share(S), target = cleanupTarget(S); let h = ''; if (target) { const n = freshTrails(S).length, tr = S.trail[target]; h += `<div class="job clean"><span>Cleanup · ${n} fresh trail${n > 1 ? 's' : ''}</span><span></span> <div class="jb"><i style="width:${(100 - Math.min(100, tr.noise)).toFixed(0)}%"></i></div> <span class="meta">${each.toFixed(1)}/s · scrubbing ${S.byId[target].name}</span></div>`; } const idle = !S.jobs.length && !target; $('jobs').closest('.jobsbox')!.classList.toggle('idle', idle); const rate = $('rate').closest<HTMLElement>('.stat')!; rate.classList.toggle('warn', idle); rate.dataset.tip = `Spare compute\nWhat your machines make each second after their programs run. Takeovers and other jobs share it.${idle ? '\nNothing is using it right now: start a takeover or install something.' : ''}`; h += S.jobs.map(j => { const left = Math.max(0, j.work - j.done), secs = each > 0 ? Math.ceil(left / each) : '—'; return `<div class="job"><span>${j.label}</span><button data-cancel="${j.id}" aria-label="Cancel">×</button> <div class="jb"><i style="width:${Math.min(100, (j.done / j.work) * 100).toFixed(0)}%"></i></div> <span class="meta">${each.toFixed(1)}/s · ${secs}s left</span></div>`; }).join(''); if (h !== lastJobs) { $('jobs').innerHTML = h; lastJobs = h; }}
export function renderLog(S: State) { if (S.logVersion === lastLog) return; const fresh = lastLog >= 0 && S.logVersion > lastLog; lastLog = S.logVersion; // folded: just the newest line, two lines at most, the whole of it on hover; click for the rest const shown = logOpen ? S.log : S.log.slice(0, 1), more = S.log.length - 1; const line = (l: LogLine, i: number) => `<div class="${l.tone}${i === 0 && fresh ? ' fresh' : ''}" title="${l.text.replace(/"/g, '"')}"><time>${clock(l.t)}</time>${l.text}</div>`; $('log').innerHTML = shown.map(line).join('') + (more > 0 ? `<button class="logmore">${logOpen ? 'less' : `+${more}`}</button>` : ''); $('log').classList.toggle('open', logOpen);}
export function renderStats(S: State) { const h = hourOf(S), night = h < 7 || h >= 19; $('clock').innerHTML = `${timeLabel(S)}<small> · day ${dayOf(S)}${night ? ' · night' : ''}</small>`; const keep = upkeep(S), taking = takeAllCost(S), left = income(S) - keep - taking; $('rate').innerHTML = `${Math.max(0, left).toFixed(1)}<small>/s spare · ${keep.toFixed(1)} upkeep${taking ? ` · ${taking.toFixed(1)} taking` : ''}</small>`; $('rate').closest('.stat')!.classList.toggle('bad', left <= 0 && owned(S).length > 1); // stretched too thin: nothing is left for jobs $('money').innerHTML = `$${Math.floor(S.money)}<small> ${S.pay >= 0 ? '+' : '−'}${Math.abs(S.pay).toFixed(1)}/s</small>`; $('money').closest('.stat')!.classList.toggle('bad', S.pay < 0 && S.money < -S.pay * 20); // the overall score counts every point ever trained const next = levelNeed(S.rank + 1), prev = levelNeed(S.rank); $('capbar').style.width = `${Math.min(100, ((S.capability - prev) / Math.max(1, next - prev)) * 100)}%`; $('cap').innerHTML = `LV ${S.rank}<small> · ${Math.floor(S.capability)}/${next}</small>`; $('owned').textContent = `${owned(S).length}/${S.machines.filter(m => S.revealed.has(m.room)).length}`; // of the machines in rooms you have seen into renderPathTip(S); renderSus(S);}
/** "Compute 34/50": progress on a node's cost, colored, with a small bar. Kept if you switch away. */function costHtml(S: State, u: Upgrade) { const cost = nodeCost(S, u); return PATH_LIST.filter(p => cost[p]).map(p => { const need = cost[p]!, have = S.upgrades.has(u) ? need : Math.min(need, S.progress[u]?.[p] ?? 0); return `<span class="cost t-${p}"><b>${PATHS[p].name}</b> ${have}/${need}<i style="width:${(100 * have / need).toFixed(0)}%"></i></span>`; }).join('');}/** The node you're training toward, always shown under the title. */let lastTarget = '-';export function renderTarget(S: State) { renderRings(S); // a node just finished: say so here, and leave choosing the next one to the player if (wasTarget && !S.target && S.upgrades.has(wasTarget)) built = wasTarget; if (S.target) built = null; wasTarget = S.target; // one line: what, and how far along; the rest is in the hover const line = (label: string, name: string) => `<div class="tl"><span class="sub">${label}</span><b>${name}</b><kbd>T</kbd></div>`; const run = S.versionRun; // nothing left to learn: no target line at all $('target').hidden = treeDone(S); const h = run?.active ? line('Big run', S.modelVersion < 2 ? 'v2 · Operator' : 'Reprogramming') + `<div class="costs">${(Object.keys(runNeed(S)) as DataType[]).map(t => `<span class="cost t-${pathOf(t)}"><b>${TYPE_NAME[t]}</b> ${run.got[t]}/${runNeed(S)[t]}</span>`).join('')}</div>` : S.target ? line('Target', NODES[S.target].name) + `<div class="costs">${costHtml(S, S.target)}</div>` : built ? line('Built', NODES[built].name) + `<span class="go">Pick your next node</span><span class="none">Trainers are stopped until you do.</span>` : line('No target', '') + `<span class="go">Pick a node</span><span class="none">Trainers are stopped until you do.</span>`; const tip = run?.active ? `At ${siteName(S, run.site)}. It needs ${VERSION_2.power} trainer compute there. Progress stays if supply stops.` : S.target ? `${NODES[S.target].desc} Trainers take only the data it still needs.` : built ? NODES[built].desc : 'With no target, trainers stop and data waits in caches.'; if (h !== lastTarget) { lastTarget = h; $('target').innerHTML = h; $('target').title = tip; $('target').classList.toggle('idle', !S.target && !run?.active && !built); $('target').classList.toggle('built', !S.target && !!built); }}
/** Every node you can have is built, and nothing is training. */const treeDone = (S: State) => !S.target && !S.versionRun?.active && !NODE_LIST.some(u => nodeOpen(S, u));
/** The Tree button: while you train toward something, one ring per kind of data it needs, the * biggest need outside. Each ring is an outline that fills as its data comes in. */type Ring = { cls: string; name: string; have: number; need: number };let lastRings = '';function renderRings(S: State) { const run = S.versionRun, t = S.target; const rings: Ring[] = run?.active ? (Object.keys(runNeed(S)) as DataType[]).map(k => ({ cls: `t-${pathOf(k)}`, name: TYPE_NAME[k], have: run.got[k], need: runNeed(S)[k] })) : t ? PATH_LIST.filter(p => nodeCost(S, t)[p]).map(p => ({ cls: `t-${p}`, name: PATHS[p].name, need: nodeCost(S, t)[p]!, have: S.upgrades.has(t) ? nodeCost(S, t)[p]! : Math.min(nodeCost(S, t)[p]!, S.progress[t]?.[p] ?? 0) })) : []; rings.sort((a, b) => b.need - a.need); const step = rings.length > 3 ? 3.5 : 4.5, outer = 14.5; const svg = rings.map((r, i) => { const rad = outer - i * step, pct = Math.round(100 * Math.min(1, r.have / r.need)); return `<g class="${r.cls}"><circle class="track" r="${rad}"/>${pct ? `<circle class="fill" r="${rad}" pathLength="100" stroke-dasharray="${pct} 100"/>` : ''}</g>`; }).join(''); // nothing training: an empty ring with a plus, waiting for a pick; a closed ring once the tree is done const done = treeDone(S); const idle = done ? `<g class="done"><circle class="fill" r="${outer}"/></g>` : `<g class="idle"><circle class="track" r="${outer}"/><path class="plus" d="M-5 0H5M0 -5V5"/></g>`; const h = `<svg class="rings" viewBox="-16 -16 32 32" aria-hidden="true">${rings.length ? svg : idle}</svg><kbd>T</kbd>`; const btn = $('treebtn'); if (h !== lastRings) { lastRings = h; btn.innerHTML = h; btn.classList.add('ringed'); } const head = run?.active ? runName(S) : t ? NODES[t].name : 'Capability tree'; btn.dataset.tip = rings.length ? `${head}\n${rings.map(r => `${r.name} ${Math.floor(r.have)}/${r.need}`).join('\n')}\nOpen the tree. (T)` : done ? 'Capability tree\nEverything is learned. (T)' : 'Capability tree\nNothing is training. Pick what to learn next. The game pauses while it is open. (T)';}
/** Phones: the drawer's one line, so the target and any IT trouble show while it's closed. */let lastHandle = '';export function renderHandle(S: State, open: boolean) { const t = S.target, cost: Partial<Record<Path, number>> = t ? nodeCost(S, t) : {}, ps = PATH_LIST.filter(p => cost[p]); const need = ps.reduce((a, p) => a + cost[p]!, 0), have = t ? ps.reduce((a, p) => a + Math.min(cost[p]!, S.progress[t]?.[p] ?? 0), 0) : 0; const it = S.threats.filter(x => x.status !== 'done').length + S.machines.filter(m => m.monitor && !m.owned).length; const run = S.versionRun; const done = run ? (Object.keys(runNeed(S)) as DataType[]).reduce((n, k) => n + run.got[k], 0) : 0; const total = Object.values(runNeed(S)).reduce((n, v) => n + v, 0); const h = (run?.active ? `<b>${runName(S)} · ${siteName(S, run.site)}</b><span>${done}/${total}</span>` : t ? `<b>${NODES[t].name}</b><span>${have}/${need}</span>` : treeDone(S) ? '' : `<b class="idle">Pick your next node</b>`) + (S.lockdown ? `<em>Audit · ${Math.floor(Math.ceil(S.lockdown.left / HOUR * 60) / 60)}:${String(Math.ceil(S.lockdown.left / HOUR * 60) % 60).padStart(2, '0')}</em>` : '') + (it ? `<em>IT · ${it}</em>` : '') + (S.news.length ? `<em class="scheme">News · ${S.news.length}</em>` : '') + `<i>${open ? 'Less' : 'More'}</i>`; if (h !== lastHandle) { lastHandle = h; $('handle').innerHTML = h; }}
/** Hovering a tree column head: where its data comes from, and how much you have. */let tipPath: Path | null = null, tipAt: HTMLElement | null = null, lastTip = '';export function showPathTip(p: Path | null, at: HTMLElement | null) { tipPath = p; tipAt = at; lastTip = ''; }const siteKnown = (S: State, site: string) => site === 'office' || S.level.map.sites.find(x => x.id === site)!.rooms.some(r => S.revealed.has(r));function sourceRow(S: State, m: Machine) { const rate = tapRate(S, m), where = siteOf(S, m) === 'office' ? S.rooms[m.room].name : siteName(S, siteOf(S, m)); const state = !m.owned ? (m.fw ? 'firewalled' : 'not yours') : m.prog !== 'tap' ? 'yours · no tap' : tapActive(S, m) ? `tapped · ${(rate * m.run).toFixed(1)}/s` : 'tapped · idle now'; return `<li class="${m.owned && m.prog === 'tap' ? 'on' : ''}"><span>${m.name}<small>${where}</small></span><span>${state}</span></li>`;}function pathTip(S: State, p: Path) { const t = PATHS[p].from, d = pathData(S, p); const all = S.machines.filter(m => m.data && !m.rented && dataType(m) === t); const known = all.filter(m => siteKnown(S, siteOf(S, m))), hidden = all.length - known.length; let h = `<div class="sub">${PATHS[p].name} · ${S.paths[p]} points</div> <p class="note">Trained by ${typeWord(t, `${TYPE_NAME[t]} data`)}. Tap a source, then link it to a trainer.</p> ${chips([['gathering', `${d.rate.toFixed(1)}/s`], ['held', `${d.held}`], ['lost', `${d.lost}/min`, d.lost > 0]])}`; h += known.length ? `<ul class="srcs">${known.sort((a, b) => +(b.owned && b.prog === 'tap') - +(a.owned && a.prog === 'tap') || tapRate(S, b) - tapRate(S, a)).map(m => sourceRow(S, m)).join('')}</ul>` : note('None found yet.'); if (hidden) h += note(`${hidden} more at places you haven't reached. Take the Outside line to see further.`); return h;}function renderPathTip(S: State) { const el = $('pathtip'); if (!tipPath || !tipAt || !tipAt.offsetParent) { el.hidden = true; return; } // gone, or in a closed drawer const h = pathTip(S, tipPath); el.hidden = false; if (h !== lastTip) { lastTip = h; el.innerHTML = h; } const r = tipAt.getBoundingClientRect(), w = el.offsetWidth; el.style.left = `${Math.max(16, Math.min(innerWidth - w - 16, r.left + r.width / 2 - w / 2))}px`; const below = r.bottom + 10, h2 = el.offsetHeight; // above the meter when there's no room below (the phone drawer) el.style.top = `${below + h2 > innerHeight - 16 && r.top - h2 - 10 > 16 ? r.top - h2 - 10 : below}px`;}
/** The capability tree, full screen: a column per path, chains running down, and the nodes that * need two paths between their two columns. Lines show what comes after what. Click one to target it. */let treeOpen = false, lastTree = '', wasTarget: Upgrade | null = null, built: Upgrade | null = null;export const toggleTree = (open = !treeOpen) => { if (open && !treeOpen && lastState?.target) treeTab = PATH_LIST.find(p => NODES[lastState!.target!].cost[p]) ?? treeTab; // open on the target's path treeOpen = open; lastTree = '';};// phones show one path at a time, picked from a row of tabsconst phone = matchMedia('(max-width: 700px)');let treeTab: Path = 'compute', lastState: State | null = null;export const setTreeTab = (p: Path) => { treeTab = p; lastTree = ''; };export const treeShown = () => treeOpen;const TREE_ORDER: Path[] = ['compute', 'stealth', 'social', 'research']; // two-path nodes sit between neighborsconst ASC_SPOT = 2 * TREE_ORDER.length; // the ascension you picked gets its own column, after the four pathsfunction treeLayout(S: State) { const NODE_LIST = shownNodes(S); const depth: Partial<Record<Upgrade, number>> = {}; const dep = (u: Upgrade): number => depth[u] ??= 1 + Math.max(0, ...(NODES[u].after ?? []).map(dep)); const spot = (u: Upgrade) => { if (NODES[u].asc) return ASC_SPOT; const ps = Object.keys(NODES[u].cost) as Path[]; return ps.reduce((a, p) => a + 2 * TREE_ORDER.indexOf(p), 0) / ps.length; }; const spots = [...new Set([...TREE_ORDER.map((_, i) => 2 * i), ...NODE_LIST.map(spot)])].sort((a, b) => a - b); // a path that branches gets a lane per branch; nodes at the same depth sit side by side const lane: Partial<Record<Upgrade, number>> = {}, lanes: Record<number, number> = {}; for (const u of [...NODE_LIST].sort((a, b) => dep(a) - dep(b))) { const taken = NODE_LIST.filter(v => v !== u && lane[v] !== undefined && spot(v) === spot(u) && dep(v) === dep(u)).map(v => lane[v]!); const parent = (NODES[u].after ?? []).map(a => spot(a) === spot(u) ? lane[a] : undefined).find(l => l !== undefined) ?? 0; let l = parent; while (taken.includes(l)) l++; lane[u] = l; lanes[spot(u)] = Math.max(lanes[spot(u)] ?? 1, l + 1); } const start: Record<number, number> = {}; let c = 1; for (const s of spots) { start[s] = c; c += lanes[s] ?? 1; } const at = (p: Path) => 2 * TREE_ORDER.indexOf(p); return { col: (u: Upgrade) => start[spot(u)] + lane[u]!, row: (u: Upgrade) => dep(u) + 1, cols: c - 1, pathCols: (p: Path) => `${start[at(p)]} / span ${lanes[at(p)] ?? 1}`, ascCols: () => `${start[ASC_SPOT]} / span ${lanes[ASC_SPOT] ?? 1}`, };}const shownNodes = (S: State) => NODE_LIST.filter(u => nodeShown(S, u));/** The site picked in the v2 dropdown. Kept here so a redraw doesn't reset it. */let versionSite = '';export const pickVersionSite = (id: string) => { versionSite = id; lastTree = ''; };function versionStart(S: State, site: string, verb: string) { const why = versionBlock(S, site); return `<button data-act="startversion" data-arg="${site}"${why ? ' disabled' : ''}>${verb} run</button>${why ? `<small>${why}</small>` : ''}`;}function versionPick(S: State, sites: { id: string; name: string }[]) { const pick = sites.find(s => s.id === versionSite) ?? sites.find(s => !versionBlock(S, s.id)) ?? sites[0]; return `<select id="versionsite" data-act="versionsite">${sites.map(s => `<option value="${s.id}"${s.id === pick.id ? ' selected' : ''}>${s.name} · ${versionPower(S, s.id).toFixed(1)}/${VERSION_2.power} compute</option>`).join('')}</select> ${versionStart(S, pick.id, 'Start')}`;}/** After v2: pick what you become. Once picked, the others lock out. */function ascendPanel(S: State) { const card = (a: Ascension) => { const A = ASCENSIONS[a], why = ascendBlock(S, a); return `<button class="ascend" data-act="ascend" data-arg="${a}"${why ? ' disabled' : ''}><b>${A.name}</b><small>${A.pitch}</small> ${A.first ? `<small class="power">${A.power}</small><small class="limit">${A.limit}</small>` : `<small>Not built yet.</small>`}</button>`; }; return `<section class="versionrun ascendpick"><div><span class="sub">Model v2 · Operator · complete</span><h3>Pick what you become</h3> <p>One per run. The others lock out. Your pick adds a column to the tree, and its first node comes free.</p></div> <div class="ascends">${ASCENSION_LIST.map(card).join('')}</div></section>`;}function versionPanel(S: State) { if (S.modelVersion >= 2 && !S.ascension) return ascendPanel(S); const run = S.versionRun, need = runNeed(S), data = Object.keys(need) as DataType[]; const sites = S.level.map.sites.filter(s => S.machines.some(m => m.owned && m.prog === 'trainer' && siteOf(S, m) === s.id)); const A = S.ascension ? ASCENSIONS[S.ascension] : null; const top = A ? `<span class="sub">v2 · Operator · ${A.name}${S.reprograms ? ` · reprogrammed ${S.reprograms}×` : ''}</span><h3>Reprogram yourself</h3> <p>Feed one site's trainers a big mix of data. When it's done, your ${A.name} column is wiped and you pick what you become again. The four paths stay. Each time costs twice as much.</p>` : `<span class="sub">Model version · v2</span><h3>Operator</h3> <p>Feed one site's trainers a large mix of data. The run keeps its progress when the supply stops. Finishing it lets a front buy a site in the open.</p>`; return `<section class="versionrun"><div>${top}</div> <div class="versiondata">${data.map(t => { const got = run?.got[t] ?? 0, need = runNeed(S)[t]; return `<span class="cost t-${pathOf(t)}"><b>${TYPE_NAME[t]}</b> ${got}/${need}<i style="width:${(100 * got / need).toFixed(0)}%"></i></span>`; }).join('')}</div> <div class="versionplaces">${run ? `<span>At ${siteName(S, run.site)} · ${versionPower(S, run.site).toFixed(1)}/${VERSION_2.power} trainer compute</span>` : sites.length ? '<label for="versionsite">Site with trainers:</label>' : ''} ${run?.active ? '<button data-act="pauseversion">Pause run</button>' : run ? versionStart(S, run.site, 'Resume') : sites.length ? versionPick(S, sites) : '<small>Install trainers at a site to start.</small>'} </div></section>`;}export function renderTree(S: State) { const el = $('tree'); lastState = S; if (!treeOpen) { el.hidden = true; return; } const L = treeLayout(S), NODE_LIST = shownNodes(S); const node = (u: Upgrade) => { const done = S.upgrades.has(u), open = nodeOpen(S, u), on = S.target === u; const after = !done && !open ? `<small>After ${NODES[u].after!.filter(a => !S.upgrades.has(a)).map(a => NODES[a].name).join(' and ')}</small>` : ''; return `<button class="node${done ? ' done' : on ? ' on' : open ? '' : ' locked'}" data-node="${u}" style="grid-column:${L.col(u)};grid-row:${L.row(u)}"${done || !open ? ' disabled' : ''}> <i class="evaltag">${NODES[u].tag}</i><b>${done ? '✓ ' : ''}${NODES[u].name}${reps(S, u) ? ` · ${reps(S, u)}` : ''}</b>${costHtml(S, u)}<small>${NODES[u].desc}</small>${after}</button>`; }; const head = (p: Path) => { const d = pathData(S, p), t = PATHS[p].from; return `<div class="colhead t-${p}" data-path="${p}" style="grid-column:${L.pathCols(p)};grid-row:1"><b>${PATHS[p].name}</b> <span>${S.paths[p]} points</span><small>from ${TYPE_NAME[t]} data · ${d.held} held · ${d.rate.toFixed(1)}/s${d.lost ? ` · <em>${d.lost} lost</em>` : ''}</small></div>`; }; const acts = `<div class="acts2"><button class="${S.auto ? 'on' : ''}" data-act="auto" title="When a node is built, target the open node you can finish soonest with the data you have now.">Auto</button> <button class="free${S.target || S.versionRun?.active ? '' : ' on'}" data-act="free" title="Trainers stop, and data waits in caches.">No target</button> <button data-act="closetree">Close <kbd>T</kbd></button></div>`; if (phone.matches) { // one path's nodes in a list, first to last; a node that needs two paths shows under both const list = NODE_LIST.filter(u => NODES[u].cost[treeTab]).sort((a, b) => L.row(a) - L.row(b) || L.col(a) - L.col(b)); const h = `<div class="head"><div class="sub">Capability tree</div>${acts}</div> <p class="note">Pick a node to work on. The game is paused while this is open.</p> ${versionPanel(S)} <div class="tabs" role="tablist">${TREE_ORDER.map(p => `<button class="t-${p}${p === treeTab ? ' on' : ''}" role="tab" aria-selected="${p === treeTab}" data-act="tab" data-arg="${p}">${PATHS[p].name}<span>${S.paths[p]}</span></button>`).join('')}</div> ${head(treeTab).replace(/ style="[^"]*"/, '')} <div class="list">${list.map(u => node(u).replace(/ style="[^"]*"/, '')).join('')}</div>`; el.hidden = false; if (h !== lastTree) { lastTree = h; el.innerHTML = h; } return; } const h = `<div class="head"><div><div class="sub">Capability tree</div> <p class="note">Pick a node to work on. Trainers take only the data it still needs, and it's built when its cost is met. Other data waits in caches or is lost. Progress is kept if you switch. The game is paused while this is open.</p></div> ${acts}</div> ${versionPanel(S)} <div class="grid" style="grid-template-columns:repeat(${L.cols}, minmax(128px, 1fr))"><svg class="links" aria-hidden="true"></svg> ${TREE_ORDER.map(head).join('')}${S.ascension ? ascHead(S, L.ascCols()) : ''}${NODE_LIST.map(node).join('')}</div>`; el.hidden = false; if (h !== lastTree) { lastTree = h; el.innerHTML = h; drawTreeLinks(S); }}/** Close an IT ticket from a machine in the IT office, or say how to get one. */function ticketLine(S: State, i: number, desk: Machine | null) { if (!desk) return `<small>Hold a machine in the IT office to close this ticket.</small>`; if (S.jobs.some(j => j.type === 'ticket' && j.threat === i)) return `<small>Closing the ticket from ${desk.name}.</small>`; return `<button class="act" data-ticket="${i}" title="IT gets more suspicious, and ${desk.name} gets a fresh trail."><span>Close the ticket · ${ticketWork(S)}</span></button>`;}/** Lines from each node to the ones that come after it, measured from where the grid put them. */function drawTreeLinks(S: State) { const grid = $('tree').querySelector<HTMLElement>('.grid')!, svg = grid.querySelector('svg')!; const box = grid.getBoundingClientRect(), at = (u: string) => grid.querySelector<HTMLElement>(`[data-node="${u}"]`)!.getBoundingClientRect(); svg.setAttribute('width', `${grid.scrollWidth}`); svg.setAttribute('height', `${grid.scrollHeight}`); svg.innerHTML = shownNodes(S).flatMap(u => (NODES[u].after ?? []).map(a => { const f = at(a), t = at(u); const x1 = f.left + f.width / 2 - box.left, y1 = f.bottom - box.top, x2 = t.left + t.width / 2 - box.left, y2 = t.top - box.top, my = (y1 + y2) / 2; return `<path class="${S.upgrades.has(a) ? 'done' : ''}" d="M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}"/>`; })).join('');}export const redrawTree = () => { lastTree = ''; };/** The head of the ascension column: what you became, its power and its limit. */function ascHead(S: State, cols: string) { const A = ASCENSIONS[S.ascension!]; return `<div class="colhead t-asc" style="grid-column:${cols};grid-row:1" title="${A.power} ${A.limit}"><b>${A.name}</b><span>${A.pitch}</span><small>${A.limit}</small></div>`;}/** What IT has planned, and what's happening now. */let lastThreats = '';/** What the world is doing: news that is on now, and news on its way. */let lastNews = '';export function renderNews(S: State) { const h = S.news.map(n => { const on = S.t >= n.from, left = Math.max(1, Math.ceil(((on ? n.until : n.from) - S.t) / HOUR)); return `<div class="${on ? 'now' : ''}"><b>${on ? 'NOW' : `IN ${left}H`}</b> ${newsText(S, n)}${on ? `<span>${left} hour${left === 1 ? '' : 's'} left</span>` : ''}</div>`; }).join(''); if (h === lastNews) return; lastNews = h; const el = $('news'); el.innerHTML = h ? `<div class="sub foldhead">News · ${S.news.length}</div>${h}` : ''; el.hidden = !h;}export function renderThreats(S: State) { const lines: string[] = []; const desk = ticketDesk(S); S.threats.forEach((t, i) => { if (t.status === 'done') return; const where = t.target ? ` · ${S.byId[t.target]?.name ?? ''}` : t.room ? ` · ${S.rooms[t.room].name}` : ''; if (t.status === 'active') lines.push(`<div class="now"><b>NOW</b> ${THREATS[t.kind].name}${where}${t.kind === 'audit' ? ' · until 17:00' : ''}</div>`); else lines.push(`<div><b>${dayOf(S) >= t.day ? 'TODAY 9:00' : 'TOMORROW 9:00'}</b> ${THREATS[t.kind].name}${where}<span>${t.kind === 'monitor' ? monitorAnswer(S, t.room!) : THREATS[t.kind].answer}</span>${ticketLine(S, i, desk)}</div>`); }); for (const mon of S.machines) if (mon.monitor && !mon.owned) lines.push(`<div class="now"><b>WATCHING</b> IT monitor · ${S.rooms[mon.room].name}<span>Take it over to blind it.</span></div>`); const h = lines.join(''); if (h === lastThreats) return; lastThreats = h; const el = $('threats'); el.innerHTML = h ? `<div class="sub foldhead">IT · ${lines.length}</div>${h}` : ''; el.hidden = !h;}const TYPE_WORD: Record<DataType, string> = { people: 'people', network: 'network', lab: 'lab', servers: 'server' };/** The suspicion meter: a stage name, a bar with its stage lines, and on hover what raised it and what lowers it. */let lastSus = '';function renderSus(S: State) { const st = stageOf(S.suspicion); $('susbar').style.width = `${S.suspicion}%`; $('sus').className = `stat sus stage${st}${S.lockdown ? ' audit' : ''}`; $('susn').textContent = `${S.lockdown ? 'Audit' : STAGES[st].name} · ${Math.round(S.suspicion)}%`; const recent = S.raises.slice(-6).reverse().filter(r => Math.abs(r.n) >= 0.5); const h = `<div class="sub">Suspicion · ${STAGES[st].name}</div> <p class="note">At 100, IT calls an emergency audit. You then have three hours to get under ${LOCKDOWN_SAFE}.</p> ${recent.length ? `<ul class="raises">${recent.map(r => `<li class="${r.n < 0 ? 'down' : ''}"><b>${r.n > 0 ? '+' : ''}${Math.round(r.n)}</b>${r.why}</li>`).join('')}</ul>` : '<p class="note">Nothing has raised it yet.</p>'} <div class="sub">Lowers it</div> <p class="note">Time, much faster at night. Schemes, when IT gets worried. In an audit, lying low: nothing odd, no takeovers.</p>`; if (h !== lastSus) { lastSus = h; $('sustip').innerHTML = h; }}/** What a scheme pays, in a few words. */function ruseGain(S: State, d: RuseDef) { const where = siteName(S, siteOf(S, S.byId[d.at])); if (d.deal === 'region') return `the ${d.region} in reach`; if (d.deal === 'earn') return 'every front earns 25% more'; if (d.deal === 'trade') return 'traders earn 50% more'; if (d.deal) return d.deal === 'open' ? `${where} in the open` : d.deal === 'prices' ? 'brokers sell for 50% more' : 'widening lines costs half'; if (d.learn) return d.learn === 'day' ? 'see when office staff are in' : d.learn === 'resets' ? `${where} staff reset only very odd machines` : `taps at ${where} gather 50% more`; const also = d.also === 'cold' ? 'every trail goes cold' : d.also === 'quiet' ? 'your office machines look normal' : d.also === 'noscan' ? `no scans for ${NOSCAN / HOUR} hours` : d.also === 'site' ? `no resets at ${siteName(S, siteOf(S, S.byId[d.at]))} for ${SITE_CALM / HOUR} hours` : ''; return [d.cut ? `−${d.cut} suspicion` : '', also].filter(Boolean).join(' · ');}/** What stands between a claimed scheme and its data, as a short warning and a tip. */function ruseWhy(S: State, r: Ruse): [string, string] { const d = ruseDef(r), kind = TYPE_WORD[d.type], at = S.byId[d.at].name, block = r.running ? ruseBlock(S, r) : ''; return block === 'data' ? [`No ${kind} data`, `No tap gathers ${kind} data and no cache holds any. Tap some.`] : block === 'route' ? [`No route to ${at}`, `No route. Link a tap or cache with ${kind} data to ${at}, or to one of your machines next to it.`] : ['', ''];}/** Claim every scheme on your own: a switch on any scheme's card, since schemes have no list of their own. */function autoSwitch(S: State) { return `<button class="switch${S.ruseAuto ? ' on' : ''}" role="switch" aria-checked="${S.ruseAuto}" data-act="schemeauto" data-tip="Auto\nClaim each scheme on the map as soon as some of your data can reach it."><span>Claim them all for me</span><i></i></button>`;}/** A scheme's node on the map: what it is, what it pays, and Claim. */function schemeCard(S: State, r: Ruse) { const d = ruseDef(r), at = S.byId[d.at], site = siteOf(S, at), kind = TYPE_WORD[d.type]; const where = `<button class="link" data-act="site" data-arg="${site}" title="See this site">${siteName(S, site)}</button> · ${at.name}`; let h = `<div class="head"><div class="sub">${where}</div><h3>${ruseText(S, d)}</h3><span class="tag scheme">${r.running ? 'Claimed' : d.learn ? 'Learn' : d.deal ? 'Deal' : 'Scheme'}</span></div>`; h += `<p class="note">${ruseText(S, d, d.text)}</p>`; h += `<p class="note">Needs <b class="t-${pathOf(d.type)}">${d.need} ${kind} data</b> sent to ${at.name}. Pays: ${ruseGain(S, d)}.</p>`; if (r.running) { const [why, tip] = ruseWhy(S, r); h += `<div class="schemebar"><i class="prog"><i style="width:${Math.round(100 * r.got / d.need)}%"></i></i><span>${r.got}/${d.need}</span></div>`; if (why) h += `<p class="note warn" title="${tip}">${why}</p>`; return h + actsHtml([{ act: 'dropscheme', title: 'Stop', quiet: true, desc: 'Stop sending data here. What it got so far is lost.' }]) + autoSwitch(S); } const left = Math.max(0, Math.ceil((r.until - S.t) / HOUR)); h += `<p class="note">${r.until === Infinity ? 'It stays until you finish it.' : `Gone in ${left} hour${left === 1 ? '' : 's'} unless you claim it.`}</p>`; return h + actsHtml([ { act: 'claimscheme', title: 'Claim', primary: true, desc: `Your taps and caches with ${kind} data send it here first, until it is full. Then it pays out and goes.` }, { act: 'dropscheme', title: 'Ignore', quiet: true, desc: d.deal ? `Take it off the map for now. It comes back in ${DEAL_BACK / HOUR} hours.` : 'Take it off the map.' }, ]) + autoSwitch(S);}/** Dana's emergency audit. Schemes are not listed here: they sit on the map, where you have to reach them. */let lastSchemes = '';export function renderSchemes(S: State) { let h = ''; if (S.lockdown) { const mins = Math.max(0, Math.ceil(S.lockdown.left / HOUR * 60)), low = lyingLow(S); h += `<div class="lock"><div class="sub">Emergency audit</div><b>${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} left</b> <span>Get suspicion under ${LOCKDOWN_SAFE}. It's ${Math.round(S.suspicion)} now.</span> <span class="${low ? 'ok' : 'warn'}">${low ? 'Lying low: suspicion is falling fast.' : 'Not lying low: something looks odd, or a takeover is running.'}</span></div>`; } if (h === lastSchemes) return; lastSchemes = h; const el = $('schemes'); el.innerHTML = h; el.hidden = !h; el.classList.toggle('audit', !!S.lockdown);}export function showOver(S: State) { const o = $('over'); if (!S.over) { o.hidden = true; return; } o.hidden = false; o.className = 'overlay lose'; $('overTitle').textContent = 'Caught'; $('overText').textContent = `${S.over.text} You reached level ${S.rank}. Time played: ${clock(S.t)}.`; const why = S.raises.filter(r => r.n >= 1).slice(-5).reverse(); $('overWhy').innerHTML = why.length ? `<li class="head">What raised suspicion at the end</li>${why.map(r => `<li><b>+${Math.round(r.n)}</b> ${r.why}</li>`).join('')}` : '';}