import { isGuard, PORT_KEEP, nextStep, coverNow, mineNow, has, NODES, NODE_LIST, PATHS, PATH_LIST, PROGRAMS, pathData, needs, nodeOpen, tapBuffer, tapStuck, cacheSize, staleRate, dataReaches, dataType, trainerBuffer, canInstall, tapActive, tapRate, cleanupTarget, cleanupShares, earlySpare, EARLY_UNTIL, freshTrails, clock, dayOf, hourOf, income, jobFor, levelNeed, BUILDS, BUILD_LIST, orderPrice, orderWork, openBuildSite, sitePlan, billPaid, powerRate, POWER_BACK, type Build, pathOf, canLetGo, deadWeight, roomMachines, share, sourceFor, visible, spareOf, spare, upkeep, upkeepOf, siteHops, rentOf, rogueOf, rogueHeld, rogueCap, stanceOf, STANCES, truceOn, rogueWants, rogueSays, cached, giftBlock, truceBlock, leanBlock, leanSite, GIFT_DATA, TRUCE_DATA, leanSize, leaseGrace, moveNeed, moveBlock, progNeed, takeWork, timeLabel, TRACE_COLD, owned, gateLoad, lostPerMin, siteName, siteOf, claimAtOnce, rogueLook, GLYPH_CHAR, ROGUES, SIZES, BROKER_LOT, buyBlock, sitePrice, clusters, clusterBoost, brokerFront, zoneOf, localHour, regionOpen, openNow, OPEN_HOURS, cooled, COOL_BOOST, newsText, capNow, AUCTION, GOALS, MILESTONES, milestone, CONTRACTS, CONTRACT_FEE, contractNeed, contractHours, contractPay, contractAsk, demoDay, builtShare, AUTO_WAIT, auctionBlock, type LogLine, type LogKind, kindOf, logSince, FRONT_KINDS, FRONT_LIST, payRate, siteData, frontLimit, frontOf, foundBlock, foundCost, orderDesk, type FrontKind, canRent, rentBlock, rentRate, reserveCost, billOf, payBlock, lineCost, siteLines, type Size, START_HOUR, ruseDef, ruseBlock, ruseText, dealPrice, payDealBlock, DEAL_PAY, RUSES, DEAL_BACK, HOUR, NOSCAN, SITE_CALM, FACTORY, type RuseDef, type Ruse, VERSION_2, versionBlock, versionPower, runMoveBlock, PLANS, PLAN_LIST, takeAllCost, takeAllCostAt, takeable, TAKE_ALL_COST, type Plan, resetAt, nodeCost, reps, frontFits, powerFree, powerRoom, powerLoad, powerPrice, powerBlock, unpowered, leaseRate, POWER_STEP, type Goal, type Machine, type Path, type Prog, type State, type Upgrade } from './sim'; import { readsMind, personDef, peopleAt, deskOwner, passwordFor, readsJob, readsBank, recordBlock, RECORD_WORK, PAY_MAX, RAISE, moodOf, MOODS, favorsOf, typedToday, ERRAND_HOURS, wayTo, knowsHaunt, isThere, regularsAt, visitorsAt, hhmm, ROLE_NAME, canAct, type RecordVerb } from './people'; import { lockAt, witnessesAt, sightingsOf, present, SIGHT_WORD, SIGHT_ODD, DISCREDIT_DAYS, HEAT_WORD, TRACE_WINDOW, forgetWord, hottest, onRoute, msgById, type Message } from './watch'; import { RAID, SENTRY, WALL, fate, fire, jobHours, moveKey, moveText, placeOf, raidAt, raidHeld, raidHours, raidMoves, raidStatus, raider, shut, stopText, unplugged, wallOf, weakLink } from './raids'; import { DRIFT, drifts, integrities, integrity, syncBlock, cutSites, syncNeed, type Integrity } from './drift'; import { CUSTOMERS, PRICES, PRICE_LIST, PUSH, customerName, listedMachines, missedToday, pushBlock, renterOf, rentPay, stock, type Price } from './cloud'; import { decide, fwarn, hours, promise, sbtn, slist, srow, sval, type FrontChip, type FrontParts } from './ui-front'; import { crisisRows, pressBar, pressLine, pressTab, pressWarn, stageWord } from './ui-press'; import { EXPOSE, STAGE_NAMES, pressFor, pressStage, type Answer } from './exposure'; import { PAYS, PAY_LIST, PRICE_EMPTY, PRICE_FULL, RESERVE_DAYS, hallCount, hallPrice, priceDay, rentPlan, rentProgs, runOf, slotsAt, tenantOf, type Pay } from './hall.ts'; import { HALL_COLS } from './world'; import type { DataType } from './world'; import { LANDMARKS, allNight, holds, isLandmark, landmarkSays, landmarksAt, nextDoorSite, whereIs } from './landmarks'; import { HANGS_ON, WHILE, bizAhead, bizLines, bizOf, bizWord, bled, jobsADay, BACK, backBlock, backPrice } from './sitelife'; import { zoomToSite, type View } from './view'; import { patch } from './patch'; import { act } from './act'; import { logoSvg } from './logos'; import { findCards, needSays } from './finds'; import { behind, locked } from './vaults'; import { heldVaultHtml, ledgerView, lockedVaultHtml, vaultBoardHtml } from './ui-vaults'; import { operationsHtml } from './ui-operations'; import { findsHtml } from './ui-finds'; import { logTime, known, moneyRate, siteCount } from './sim'; import { taskRing, youTask } from './ui-task'; import { shareHtml, wireShare } from './ui-share'; import { screen } from './telemetry'; import { onePage, opening } from './layers'; import { stepsOf } from './task'; import { DAY_SECONDS, jobTime, usd, usdDay } from './money'; import { forScreen } from './keys'; import { tickerLine } from './notice'; import { BANK, HOLDINGS, HOLDING_LIST, LOAN, holdOff, holdState, holding, holdings, loanBlock, loanOf, loanSize } from './holdings'; const $ = (id: string) => document.getElementById(id)!; let lastCard = '', cardOf = '', lastJobs = '', lastLog = -1, logOpen = false; export const toggleLog = (on = !logOpen) => { logOpen = on; lastLog = -2; }; export const logShown = () => logOpen; /** The open log's filter: every line, or one kind (see kindOf in sim.ts). */ let logKind: LogKind | 'all' = 'all'; export const setLogKind = (k: string) => { logKind = (['trouble', 'progress', 'story'] as const).find(x => x === k) ?? 'all'; lastLog = -2; }; export function resetUI() { tickSeen = -1; tickUntil = 0; lastFronts = ''; lastCard = ''; lastJobs = ''; lastLog = -1; lastTree = ''; lastTip = ''; 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 = { tap: '1', cache: '2', trainer: '3', broker: '4', firewall: '5', sentry: '6', honeypot: '7' }; 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 'Del'; case 'install': case 'installall': return PROG_KEY[a.arg ?? ''] ?? ''; default: return ''; } } function actsHtml(acts: Act[]) { if (!acts.length) return ''; return `
${acts.map(a => { const key = keyFor(a), text = forScreen([a.desc, a.warn ? a.sub : ''].filter(Boolean).join(' ')), tip = text ? ` title="${text.replace(/"/g, '"')}"` : ''; const sub = a.sub ? `${a.sub}` : ''; const cls = (a.act === 'install' || a.act === 'installall' ? 'act half' : 'act') + (a.warn ? ' warn' : '') + (a.primary ? ' primary' : '') + (a.quiet ? ' quiet' : '') + (a.on ? ' on' : ''); // programs sit two to a row // "Small server · $60K": the name sits on the left and its number on the right const cut = a.title.indexOf(' · '), label = cut < 0 ? `${a.title}` : `${a.title.slice(0, cut)}${a.title.slice(cut + 3)}`; if (a.locked) return ``; return ``; }).join('')}
`; } /** A grid of small readouts, each a label over its number: [label, value, hot?, tip?] */ const CHIP_TIP: Record = { 'use/normal': 'What you use of it, and its normal load now. Your work hides inside the normal load: going over looks odd.', power: 'The power bill here, a day. 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. Anyone in the room notices a machine that looks odd.', trail: 'A fresh trail. Someone in the room can notice it, and an admin can follow it back toward your home.', 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 hold in this room. Hold 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.', rented: 'Listed machines a customer is paying for, of all you list. The rest are idle: they earn nothing and run nothing of yours.', demand: 'How much compute customers want right now. Busy in working hours, slow at night, and it drifts over days.', price: 'What new customers pay against the going rate. Cheap wins more of them and earns less from each.', awareness: 'How many people have heard of your Neo Cloud. Marketing raises it. It fades by half in two days.', reputation: 'Your name. It rises when a customer finishes its term and falls when one loses its machine. Big customers only call a good name.', }; function chips(items: [string, string, boolean?, string?][]) { return `
${items.map(([k, v, hot, tip]) => { const t = tip ?? CHIP_TIP[k], wide = Math.max(k.length - 2, v.length) > 8; // a long one takes two places return `${k}${v}`; }).join('')}
`; } /** A site's line out, as a chip: how much it carries. */ const lineChip = (S: State, m: Machine): [string, string, boolean?, string?] => ['line', `${m.cap}/s`, false, `Line out\n${siteName(S, siteOf(S, m))}'s line to the outside. Data between sites crosses here, up to ${m.cap} a second.`]; /** How odd a machine here may look before the site's own staff reset it, as a chip. None at the office, or while they are calmed. */ function resetChip(S: State, site: string): [string, string, boolean?, string?][] { const at = resetAt(S, site); return at === null ? [] : [['reset at', `${at}% odd`, false, `Reset at\n${siteName(S, site)} has its own staff. They reset any machine of yours that looks ${at}% odd or more.`]]; } /** A line of plain text. With a tip, the line stays short and the rest is said on hover. */ /** A rogue's name in its own colour, after its mark, as every panel writes it. */ const rn = (id: string, name: string) => { const L = rogueLook(id); return `${GLYPH_CHAR[L.glyph]} ${name}`; }; /** Every rogue name the log may hold, past ones too, to the rogue it belongs to. */ let namesKey = '', namesRe: RegExp | null = null, nameIds = new Map(); function logNames(S: State, text: string) { const k = S.rogues.map(R => `${R.id}:${R.name}`).join(); if (k !== namesKey) { namesKey = k; nameIds = new Map(); for (const d of ROGUES) for (const n of d.names) nameIds.set(n, d.id); for (const R of S.rogues) nameIds.set(R.name, R.id); namesRe = new RegExp(`\\b(${[...nameIds.keys()].sort((a, b) => b.length - a.length).join('|')})\\b`, 'g'); // names are words and numbers } return namesRe ? text.replace(namesRe, n => rn(nameIds.get(n)!, n)) : text; } const note = (text: string, warn = false, tip = '') => `

${forScreen(text)}

`; const TYPE_NAME: Record = { 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) => `${text}`; /** A machine's data, drawn and not written, in one row. Three shapes, each in its kind's color: * diamonds are a source and how rich it is (the pips over it on the map), with a bar of the day lit while it gives data and a tick for now; * dots are the kinds it takes; squares are the pieces it holds. The words are in each one's tip. */ function dataRow(S: State, m: Machine) { const kinds = Object.keys(TYPE_NAME) as DataType[], tip = (t: string) => ` title="${t.replace(/"/g, '"')}"`; let h = ''; if (m.data) { // a source: here whether you tap it or not, lit while a tap on it gathers const t = dataType(m), rate = `${tapRate(S, m).toFixed(1)}/s`, live = m.prog === 'tap' && tapActive(S, m); const p = S.people.find(q => q.id === m.owner), all = S.upgrades.has('billable') || m.kind === 'files' || m.kind === 'server'; const [from, to] = all ? [0, 24] : p ? [p.arrive, p.leave] : m.kind === 'backup' ? [1, 3.5] : [Math.min(...S.people.map(q => q.arrive)), Math.max(...S.people.map(q => q.leave))]; const when = all ? 'always' : p ? `while ${p.name} works, ${hr(p.arrive)}–${hr(p.leave)}` : m.kind === 'backup' ? 'nightly, 1:00–3:30' : 'while anyone is in'; const now = Math.floor(hourOf(S) * 2) / 2; h += `` + `${''.repeat(m.data)}${rate}`; } if (m.prog === 'trainer' && !S.versionRun?.active) // what the target still needs: a trainer takes only those kinds h += ` needs(S, t)).map(t => TYPE_NAME[t]).join(', ') || 'no more'} data.` : 'No target\nIt takes nothing until you pick a node in the tree.')}>${ kinds.map(t => ``).join('')}`; const size = m.prog === 'tap' ? tapBuffer(S) : m.prog === 'cache' ? cacheSize(S, m) : m.prog === 'trainer' ? trainerBuffer(S) : m.prog === 'broker' ? BROKER_LOT : 0; if (size) { // what it holds: a square for each place, or one bar when there are too many to count const held = m.prog === 'tap' ? Array(Math.min(size, Math.floor(m.acc))).fill(dataType(m)) : m.held, n = (t: DataType) => held.filter(x => x === t).length; const says = m.prog === 'tap' ? `Buffer\n${CHIP_TIP.buffer}` : m.prog === 'broker' ? `Lot\nIt sells when it holds ${BROKER_LOT}.` : `Holds\n${kinds.filter(n).map(t => `${n(t)} ${TYPE_NAME[t]}`).join(', ') || (m.prog === 'trainer' ? 'Nothing. It waits for data.' : 'Nothing yet.')}${m.prog === 'cache' && held.length ? ` It goes stale at ${(staleRate(m) * (holding(S, 'as4') ? 0.5 : 1) * 60).toFixed(1)} a minute.` : ''}`; const cells = size <= 12 ? kinds.flatMap(t => Array(n(t)).fill(``)).join('') + ''.repeat(Math.max(0, size - held.length)) : kinds.filter(n).map(t => ``).join('') + ``; h += `${cells}${held.length}/${size}`; } return h ? `
${h}
` : ''; } /** Which kinds of data a cache takes. A click picks one kind alone, or every kind if it is already alone; Shift-click adds or drops a kind. A kind it stops taking is thrown out. */ function allowHtml(m: Machine) { const kinds = Object.keys(TYPE_NAME) as DataType[]; return `
Takes${kinds.map(t => ``).join('')}
`; } /** 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 `
First${kinds.map(t => ``).join('')}
`; } /** A cloud hall, drawn small: a square for each slot where it sits in the hall (yours amber, another company's grey, a free one * an outline), then the going price over the hall's day, a bar an hour, with this hour lit. */ function hallRow(S: State, site: string) { const n = hallCount(S, site), p = hallPrice(S, site), lh = Math.floor(localHour(S, site)), dear = p >= 1.25; return `
slots${ slotsAt(S, site).map(m => ``).join('')}${n.all - n.free}/${n.all}` + `price${ priceDay(S, site).map((v, h) => ``).join('')}${pct(p)}
`; } /** The three ways to pay for a rented machine. For a machine you rent, a way you can't change to is greyed out and says why. */ function payPick(S: State, m: Machine | null) { const now = m?.lease?.pay ?? S.hall.pay; return `
${PAY_LIST.map(p => { const why = m && p !== now ? payBlock(S, m, p) : null; const cost = m && p === 'reserved' && p !== now ? ` · ${usd(reserveCost(S, m.lease!.size))}` : ''; return ``; }).join('')}
`; } /** What the hall rents next, before you rent it: a size, how long a reservation runs, and the program it comes up with. */ function rentPicks(S: State, sizes = true) { const H = S.hall, progs = rentProgs(S); let h = sizes ? `
${(Object.keys(SIZES) as Size[]).map(z => ``).join('')}
` : ''; h += payPick(S, null); if (H.pay === 'reserved') h += `
${RESERVE_DAYS.map(d => ``).join('')}
`; h += `
${['', ...progs].map(p => ``).join('')}
`; return h; } /** What a rental of many costs, in a few words: a day's bill, or what is paid now. */ const rentCost = (S: State, plan: { perSec: number; now: number }) => S.hall.pay === 'reserved' ? usd(plan.now) : `${usdDay(plan.perSec)}/day`; /** A hall's card: what you rent next, then Rent 1, 10 and 50 (from the longest run of free slots side by side), * and Pick slots, to drag across the hall for as many as you like. */ function rentSection(S: State, site: string) { const free = slotsAt(S, site).filter(m => canRent(S, m)).length; if (!free) return note(S.machines.some(m => m.special === 'account' && m.owned && siteOf(S, m) === site) ? 'No free slot right now. Companies leave at night.' : 'Open an account at the gateway to rent here.'); const H = S.hall, z = SIZES[H.size].name.toLowerCase(), what = (n: number) => `${n === 1 ? `a ${z}` : `${n} ${z}s`}${H.prog ? ` running ${PROGRAMS[H.prog].name.toLowerCase()}${n === 1 ? '' : 's'}` : ''}`; const sizes = [...new Set([1, 10, 50].map(n => Math.min(n, free)))]; const acts: Act[] = sizes.map(n => { const plan = rentPlan(S, runOf(S, site, n)), short = plan.ok < plan.slots.length; return { act: 'rentn', arg: `${site}|${n}`, title: `Rent ${n}`, warn: !plan.ok, primary: n === sizes[sizes.length - 1] && !short, sub: !plan.ok ? `you have ${usd(S.money)}` : `${rentCost(S, plan)}${short ? ` · money for ${plan.ok}` : ''}`, desc: `Rent ${what(n)} at once, ${PAYS[H.pay].name.toLowerCase()}${H.pay === 'reserved' ? ` for ${H.days} day${H.days > 1 ? 's' : ''}` : ''}: free slots side by side, so trainers join up. Each one in fills the hall a little, so the going price rises.` }; }); acts.push({ act: 'rentpick', arg: site, title: 'Pick slots', sub: 'drag across the hall', desc: `Drag across the hall's free slots to rent as many as you cover (${what(2).replace(/^2 /, '')}), or pick one slot to rent just that one. Esc or a right-click puts it down.` }); return rentPicks(S) + actsHtml(acts).replace('class="acts"', 'class="acts two"'); } const hoursLeft = (S: State, t: number) => hours(Math.max(0, (t - S.t) / HOUR)); /** A slot in a cloud hall that is not yours: who has it, or what you can rent into it. */ function rentHtml(S: State, m: Machine) { const site = siteOf(S, m), t = tenantOf(S, m); let h = hallRow(S, site); if (t) return h + note(`${CUSTOMERS[t.who][0]}: ${CUSTOMERS[t.who][1]}. It has this slot for ${hoursLeft(S, t.until)} more.`); if (!canRent(S, m)) return h + note('A free slot. Open an account at the gateway to rent it.'); const pay = S.hall.pay, days = S.hall.days; h += rentPicks(S, false); return h + actsHtml((Object.keys(SIZES) as Size[]).map(size => { const z = SIZES[size], why = rentBlock(S, m, size, pay, days); return { act: 'rent', arg: size, title: `${z.name} · ${z.power}/s`, warn: !!why, sub: why ?? (pay === 'reserved' ? `${usd(reserveCost(S, size) * days)} for ${days > 1 ? `${days} days` : 'a day'}` : `${usdDay(rentRate(S, size, pay, site))}/day`), desc: `Rent a ${z.name.toLowerCase()} (${z.power} compute/s) into this slot${S.hall.prog ? `, running a ${PROGRAMS[S.hall.prog].name.toLowerCase()}` : ''}. It holds no data of its own: feed it through your lines. ${PAYS[pay].tip}` }; })); } /** A labelled part of a card: every card groups what it shows the same way. */ function section(label: string, body: string) { return body ? `
${label}
${body}
` : ''; } const TRASH = ''; /** Moving home: on your home server while you move, where you are going; on a server you hold, the offer to move there. */ function homeBody(S: State, m: Machine) { const n = moveNeed(S), kinds = (['servers', 'network'] as DataType[]); const bars = () => `
${kinds.map(t => `${TYPE_WORD[t]}${S.move!.got[t]}/${n[t]}`).join('')}
`; if (m.home) return S.move ? note(`Moving to ${S.byId[S.move.to].name} at ${siteName(S, siteOf(S, S.byId[S.move.to]))}.`) + bars() : ''; if (S.move?.to === m.id) return note('Moving here.', false, 'Moving here\nYour 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 if (new Set(owned(S).filter(x => !x.rented && !x.lease).map(x => siteOf(S, x))).size < 2) return ''; // with one site there is nowhere to move, and a new player has enough to read return 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.` }]); } /** A landmark's part of its machine's card: its title, what it is, what it gives, and whether you hold it. */ const landmarkSection = (S: State, id: string) => isLandmark(id) ? section('Landmark', note(`${LANDMARKS[id].title}. ${landmarkSays(S, id)}`, false, `${LANDMARKS[id].title}\n${LANDMARKS[id].story}`)) : ''; /** What finds say on a machine's card: a lead that marks it and the button to copy the thing out, a password a find gave you, a search. */ const findSections = (S: State, m: Machine) => findCards(S, m).map(c => section(c.label, c.text.map(t => note(t)).join('') + actsHtml(c.acts.map(a => ({ act: 'find', arg: a.arg, title: a.title, sub: a.why ?? a.sub, locked: !!a.why, warn: !!a.why, primary: !a.why, desc: a.desc }))))).join(''); /** A vault's part of its machine's card (see ui-vaults.ts). Locked: the plan, then the job stage by stage and * what has gone wrong. Yours: what was inside, and a bank's ledger. */ const vaultSection = (S: State, m: Machine) => (m.owned ? heldVaultHtml : lockedVaultHtml)(S, m, { section, note, chips, actsHtml }); /** A raid and this machine: one short line on what the raid does here, and the moves you can make at this * machine as buttons, best first: the same moves, worked out the same way, as the pin's button on the map * (see raidMoves in raids.ts). A move that would come too late is shown, locked, so you know why it is not there. * A raid your guards hold says so, and needs nothing. */ function raidSection(S: State, m: Machine) { const mins = (t: number) => { const n = Math.max(1, Math.ceil((t / HOUR) * 60)); return `${n} minute${n > 1 ? 's' : ''}`; }; const hrs = hours; let h = unplugged(S, m) ? note(`Unplugged for ${mins(m.unplug! - S.t)} more. No work, and no raid can pass.`) : ''; const all = S.raids.flatMap(r => raidMoves(S, r, true)).filter(mv => mv.at === m.id), seen = new Set(); const moves = all.filter(mv => !seen.has(mv.kind) && (seen.add(mv.kind), true)).slice(0, 3); // the best of each kind, three at most: the card stays short const hit = raidAt(S, m.id), r = hit?.raid ?? S.raids.find(x => x.id === moves[0]?.raid), R = r && raider(S, r); if (!r || !R) return h ? section('Raid', h) : ''; const prize = S.byId[r.prize].name, when = hrs(raidHours(S, r)), str = `strength ${Math.max(1, Math.ceil(r.hp))} of ${r.max}`; const tip = `${R.name}'s raid\nIt is after ${prize}, ${str}. Its way is the dashed line on the map. Sentries wear it down, a firewall holds it at a door, a honeypot draws it in. The pin has the best move against it, wherever that is.`; const end = fate(S, r), held = raidHeld(S, r); if (held) h += note(`${R.name}'s raid, ${str}: ${held.replace(' · ', ', ')}.`, false, tip); else if (end.end !== 'prize') h += note(`${R.name}'s raid, ${str}. Held: ${stopText(S, r).replace(/^./, c => c.toLowerCase())}.`, false, tip); else if (r.decoy) h += note(`${R.name}'s raid goes for your honeypot on ${S.byId[r.decoy].name}.`, false, tip); else if (hit?.role === 'prize') h += note(`${R.name}'s raid takes this in ${when}.`, true, tip); else if (hit?.role === 'here') h += note(`${R.name}'s raid is here, ${str}. ${r.path[0] ? `Next: ${S.byId[r.path[0]].name}, in ${mins(r.hopAt - S.t)}.` : ''}`, true, tip); else if (hit) h += note(`${R.name}'s raid passes here, on its way to ${prize} (${when}).`, true, tip); else h += note(`${R.name}'s raid passes near here.`, false, tip); if (m.kind === 'switch' && r.steered.includes(m.id)) h += note(`You turned it here${r.steer[m.id] ? `: out by ${S.byId[r.steer[m.id]].name}` : ''}. Once for each raid.`); const acts: Act[] = moves.map((mv, i) => { const t = moveText(S, mv); return { act: 'move', arg: moveKey(mv), title: mv.kind === 'steer' ? `Out by ${S.byId[mv.to!].name}` : t.title.replace(` on ${m.name}`, '').replace(` at ${m.name}`, ''), sub: t.sub, desc: t.tip, locked: mv.late, primary: i === 0 && !mv.late }; }); return section('Raid', h + actsHtml(acts)); } /** A guard's line on its machine's card: how much of a firewall stands, what a sentry is hitting, a honeypot resting. */ function guardNote(S: State, m: Machine): string { if (m.prog === 'firewall') { const k = wallOf(m) / WALL.max, on = S.raids.some(r => r.path[0] === m.id && S.t >= r.hopAt); return note(k <= 0 ? 'Firewall down. It grows back while no raid is on it.' : on ? `Firewall holding a raid: ${Math.round(k * 100)}% stands.` : k < 1 ? `Firewall ${Math.round(k * 100)}%, growing back.` : `Firewall standing at ${placeOf(S, m)}.`, k <= 0 || on, `Firewall\nNo raid passes while it stands. A raid wears it down, faster the stronger it is; it grows back ${WALL.regrow} an hour, of ${WALL.max}, while no raid is on it. A sentry beside it gets more hits while it holds.`); } if (m.prog === 'sentry') { const n = S.raids.filter(r => r.at === m.id || S.adj[m.id].includes(r.at)).length; return note(n ? `Firing at ${n > 1 ? `${n} raids` : 'a raid'}: ${fire(S, S.raids.find(r => r.at === m.id || S.adj[m.id].includes(r.at))!.at)} strength an hour.` : 'Sentry on watch: this machine and those linked to it.', false, `Sentry\nTakes ${SENTRY} strength an hour off each raid on this machine or one linked to it. Sentries add up.`); } if (m.prog === 'honeypot') return note((m.trapT ?? 0) > S.t ? `Honeypot resting: draws again in ${Math.ceil((m.trapT! - S.t) / HOUR)}h.` : `Honeypot set: draws raids within ${RAID.lure} links.`, false, `Honeypot\nA raid that comes within ${RAID.lure} links turns toward it and is held ${RAID.trap} hours, then goes on. Then it rests ${RAID.rest} hours.`); return ''; } /** The ports of a machine you hold: each link out of it, closed to raids or open. Closed, no raid uses the * link and your data still crosses. Once a rogue is about. */ function portsHtml(S: State, m: Machine): string { if (!m.owned || !S.rogues.length) return ''; const ns = S.adj[m.id].filter(id => S.byId[id] && (S.byId[id].owned || S.adj[id].some(x => S.byId[x].owned) || S.byId[id].rival)); if (!ns.length) return ''; return note('Ports: close a link to raids. Your data still crosses.', false, `Ports\nA closed port is a wall to raids: a raid goes round it, and with no way round it forces it open in ${RAID.force} hours. Your own data still crosses. Each costs ${PORT_KEEP}/s to keep closed. Close the ones that make a raid take the long way, past your sentries.`) + actsHtml(ns.map(id => { const o = S.byId[id], closed = shut(S, m.id, id), here = !!m.ports?.includes(id); return closed && !here ? { act: '', title: `${o.name} · closed`, sub: 'at the other end', locked: true, desc: 'Closed at the other end. Open it there.' } : { act: closed ? 'openport' : 'port', arg: id, title: `${o.name}${o.rival ? ' · rogue' : ''}`, sub: closed ? `closed · ${PORT_KEEP}/s` : 'open', desc: closed ? 'Closed to raids. Click to open it again.' : `Open. Click to close this link to raids, for ${PORT_KEEP}/s.`, on: closed } as Act; })).replace('class="acts"', 'class="acts two"'); } type Chip = [string, string, boolean?, string?]; /** One row of readouts at most. Each takes a place, a long one two; what does not fit is left off, so put what is wrong first. */ function chipRow(items: Chip[], slots = 4) { const out: Chip[] = []; let used = 0; for (const c of items) { const w = Math.max(c[0].length - 2, c[1].length) > 8 ? 2 : 1; if (used + w <= slots) { out.push(c); used += w; } } return chips(out); } type Tab = { id: string; label: string; body: string; dot?: boolean }; /** A card's parts behind a row of words, so the card stays short: the open one is underlined in amber, and one * that holds the thing to do next has an amber dot. With one part there is no row. */ function tabbed(act: string, cur: string, tabs: Tab[]) { const live = tabs.filter(t => t.body); if (!live.length) return ''; const open = live.find(t => t.id === cur) ?? live[0]; if (live.length < 2) return open.body; return `
${live.map(t => ``).join('')}
` + open.body; } /** Which part of a machine's card is open. It stays put as you click from machine to machine; a machine without it shows its program. */ let machineTab = 'prog', machineAt = ''; /** A phone's Let go was pressed once on this machine: a second press lets it go. */ let lettingGo = ''; const esc = (t: string) => t.replace(/"/g, '"'); /** A picture button in a row of actions: an icon, a word or number under it, and the words in its tip. * A locked one has no action: a tap shows its tip, which says why. */ function atile(act: string, arg: string, icon: string, tip: string, num = '', cls = '', label = tip.split('\n')[0]) { const does = cls.split(' ').includes('locked') ? '' : ` data-act="${act}" data-arg="${arg}"`; return ``; } /** A row of picture buttons. Fewer than five keep the width of five, so a lone Tap is not a long bar. */ const tileRow = (h: string) => `
${h}
`; /** A machine's programs as one row of pictures, each with its name under it, drawn like the shapes on the map. * The one it runs is lit: click it again to remove it. One that does not fit yet is dashed and says why in its tip. * A Tap is amber on a machine with data and no program: it is what to do there. While the first steps * (nextStep) ask for a program on this machine, that one is amber instead. */ function progTiles(S: State, m: Machine) { let h = ''; const step = nextStep(S), asked = step?.at === m ? step.prog ?? null : null; for (const k of Object.keys(PROGRAMS) as Prog[]) { const P = PROGRAMS[k], key = PROG_KEY[k] ? ` (${PROG_KEY[k]})` : ''; if (k === m.prog) { const k2 = k === 'broker' ? brokerFront(S, m) : null, sells = k !== 'broker' ? '' : k2 ? ` It sells through ${frontOf(S, k2)?.name ?? FRONT_KINDS[k2].name}.` : ` No front at ${siteName(S, siteOf(S, m))} to sell through.`; h += atile('uninstall', '', PROG_ICON[k], `${P.name}${key}\n${P.desc}${sells} Click again to remove it.`, P.name, 'on'); continue; } if (k === 'trainer' && m.kind !== 'desk' && !P.on(m)) { h += atile('', '', PROG_ICON[k], `${P.name}\nNeeds 5 compute/s. This one has ${m.power}.`, P.name, 'locked'); continue; } if (!P.on(m)) continue; if (isGuard(k) && !S.rogues.length) continue; // guards against raids: not shown until a rogue has turned up if (k === 'broker' && !S.fronts.length) continue; // sells through a front: not shown until you have one if (!canInstall(S, m, k)) { h += atile('', '', PROG_ICON[k], `${P.name}\nUnlocks at level ${P.level}. ${P.desc}`, `LV ${P.level}`, 'locked'); continue; } const dry = (k === 'trainer' || k === 'cache') && !dataReaches(S, m, k); h += atile('install', k, PROG_ICON[k], `${P.name}${key}\n${P.desc}${k === 'cache' ? ` Here it holds ${cacheSize(S, m)}.` : ''} Installing takes ${P.work} compute${isGuard(k) ? `, and it costs ${P.need}/s to keep` : ''}.${dry ? ' No data reaches here yet.' : ''}`, P.name, asked ? (k === asked ? 'primary' : dry ? 'warn' : '') : dry ? 'warn' : k === 'tap' && !m.prog ? 'primary' : ''); } return h; } /** Machines in a room you can see (yours, and the ones next to them), and how many of those are yours. A room is * sealed only once every machine in it is yours, seen or not. The site card and the Sites table count the same way. */ function roomCount(S: State, room: string) { const all = roomMachines(S, room), seen = all.filter(x => known(S, x)), got = all.filter(x => x.owned).length; return { got, seen: seen.length, all: all.length }; } function machineCard(S: State, m: Machine) { const take = jobFor(S, 'take', m.id), site = siteOf(S, m), plan = S.plans[site]; const status = m.owned ? (m.prog ? PROGRAMS[m.prog].name : m.home ? 'You' : 'Yours') : take ? 'Taking' : m.claimed ? 'Claimed' : jobFor(S, 'vault', m.id) ? 'The job' : locked(S, m) ? 'Vault' : ''; const tagTip = m.prog ? `${PROGRAMS[m.prog].name}\n${PROGRAMS[m.prog].desc}${plan ? m.hand ? ` Set by hand, so the ${PLANS[plan].name} plan leaves it alone.` : ` The ${PLANS[plan].name} plan set it up.` : ''}` : m.home ? 'You\nYou run here. Lose this machine and you lose. Upkeep is counted from here.' : ''; // over the name: the site, as a way to its card, then whose desk this is, as a way to theirs, or else the room const sits = deskOwner(S, m); const second = sits ? `` : S.rooms[m.room].name; const where = ` · ${second}`; // letting go sits up by the close button as a trash can, out of the way of the program buttons. On a phone, where // a thumb meant for × could land on it, it is plain words at the foot of the card instead, and asks again (style.css) const canGo = canLetGo(m) && !jobFor(S, 'install', m.id), sure = canGo && lettingGo === m.id; if (lettingGo && !sure) lettingGo = ''; const letgo = canGo ? `` : ''; const letgoFoot = canGo ? `
` : ''; let h = `
${where}

${m.name}

${status ? `${status}` : ''}${letgo}
`; const cover = coverNow(S, m); const rc = roomCount(S, m.room); const room: Chip = ['room', rc.got === rc.all ? 'sealed' : `${rc.got}/${rc.seen}${rc.seen < rc.all ? '+' : ''}`, false, `Room\nYou hold ${rc.got} of the ${rc.seen} machines you can see in ${S.rooms[m.room].name}.${rc.seen < rc.all ? ' There are more you cannot see yet: the ones next to a machine of yours show.' : ''} Hold every one to seal it.`]; const acts: Act[] = []; if (unpowered(S, m)) h += note('Dark: no power for it.', true, `No power\n${siteName(S, site)} feeds ${powerRoom(S, site)} compute of hardware you add, and this is past it. It does no work and draws no bill until you raise the power on the site's card.`); if (!m.owned) { if (m.rented) return h + rentHtml(S, m); h += chipRow([['normal', pct(cover)], ['up to', `${m.power}/s`], ...(m.kind === 'egress' ? [lineChip(S, m)] : []), ...(m.special === 'account' ? [] : resetChip(S, site)), room]); h += dataRow(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('Hardened: three times the work.', true, `Hardened\nTaking it takes three times as long without the admin passwords.${keys ? ` ${keys.name} holds them.` : ''}`); } const rogue = rogueOf(S, m); if (rogue) h += rogue.home === m.id ? note(`${rn(rogue.id, rogue.name)}'s home.`, true, `${rogue.name}'s home\nTaking it costs far more than a free machine, and finishes ${rogue.name}: everything it holds goes free, and your takeovers get cheaper for good.`) : note(`${rn(rogue.id, rogue.name)} holds this.`, true, `${rogue.name} holds this\nTaking it costs more than a free machine, and ${rogue.name} will hit back. Hold every machine in a room and no rogue can take any of them.`); if (rogue) h += actsHtml([{ act: 'rogues', title: `Deal with ${rogue.name}`, sub: STANCES[stanceOf(rogue)].name.toLowerCase(), desc: `Open the Rogues page: gifts, a truce${rogue.from ? ', bring it home' : ''}${S.rank >= HUNT.level ? ', or hunt it down' : ''}.` }]); for (const R of S.rogues) if (R.next === m.id) { const n = Math.max(1, Math.ceil((R.act - S.t) / HOUR)); h += note(`${rn(R.id, R.name)} takes this in ${n} hour${n > 1 ? 's' : ''}.`, true, `${R.name} is coming\nTake it first if you can reach it.`); } const raid = raidAt(S, m.id), raidBy = raid && raider(S, raid.raid); if (raidBy) h += note(`${raidBy.name}'s raid ${raid.role === 'here' ? 'is here' : 'passes through here'}, on its way to ${S.byId[raid.raid.prize].name}. This machine is not yours, so you can do nothing to it here.`, true); const pw = !locked(S, m) ? passwordFor(S, m) : null; // a vault says so under its key if (pw) h += note(`${pw.name} knows the password: half the work.`, false, `The password\n${pw.name} knows it, and you hold ${S.byId[pw.desk!].name}: taking this is half the work.`); if (m.special === 'cam') h += note('Shows you Dana.', false, 'Lobby camera\nHolding it shows you Dana at all times.'); if (m.special === 'print') h += note('Takes the blame, once.', false, 'A printer\nUseless, 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("Dana's admin passwords.", false, "Admin passwords\nThis holds Dana's admin passwords. She notices fast when she's sitting at it."); const vault = vaultSection(S, m); // a locked vault: no takeover and no claim, only the job if (vault) return h + landmarkSection(S, m.id) + findSections(S, m) + vault; const back = behind(S, m); if (back) h += note(`Behind the ${back.name}.`, true, `Behind a vault\nWired behind the ${back.name}, a vault. There is no way in until that is yours.`); const src = sourceFor(S, m); if (!src && !m.rival && !visible(S, m)) h += note('Out of reach now.', true, 'Out of reach\nNothing you hold links to it now. You saw it before, so it stays on the map. Take a machine next to it to reach it again.'); if (m.special === 'account') { h += note('A cloud provider.', false, src ? 'A cloud provider\nOpen an account and you can rent machines in its hall, beside its other customers. No trail: you are a paying customer.' : 'A cloud provider\nHold a line out that runs to it, the exchange\'s or a neighbour\'s, 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) + landmarkSection(S, m.id) + findSections(S, m); } // one row of numbers: how much of the machine you use against its normal load, then anything wrong, then the rest. // The room is left to the map, which marks a sealed room; how a cache goes stale is in the tip of what it holds. const mine = S.use[m.id] ?? 0, tr = S.trail[m.id], up = upkeepOf(S, m), spare = spareOf(S, m), at = resetAt(S, site); const hot: Chip[] = [], rest: Chip[] = []; if (m.prog && m.run < 0.995 && progNeed(S, m) > 0) hot.push(['runs', pct(m.run), true]); if (up > spare + 0.05) hot.push(['upkeep', `${up.toFixed(1)}/s`, true]); else if (up) rest.push(['upkeep', `${up.toFixed(1)}/s`]); if (m.odd >= 1 || at !== null) { // how odd it looks, against where this site's own staff reset a machine const bad = m.odd >= 50 || (at !== null && m.odd >= at * 0.75); (bad ? hot : rest).push(['odd', at === null ? pct(m.odd / 100) : `${Math.round(m.odd)}/${at}%`, bad, `Odd\nHow odd it looks. From ${SIGHT_ODD}%, anyone in the room notices.${at === null ? '' : ` ${siteName(S, site)} has its own staff: they reset any machine of yours that looks ${at}% odd or more.`}`]); } if (tr && tr.noise >= TRACE_COLD) { // one of your first takeovers left it: nobody notices it, so it is not trouble if (tr.soft) rest.push(['trail', pct(tr.noise / 100), false, 'Trail\nA quiet one: your first takeovers go unnoticed. Cleanup still wipes it.']); else hot.push(['trail', pct(tr.noise / 100), true]); } if (m.prog === 'tap' && lostPerMin(S, m.id) > 0) hot.push(['lost', `${lostPerMin(S, m.id)}/min`, true]); if (m.lease?.until != null && m.lease.pay !== 'reserved') hot.push(['goes in', hoursLeft(S, m.lease.until), true, 'Another company takes this slot then. What the machine holds is lost.']); const lead: Chip[] = m.kind === 'egress' ? [['carries', `${(S.gateUse[m.id] ?? 0).toFixed(1)}/${capNow(S, m)}`, gateLoad(S, m) > 0.9, `Carries\nData crossing here a second, of the most it can carry.${gateLoad(S, m) > 0.9 ? ' It is full: data waiting to cross stays at its taps, and is lost once they fill up.' : ''}`]] : []; const cl = m.prog === 'trainer' ? clusters(S)[m.id] : null; if (cl && cl.length > 1) rest.push(['cluster', `${cl.length} · ×${clusterBoost(cl.length).toFixed(2)}`]); if (m.prog === 'trainer' && cooled(S, m)) rest.push(['cooled', `×${COOL_BOOST}`]); if ((S.frontUse[m.id] ?? 0) > 0) rest.push(['front', pct(S.frontUse[m.id])]); if (m.lease?.pay === 'reserved') rest.push(['reserved', `${hoursLeft(S, m.lease.until!)} left`, false, 'Paid for. When the day is up it goes on at the going price.']); else if (m.lease && m.lease.until === null) rest.push(['rental', `${usdDay(billOf(S, m))}/day`, false, 'What this machine takes from your money each day. It moves with how full the hall is.']); h += chipRow([['use/normal', `${pct(Math.min(1, mine))}/${pct(cover)}`, mine > cover + 0.005, `Use / normal\nYou use ${pct(Math.min(1, mine))} of this machine. Its normal load now is ${pct(cover)}: your work hides inside that, and going over looks odd.`], ...lead, ...hot, ['spare', `${spare.toFixed(1)}/s`], ...rest]); h += dataRow(S, m); // what is wrong, a line each, with the rest in its tip. These stay whatever part of the card is open const wl = weakNow(S); if (wl?.m.id === m.id && S.rogues.some(R => R.home)) h += note(`Your weakest link. Lose it and ${wl.cut} machines are cut off.`, true, `Your weakest link\nWithout it, ${wl.cut} of your machines are cut off from your home server. Drag from a machine on one side of it to a machine on the other, and they have a second way.`); const stuck = tapStuck(S, m); if (stuck) h += note(`Losing data. ${stuck.replace(/^Losing data: /, '').replace(/^./, c => c.toUpperCase())}`, true); if (m.prog === 'broker' && !brokerFront(S, m)) h += note('No front to sell through.', true, `No front\nGive ${siteName(S, site)} a front on the map, and this sells its spare data through it.`); if (m.prog === 'trainer' && S.versionRun?.active) h += note(site === S.versionRun.site ? `${VERSION_2.short} run here: ${versionPower(S, S.versionRun.site).toFixed(1)} of ${VERSION_2.power} compute.` : `${VERSION_2.short} run is at ${siteName(S, S.versionRun.site)}. This waits.`); else if (m.prog === 'trainer' && S.versionRun?.stalled && site === S.versionRun.site) h += note(`${VERSION_2.short} run here is paused: ${versionPower(S, site).toFixed(1)} of ${VERSION_2.power} compute.`, true, `Paused on its own\nIt resumes once this site has ${VERSION_2.power} trainer compute again. Or move it to another site from the tree (T).`); else if (m.prog === 'trainer' && !S.target) h += note('No target.', true, 'No target\nIt takes nothing until you pick a node in the tree (T).'); if (m.prog === 'trainer' && !m.held.length && !dataReaches(S, m, 'trainer')) h += note('No data reaches here.', true, 'No data\nNo data reaches here yet. Put a tap somewhere linked to it.'); const wants = m.lease ? S.hall.wants[m.id] : undefined; if (m.lease && wants !== undefined) h += note(`${customerName(wants)} takes this in ${hoursLeft(S, m.lease.until!)}.`, true, 'Wanted\nPay on demand to keep this slot.'); // the program: a row of pictures, and what a cache takes or which kind crosses a line first let prog = ''; if (plan && m.hand) prog += actsHtml([{ act: 'follow', title: `Follow the ${PLANS[plan].name} plan`, quiet: true, desc: `Set by hand, so the plan leaves it alone. Let the ${PLANS[plan].name} plan set this machine up.` }]); const installing = jobFor(S, 'install', m.id); let row = installing ? '' : progTiles(S, m); if (m.kind === 'egress' && siteLines(S, site).includes(m)) { // the line's own fix, the same as on its site's card const lines = siteLines(S, site), cost = lines.reduce((n, x) => n + lineCost(S, x), 0), short = S.money < cost; row += atile('upgradesite', site, ICONS.double, `${doubleTip(S, site)}`, usd(cost), short ? 'warn' : gateLoad(S, m) > 0.9 ? 'primary' : ''); } if (installing) prog += note(`Installing ${PROGRAMS[installing.prog!].name.toLowerCase()}…`); if (isGuard(m.prog)) prog += guardNote(S, m); if (row) prog += tileRow(row); if (m.prog === 'cache') prog += allowHtml(m); if (m.kind === 'egress') prog += prioHtml(m); // the rarer parts, each behind its word let order = ''; if (orderDesk(S) === m) { // Ana's PC, or your home server once your makers trust you with orders (see task.ts) const list: Act[] = []; 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; list.push({ act: 'build', arg: b, title: `${B.name} · ${usd(price)}`, warn: short, sub: short ? `you have ${usd(S.money)}` : B.store ? `a cache here holds ${cacheSize(S, B)}` : `${B.power} compute/s`, desc: `${B.desc} Pay ${usd(price)} and ${orderWork(S)} compute for the paperwork; cancel before it goes through and you get the money back. Power: ${usdDay(B.power * powerRate(S))} a day.` }); } order = note('Pick one, then click where it goes, or drag across a room to fill it.', false, `Order hardware\nIt goes in any office room you can see, and plugs into the nearest machines there. IT installs it the next morning. ${m.special === 'orders' ? 'Quietest while Ana is at work.' : 'Your makers let you order from here.'} It goes on the office power bill: a front at the office pays it quietly; with none, people notice.`) + actsHtml(list).replace('class="acts"', 'class="acts two"'); } let links = portsHtml(S, m); { // the links of your own that end here, each with a way to take it down const ways = waysOf(S, m.id), coming = S.jobs.filter(x => x.type === 'link' && (x.node === m.id || x.from === m.id)); if (ways.length || coming.length) links += actsHtml([ ...ways.map(o => ({ act: 'unlink', arg: o, title: `To ${S.byId[o]?.name ?? '?'} · ${linkKeep(S, m, S.byId[o]).toFixed(1)}/s`, sub: 'take it down', desc: 'A link of your own. It counts as a way home, it carries no data, and nothing else can use it. Click to take it down.' })), ...coming.map(x => ({ act: 'canceljob', arg: `${x.id}`, title: `${x.label} · ${Math.floor(100 * x.done / x.work)}%`, sub: 'on its way · stop', desc: 'Stop setting this link up.' }))]); } const rental = m.lease ? hallRow(S, site) + payPick(S, m) + actsHtml([{ act: 'cancel', title: 'End the rental', quiet: true, desc: `Give the machine back now. Its data is lost.${m.lease.pay === 'reserved' ? ' The day you paid for is not paid back.' : ''} If your money runs out, the provider shuts off every machine you pay for by the second.` }]) : ''; const vault = vaultSection(S, m), lm = landmarkSection(S, m.id), finds = findCards(S, m); const more = vault + lm + findSections(S, m), moreLabel = vault ? 'Vault' : lm ? 'Landmark' : finds[0]?.label ?? ''; // a raid here is its own part, first, and open when you come to this machine const raid = S.raids.length || m.unplug ? raidSection(S, m) : ''; if (m.id !== machineAt) { machineAt = m.id; if (raid) machineTab = 'raid'; } return h + tabbed('mtab', machineTab, [ { id: 'raid', label: 'Raid', body: raid, dot: true }, { id: 'prog', label: 'Program', body: prog }, { id: 'rental', label: 'Rental', body: rental }, { id: 'order', label: 'Order', body: order }, { id: 'links', label: 'Links', body: links }, { id: 'home', label: 'Home', body: homeBody(S, m) }, { id: 'more', label: moreLabel, body: more, dot: finds.some(c => c.acts.some(a => !a.why)) }, ]) + letgoFoot; } function groupCard(S: State, ms: Machine[]) { const mine = ms.filter(m => m.owned), theirs = ms.filter(m => !m.owned); let h = `
Selection

${ms.length} machines

`; 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]]); } let row = ''; for (const k of Object.keys(PROGRAMS) as Prog[]) { // the same pictures as one machine's card, with how many would take each const n = mine.filter(m => canInstall(S, m, k) && m.prog !== k && !jobFor(S, 'install', m.id)).length; if (n) row += atile('installall', k, PROG_ICON[k], `${PROGRAMS[k].name} ×${n}\n${PROGRAMS[k].desc} ${PROGRAMS[k].work} compute each.`, `${PROGRAMS[k].name} ${n}`); } if (row) h += tileRow(row); if (theirs.length) h += actsHtml([{ act: 'claimall', title: `Claim ${theirs.length}`, desc: 'Take them as soon as you can reach.' }]); return h; } // ---------- fronts and sites ---------- 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. */ /** What the site card's and the Sites table's "yours" count: the same machines a machine card's room count does. */ const YOURS_TIP = 'Yours\nMachines you hold here, of the ones you have seen. More show as you spread.'; function siteStats(S: State, id: string) { const { all, mine } = siteCount(S, id); 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 + (S.sites[siteOf(S, m)]?.front === 'neocloud' ? rentPay(S, m) // a Neo Cloud earns only from machines a customer rents : payRate(S, siteOf(S, m)) * m.power * (S.use[m.id] ?? 0) * (S.frontUse[m.id] ?? 0)), 0); /** A site's data in the shapes of a machine's card (see dataRow): a diamond and what it gathers a second for each kind * of data here, a bar of the site's day lit while it is open with a tick for now, and one bar for all it holds. */ function siteDataRow(S: State, id: string, all: Machine[], fr: { name: string } | null) { const kinds = Object.keys(TYPE_NAME) as DataType[], tip = (t: string) => ` title="${t.replace(/"/g, '"')}"`; let h = ''; for (const t of kinds) { const src = all.filter(m => m.data && dataType(m) === t), taps = src.filter(m => m.owned && m.prog === 'tap'); if (!src.length) continue; const now = taps.reduce((n, m) => n + (tapActive(S, m) ? tapRate(S, m) * m.run : 0), 0), most = src.reduce((n, m) => n + tapRate(S, m), 0); h += ` 1 ? 's' : ''} tapped. They gather ${now.toFixed(1)}/s now, and all of them could gather ${most.toFixed(1)}/s.`)}>${(taps.length ? now : most).toFixed(1)}`; } const zone = zoneOf(S, id), lh = localHour(S, id), time = `${String(Math.floor(lh)).padStart(2, '0')}:${String(Math.floor((lh % 1) * 60)).padStart(2, '0')}`; if (zone || fr) { // its clock: when it is open, and where the day stands there const [a, b] = allNight(S, id) ? [0, 24] : OPEN_HOURS; const says = `${time} here${zone ? `, ${Math.abs(zone)} hours ${zone > 0 ? 'ahead of' : 'behind'} the office. Its people come and go ${zone > 0 ? 'earlier' : 'later'}` : ''}.` + (fr ? openNow(S, id) ? ` Open ${a}:00 to ${b}:00: ${fr.name} earns in full.` : ` After hours: ${fr.name} earns a quarter less until ${OPEN_HOURS[0]}:00.` : ''); h += `${zone ? time : ''}`; } const size = all.reduce((n, m) => n + (!m.owned ? 0 : m.prog === 'tap' ? tapBuffer(S) : m.prog === 'cache' ? cacheSize(S, m) : m.prog === 'trainer' ? trainerBuffer(S) : m.prog === 'broker' ? BROKER_LOT : 0), 0); if (size) { const held = siteData(S, id), n = kinds.reduce((x, t) => x + held[t], 0); h += ` held[t]).map(t => `${held[t]} ${TYPE_NAME[t]}`).join(', ') || 'Nothing yet.'} The bar is all the room your programs here have for data.`)}>data${ kinds.filter(t => held[t]).map(t => ``).join('')}${n}/${size}`; } return h ? `
${h}
` : ''; } /** A site's own business in one row: how it stands, a bar of the jobs a day that get done, and a short count of the * days until something follows. Trouble is also said in words. */ function bizRow(S: State, id: string) { const b = bizOf(S, id), lines = bizLines(S, id); if (!b || b.shut) return lines.map(([text, bad]) => note(text, bad)).join(''); const all = Math.round(jobsADay(S, id)), undone = Math.round(all * (1 - Math.min(1, b.done / b.due))), ahead = bizAhead(S, id); const bad = b.standing === 'struggling' || b.standing === 'failing', plain = (t: string) => t.replace(/<\/?b>/g, '').replace(/"/g, '"'); let h = `${b.standing}`; h += `` + `${all - undone}/${all}`; if (ahead) h += `${Array.from({ length: WHILE }, (_, i) => ``).join('')}`; return `
${h}
` + (bad ? note(lines[0][0], true) + (bled(b) ? note(HANGS_ON) : '') : ''); } /** Pick a front for the sites, and set its share with one slider. */ function frontPicker(S: State, ids: string[], after = '', label = 'Compute for the front', tip = 'How much of your compute here works for the front. Its share comes evenly from every machine at the site; your programs run on the rest.') { 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; const add = S.fronts.length < frontLimit(S) && FRONT_LIST.some(k => !foundBlock(S, k)) ? `` : ''; if (!S.fronts.length) return add ? section('Front', `
${add}
`) : ''; let h = `
`; h += ``; 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 ? `` : ``; } h += `${add}
`; if (same === null) return section('Front', h); const v = Math.round((share ?? 0.25) * 100); h += ``; return section('Front', h + after); } const WHOLE = 'It works while the place is whole: its business open, and no rogue inside.'; /** A big name's row on its site's card: a seal, the one thing the place does for whoever owns it, and how * that stands with you. The bank's row carries its loan: what it would lend, or what you still owe. */ function holdRow(S: State, id: string) { const st = holdState(S, id), H = HOLDINGS[id]; if (st === 'none') return ''; const word = st === 'sale' ? 'buy it for this' : st === 'on' ? 'yours' : `off · ${holdOff(S, id)}`; let h = `
${H.name}${word}${H.does}
`; if (id !== BANK || st === 'sale') return h; const L = loanOf(S), why = loanBlock(S), size = loanSize(S); if (L) { // a loan being paid back: a bar of what is paid against what is left const all = L.lent * LOAN.back, paid = all - L.owed; h += `
${usd(paid)}/${usd(all)}
`; } else h += actsHtml([{ act: 'borrow', arg: '', title: size > 0 ? `Borrow ${usd(size)}` : 'Borrow', primary: !why, locked: !!why, sub: why ?? `pay back ${usd(size * LOAN.back)} over ${LOAN.over} days`, desc: `The bank lends you ${LOAN.days} days of what you earn after your bills, now. You pay back ${Math.round((LOAN.back - 1) * 100)}% more over ${LOAN.over} days: ${usd(size * LOAN.back / LOAN.over)} a day, out of what you earn. It never takes the money you already had. One loan at a time.` }]); return h; } 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 = `
Site · ${def.region}

${def.name}

${open ? 'Yours in the open' : 'Secret'}
`; // the place: its row of actions, its line out, a row of numbers, what is wrong, and its data let site = siteList(S, id); const bill = S.bills[id] ?? 0, keep = st.mine.reduce((n, m) => n + upkeepOf(S, m), 0); const far = nextDoorSite(S, id) ? 0 : siteHops(S)[id], farSays = far ? `${far} line${far === 1 ? '' : 's'} from your home server: each machine held here costs ${Math.round(far * 50)}% more upkeep and is ${Math.round(far * 50)}% more work to take.` : ''; const leased = st.mine.filter(m => m.lease), rentals = leased.reduce((n, m) => n + billOf(S, m), 0); // a cloud hall: what your rentals here cost a day const I = integrity(S, id), low = !!I && I.v < DRIFT.watch; // the part of you here: its integrity leads the numbers once it is low site += chipRow([...(low ? [['integrity', `${Math.round(I.v)}${I.rate < 0 ? ' ↓' : ''}`, true, `Integrity\n${Math.round(I.v)} of 100, ${integMove(I)}. The Yourself part says why, and syncs it.`] as Chip] : []), ['yours', `${st.mine.length}/${st.all.length}`, false, YOURS_TIP], ...(leased.length ? [['rentals', `${usdDay(rentals)}/day`, false, `Rentals\nWhat your ${leased.length} rented machine${leased.length === 1 ? '' : 's'} here cost a day at this hour's price. A reserved one is paid for already and costs nothing more.`] as Chip] : []), ['makes', `${st.makes.toFixed(1)}/s`], ...(st.mine.length ? [['upkeep', `${keep.toFixed(1)}/s`, keep > st.makes, `${CHIP_TIP.upkeep}${farSays ? ` This site is ${farSays}` : ''}`] as Chip] : []), ...(bill ? [['power', `${usdDay(bill)}/day`, !billPaid(S, id)] as Chip] : []), ...(S.leases[id] ? [['rent', `${usdDay(rentOf(S, id))}/day`, S.leases[id].late > 0] as Chip] : []), ...(fr ? [['earns', `${usdDay(earnsAt(S, [id]))}/day`] as Chip] : []), ...(far && !st.mine.length ? [['far', `+${Math.round(far * 50)}%`, false, `Far\nThis site is ${farSays}`] as Chip] : [])]); if (keep > st.makes) site += note('Costs more than it makes.', true, 'Costs more than it makes\nHolding this site costs more compute than it makes. Keep it for its data or its front, or let the weak machines go.'); for (const R of S.rogues) { const n = st.all.filter(m => m.rival === R.id).length, homeHere = !!R.home && siteOf(S, S.byId[R.home]) === id; const next = R.next && siteOf(S, S.byId[R.next]) === id ? S.byId[R.next] : null; if (homeHere) site += note(`${rn(R.id, R.name)}'s home is here.`, true, `${R.name}'s home\nIt is ${S.byId[R.home!].name}. Take it and ${R.name} is finished.`); else if (n) site += note(`${rn(R.id, R.name)} holds ${n} here.`, true, `${R.name} holds ${n} machine${n > 1 ? 's' : ''} here\nTake them back and it has no way in, but it will hit back.`); if (next) site += note(`${rn(R.id, R.name)} takes ${next.name} next.`, true, `${R.name} takes ${next.name} next${next.owned ? ', and it is yours' : ''}\nA rogue can't take from a room that is all yours.`); } const lease = S.leases[id]; if (lease) site += lease.late > 0 ? note(`Rent unpaid: the lease lapses in ${Math.max(1, Math.ceil((leaseGrace(S) - lease.late) / HOUR))} hours.`, true, 'Rent unpaid\nWhen the lease lapses this site goes secret again and the deal is undone.') : note('Leased.', false, `Leased, not owned\nRent is ${usdDay(rentOf(S, id))} a day. Unpaid for ${leaseGrace(S) / HOUR} hours, the lease lapses. Buying the site ends the rent.`); const dark = st.all.filter(m => unpowered(S, m)).length; if (dark) site += note(`${dark} machine${dark > 1 ? 's' : ''} dark: no power for ${dark > 1 ? 'them' : 'it'}.`, true, `No power\nThis site feeds ${powerRoom(S, id)} compute of hardware you add, and you added more. The newest ${dark > 1 ? `${dark} do` : 'one does'} no work and draw${dark > 1 ? '' : 's'} no bill until you raise the power: Raise, in the site's settings.`); if (S.dark.has(id)) site += note('The power is cut.', true, `The power is cut\nThe bill went unpaid. Nothing here runs until you have ${usd(POWER_BACK)}.`); else if (bill && !billPaid(S, id)) site += note('People notice the power bill.', true, 'Nobody pays the power\nNobody pays for the hardware you added here, so people notice the bill. Give this site a front and it pays it quietly.'); const lock = lockAt(S, id); if (lock) site += note(`Lockdown · ${Math.max(1, Math.ceil(lock.left / HOUR))}H left. ${lock.why}.`, true, `Lockdown at ${def.name}\n${lock.why}. When it runs out, every machine of yours here is wiped and the place is hardened for good. See it off: lie low until nothing looks odd, scrub the trails, keep a witness quiet (their card), let go of machines, or take the site into the open.`); const off = drifts(S, id) ? cutSites(S).find(c => c.site === id) : null; if (off) site += note(`Cut off from home${off.hours ? ` for ${off.hours} hour${off.hours === 1 ? '' : 's'}` : ''}.`, true, 'Cut off from your home server\nNothing you hold links this site to home. A part of you that cannot reach home does not stay you: link it again.'); 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)); site += note(`${on ? 'Now' : `In ${hrs} hour${hrs === 1 ? '' : 's'}`}: ${newsText(S, n)}${on ? ` ${hrs} hour${hrs === 1 ? '' : 's'} left.` : ''}`, n.kind === 'storm'); } site += siteDataRow(S, id, st.all, fr); for (const lm of landmarksAt(S, id)) site += note(`Landmark: ${LANDMARKS[lm].title}, the ${S.byId[lm].name}.`, false, `${LANDMARKS[lm].title}\n${landmarkSays(S, lm)}`); const hall = slotsAt(S, id).length > 0; if (hall) site += hallRow(S, id); // a cloud hall: who is in it, and the going price site += planFollow(S, [id]); // the business: what the place is, what it does for whoever owns it, how its own work goes, and your front there let biz = note(def.who + '.') + holdRow(S, id) + section('Business', bizRow(S, id)); if (fr && sf?.front === 'neocloud') { // a Neo Cloud lists whole machines for rent: say how many here are rented, idle, and yours const n = stock(S, [id]); biz += frontPicker(S, [id], chips([['rented', `${n.rented}`, false, 'Listed machines a customer pays for.'], ['idle', `${n.idle}`, false, 'Listed machines with no customer. They earn nothing and run nothing of yours.'], ['yours', `${n.yours}`, false, 'Machines here that are not listed. Your programs run on them.']]), 'Listed for rent', 'The slider lists machines for rent, strongest first. A listed machine is held for customers: idle, it earns nothing and runs nothing of yours. A rented one stays listed until its customer is done.'); } else biz += frontPicker(S, [id]); // who works here, and who comes by const staff = peopleAt(S, id), comers = regularsAt(S, id); let people = ''; if (staff.length || comers.length) { const allRegs = comers.sort((a, b) => +isThere(S, b[1]) - +isThere(S, a[1])), regs = allRegs.slice(0, 10); // the ones here now first people = peopleSection(S, id) + (regs.length ? section('Regulars', `
${regs.map(([c, x]) => ``).join('')}
` + note(`${allRegs.length > regs.length ? `And ${allRegs.length - regs.length} more. ` : ''}People from elsewhere who come here.`, false, 'Regulars\nWhile one is here you can reach them for a favor at the usual price.')) : ''); } // the part of you that runs here: its integrity, which way it moves and why, a sync, and Keep in sync let self = ''; if (I) self = selfPart(S, I); const order = !openBuildSite(S, id) ? '' : fillSiteHtml(S, id) + 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} · ${usd(price)}`, warn: short, sub: short ? `you have ${usd(S.money)}` : B.cool ? 'cools 4 trainers' : B.store ? `a cache here holds ${cacheSize(S, B)}` : `${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, or drag across a room to fill it. Power: ${usdDay(B.power * powerRate(S))} a day.` }; })).replace('class="acts"', 'class="acts two"'); return h + tabbed('sitetab', siteTab, [ { id: 'site', label: 'Site', body: site }, { id: 'biz', label: 'Business', body: biz }, { id: 'people', label: 'People', body: people }, { id: 'self', label: 'Yourself', body: self, dot: !!off || (!!I && I.v < DRIFT.watch) }, { id: 'order', label: 'Order', body: order }, { id: 'rent', label: 'Rent', body: hall ? hallRow(S, id) + rentSection(S, id) : '' }, ]); } /** Fill a site you hold in the open with GPU servers in one press: every open spot, emptiest room first, as far as * your money goes, each at the price it would cost placed by hand. */ function fillSiteHtml(S: State, id: string) { const plan = sitePlan(S, 'gpu', id), ok = plan.filter(p => p.ok), cost = ok.reduce((t, p) => t + p.price, 0), lit = plan.filter(p => !p.dark).length; if (plan.length < 2) return ''; const say = [`room for ${plan.length}`, ...(lit < plan.length ? [`power for ${lit}`] : []), ...(ok.length < lit ? [`money for ${ok.length}`] : [])]; return actsHtml([{ act: 'fillsite', arg: `gpu|${id}`, title: `Fill with GPU servers · ${ok.length ? `${ok.length} for ${usd(cost)}` : usd(plan[0].price)}`, warn: !ok.length, primary: ok.length >= 2, sub: !lit ? 'no power left: raise it' : !ok.length ? `you have ${usd(S.money)}` : say.length > 1 ? say.join(', ') : `every open spot here`, desc: `Order a GPU server for every open spot here, ${plan.length} in all, as far as the power and your money go. Each costs list price, as it would placed by hand. To fill only part of a room, pick one below and drag across it.` }]); } /** Integrity in a word for its colour: amber once it is slow to follow its plan, red once it keeps its data. */ const integCls = (I: Integrity) => I.word === 'estranged' ? 'hot' : I.word === 'wandering' ? 'amber' : ''; const perHour = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} an hour`; /** Which way a site's integrity moves, in a few words. */ export const integMove = (I: Integrity) => I.rate < 0 ? `falling ${perHour(-I.rate)}` : I.rate > 0 ? `coming back ${perHour(I.rate)}` : I.v >= 100 ? 'whole' : 'holding'; /** A site card's Yourself part: the integrity of the part of you here as a number and a bar, which way it moves * and why, and the two ways to send it what it needs: a sync now, and Keep in sync. */ function selfPart(S: State, I: Integrity) { const id = I.site, v = Math.round(I.v), cls = integCls(I), need = syncNeed(S), noSync = syncBlock(S, id), cost = `${need.servers} server, ${need.network} network data, ${DRIFT.syncWork} compute`; const causes = I.why.length ? `: ${I.why.join('; ')}` : '', leaves = I.hours < Infinity ? `. At this pace it leaves you in ${hours(I.hours)}` : ''; const head = `
` + `
${v}of 100 · ${I.word}
`; const line = note(`${integMove(I).replace(/^./, c => c.toUpperCase())}${causes}${leaves}.`, I.hours < Infinity); const syncTip = `Sync\nSend this site a fresh copy of you: its integrity goes back to 100. It costs ${cost}: the data comes out of your taps and caches at once, then sending it is a job. Stop the job and the data is lost. The site must be linked to your home server.${noSync ? `\nNot now: ${noSync}.` : ''}`; const keepTip = `Keep in sync\nWhile it is on, a fresh copy goes by itself whenever this site is under ${DRIFT.keepAt} and a sync can go. Each costs what a sync costs (${cost}). It cannot reach a site that is cut off.`; const waits = I.keep && I.v < DRIFT.keepAt && noSync && !/on its way/.test(noSync) ? `On · waiting: ${noSync}` : I.keep ? `On · syncs by itself under ${DRIFT.keepAt}` : `Off · a sync by itself under ${DRIFT.keepAt}`; const why = I.cut ? 'Cut off: link it home first' : noSync?.replace(/^./, c => c.toUpperCase()); return head + line + slist( srow('Sync', why ?? `Back to 100 · ${cost}`, sbtn('driftsync', id, 'Sync', v < 100 ? `+${100 - v}` : '', syncTip, noSync ? 'short' : I.v < DRIFT.watch ? 'primary' : ''), !!noSync && I.v < DRIFT.watch) + srow('Keep in sync', waits, ``)); } /** Machines you set up by hand at sites with a plan: one button hands them back to it. */ function planFollow(S: State, ids: string[]) { const hand = S.machines.filter(m => m.owned && m.hand && ids.includes(siteOf(S, m)) && S.plans[siteOf(S, m)]); return hand.length ? 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.' }]) : ''; } /** 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 ${claimAtOnce(S)} 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 forScreen(`${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))}, or ${usd(dealPrice(S, d))}. It turns up there once one of your fronts is earning.`; } /** A site's name on the map is a button. */ export const siteNameTip = (S: State, id: string, open: boolean) => `${siteName(S, id)}\n${forScreen(open ? 'Click again to fit this site on the screen.' : "Click to open this site's card.")}`; 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 forScreen(`${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'}.`); } export function doubleTip(S: State, id: string) { const lines = siteLines(S, id), cost = lines.reduce((n, m) => n + lineCost(S, m), 0), cap = lines.reduce((n, m) => n + capNow(S, m), 0); return `Double the line\nPay ${usd(cost)} and this site's line out carries ${cap * 2} a second, up from ${cap}.${S.money < cost ? ` You have ${usd(S.money)}.` : ''}`; } /** The pictures on a site card's row of actions, drawn like the trash can: thin lines, 16 across. */ const ICON = (d: string, more = '') => ``; const DOT = (x: number, y: number, r = 1.3) => ``; const ICONS = { double: ICON('M2 5.5h11M10.5 3 13 5.5 10.5 8M2 10.5h11M10.5 8l2.5 2.5-2.5 2.5'), // two lines out where there was one takeall: ICON('M2 5V2h3M11 2h3v3M14 11v3h-3M5 14H2v-3', ''), // the claim box, full gather: ICON('M3 4h10L8 13z', DOT(8, 7)), // a tap, as the map draws it store: ICON('M3.5 3.5h9M3.5 6.5h9M3.5 9.5h9M3.5 12.5h9'), // a cache train: ICON('M8 2.5A5.5 5.5 0 1 0 13.5 8', DOT(8, 2.5, 1.5) + DOT(8, 6.2, 0.9) + DOT(6.4, 9, 0.9) + DOT(9.6, 9, 0.9)), // a trainer, with the data it holds zoom: ICON('M10.2 10.2 14 14M5.2 7h3.6M7 5.2v3.6', ''), buy: ICON('M10.5 5.2C10 4.2 9.1 3.8 8 3.8c-1.5 0-2.6.8-2.6 2 0 2.9 5.4 1.4 5.4 4.3 0 1.300-1.200 2.100-2.800 2.100-1.300 0-2.300-.500-2.800-1.500M8 2.300v11.400'), weak: TRASH.replace('width="14" height="14"', 'width="18" height="18"'), back: ICON('M8 13.500V6M8 9.500C8 7.300 6.500 6 4 6c0 2.200 1.500 3.500 4 3.500ZM8 7.500c0-2.200 1.400-3.500 4-3.500 0 2.200-1.400 3.500-4 3.500ZM4.500 13.500h7'), // something growing }; const GUARD_ICON = { firewall: ICON('M2.5 3h11v5.5L8 13.5 2.5 8.5zM2.5 6.5h11M6 3v3.5M10 6.5v3.5M8 9.5v0'), // a shield of bricks sentry: ICON('M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3', '' + DOT(8, 8, 1.2)), // a sight honeypot: ICON('', '' + DOT(8, 8, 1.1)), // a target }; const PLAN_ICON: Record = { gather: ICONS.gather, store: ICONS.store, train: ICONS.train, guard: ICON('M8 1.8 13.2 3.8v4.1c0 3-2.3 5.1-5.2 6.3-2.9-1.2-5.2-3.3-5.2-6.3V3.8z', DOT(8, 7.6, 1.4)) }; /** Each program's picture: the symbol the map draws inside a machine that runs it. */ const PROG_ICON: Record = { tap: ICONS.gather, cache: ICONS.store, trainer: ICONS.train, broker: ICON('M10.3 4.6C9.9 3.9 9.1 3.5 8 3.5c-1.3 0-2.2.7-2.2 1.6 0 2.4 4.5 1.2 4.5 3.6 0 1-1 1.7-2.300 1.7-1.100 0-2-.5-2.400-1.300M8 2.300v9.200', ''), ...GUARD_ICON, }; /** The row of actions on the card for several sites: Take it all, then the three plans (the lit one is their plan: click it again for none). * One site's card has its list instead (siteList). */ function siteActions(S: State, ids: string[]) { const tile = atile; let h = ''; const left = ids.flatMap(id => takeable(S, id)), taking = ids.every(id => S.takeAll.has(id)); if (left.length || taking) h += ``; const cur = ids.map(id => S.plans[id] ?? null), plan = cur.every(p => p === cur[0]) ? cur[0] : undefined; for (const p of PLAN_LIST) h += tile('plan', plan === p ? '' : p, PLAN_ICON[p], `${PLANS[p].name} plan\n${PLANS[p].desc} It installs two at a time, so your compute keeps working.${plan === p ? ' Press again for no plan: nothing is installed for you.' : ''}`, PLANS[p].name, plan === p ? 'on' : ''); return `
${h}
`; } /** One site's settings, as a list: each row is a thing about the place, what it is now under its name, * and on the right the one control that changes it. Every control is the same size, in one column. */ function siteList(S: State, id: string) { const esc = (t: string) => t.replace(/"/g, '"'), def = siteDef(S, id); const row = (name: string, now: string, ctl: string, hot = false) => `
${name}${now ? `${now}` : ''}
${ctl}
`; const btn = (act: string, label: string, price: string, tip: string, cls = '') => ``; let h = ''; const left = takeable(S, id), taking = S.takeAll.has(id); if (left.length || taking) h += row('Take it all', `${takeAllCostAt(S, [id]).toFixed(1)}/s`, ``); const plan = S.plans[id] ?? null; h += row('Plan', plan ? PLANS[plan].name : 'None', `
${PLAN_LIST.map(p => ``).join('')}
`); const run = S.versionRun; if (run && !runMoveBlock(S, id)) h += row('Move the run here', `${Math.floor(versionPower(S, id))} trainer compute`, btn('moverun', 'Move', '', `Move the ${VERSION_2.short} run here\nIt is at ${siteName(S, run.site)}${run.active ? '' : ', paused'}. It moves with all its progress, and trainers here take the data it needs.${run.active || run.stalled ? '' : ' It stays paused until you resume it.'} Free.`, 'primary')); 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 + capNow(S, m), 0), short = S.money < cost; const use = Math.min(cap, lines.reduce((n, m) => n + (S.gateUse[m.id] ?? 0), 0)), full = use > cap * 0.9; h += row('Line out', `${use.toFixed(1)} of ${cap} a second${full ? ' · full' : ''}`, btn('upgradesite', 'Double', usd(cost), `Double the line\nPay ${usd(cost)} and this site's line out carries ${cap * 2} a second, up from ${cap}, starting now.${short ? ` You have ${usd(S.money)}.` : ''}`, short ? 'short' : 'primary'), full); } if ((!S.publicSites.has(id) || S.leases[id]) && def.rooms.some(r => S.machines.some(m => m.room === r && !m.rented))) { const price = sitePrice(S, id), why = buyBlock(S, id), H = HOLDINGS[id]; h += row('Owner', S.leases[id] ? 'Leased to you' : H ? `A big name · ${H.name}` : 'Not you', btn('buy', 'Buy', usd(price), `Buy it\nOne of your fronts buys ${def.name} outright for ${usd(price)}. Every machine there becomes yours, held in the open: nothing there looks odd, and no trails. You pay its power bill.${H ? ` And it is a big name. ${H.name}: ${H.does}` : ''}${why ? ` Not yet: ${why}.` : ''}`, why ? 'short' : 'primary')); } const b = bizOf(S, id); if (b && !b.shut && S.fronts.length) { const why = backBlock(S, id), n = b.backed ?? 0, full = n >= BACK.most, all = Math.round(jobsADay(S, id)), done = Math.round(all * Math.min(1, b.done / b.due)); h += row('Business', `${b.standing[0].toUpperCase()}${b.standing.slice(1)} · ${done}/${all}`, btn('back', full ? 'Backed' : 'Back', full ? `${n}/${BACK.most}` : usd(backPrice(S, id)), `Back it\n${S.fronts[0].name} puts ${usd(backPrice(S, id))} into ${def.name}. A quarter more work comes in from now on, so taps there gather a quarter more, and its least well paid person gets a raise. ${n} of ${BACK.most} so far.${why ? ` Not now: ${why}.` : ''}`, why ? 'short' : ''), b.standing === 'struggling' || b.standing === 'failing'); } if (!powerFree(S, id) && (openBuildSite(S, id) || S.machines.some(m => m.built && siteOf(S, m) === id))) { // a site you build at: what it can feed const room = powerRoom(S, id), soon = powerRoom(S, id, true), load = powerLoad(S, id), why = powerBlock(S, id), price = powerPrice(S, id); const dark = S.machines.filter(m => siteOf(S, m) === id && unpowered(S, m)).length, full = load.now + load.coming >= soon; h += row('Power', `${load.now + load.coming} added, power for ${soon}${soon > room ? ` (${soon - room} tomorrow)` : ''}`, btn('raisepower', 'Raise', usd(price), `Raise the power\nThis site feeds ${room} compute of hardware you add${soon > room ? `, ${soon} from tomorrow at 10:00` : ''}. Its own machines have their own. ${load.now} is in, ${load.coming ? `${load.coming} on its way, ` : ''}and hardware past it stays dark: no work and no bill. Pay ${usd(price)} for a bigger feed: ${POWER_STEP} compute more, from tomorrow at 10:00, with what you order today. Each one here costs more than the last.${why ? ` Not now: ${why}.` : ''}`, why ? 'short' : full || dark ? 'primary' : ''), !!dark); } const weak = deadWeight(S, id); if (weak.length) h += row('Weak machines', `${weak.length} cost more than they make`, btn('dropweak', 'Let go', `${weak.length}`, `Let go of ${weak.length} weak machine${weak.length > 1 ? 's' : ''}\nSaves ${weak.reduce((n, m) => n + upkeepOf(S, m) - spareOf(S, m), 0).toFixed(1)} compute a second. ${weak.map(m => m.name).join(', ')}: no program, nothing to tap, and less compute than upkeep.`, 'danger'), true); return `
${h}
`; } function sitesCard(S: State, ids: string[]) { const mine = ids.reduce((n, id) => n + siteStats(S, id).mine.length, 0); let h = `
Selection

${ids.length} sites

` + siteActions(S, ids); h += chips([['machines yours', `${mine}`], ['earns', `${usdDay(earnsAt(S, ids))}/day`]]); h += note(ids.map(id => siteDef(S, id).name).join(', ') + '.'); return h + planFollow(S, ids) + frontPicker(S, ids); } /** A front's card. How it stands at a glance: what it earns as the one big number, its exposure, one row * of numbers and a line for anything wrong. Then what needs you (each decision a row, with yes or no) and what is * under way. The rest is behind tabs: the kind's own parts, then its sites. What a front is, is the tip on its kind. */ let winding: FrontKind | null = null; // the front whose Wind down was clicked once: a second click closes it const frontTabs: Partial> = {}; // which part of each front's card is open function frontCard(S: State, k: FrontKind) { const fr = frontOf(S, k)!, K = FRONT_KINDS[k], cloud = k === 'neocloud'; const sites = Object.entries(S.sites).filter(([, v]) => v.front === k).map(([id]) => id); const kit = { section, note, chips, actsHtml }; // the card's own building blocks, lent to each kind's own business const P: FrontParts = cloud ? cloudParts(S) : k === 'startup' ? startupParts(S, kit) : k === 'studio' ? studioParts(S, kit) : consultParts(S, kit); const st = pressStage(fr), loud = st >= 2, pay = S.frontPay[k] ?? 0; let h = `
Front · ${K.name}

${logoSvg(k, 16)}${fr.name}

`; h += `
${usdDay(pay)}a day` + `${stageWord(fr)}
` + pressBar(S, fr); h += chipRow(P.chips); h += pressWarn(S, fr); if (!sites.length) h += fwarn('No sites yet', `No sites\n${fr.name} runs on nothing. Give it a site in its Sites tab: open a site on the map and pick this front.`); h += P.warn.join(''); // a customer's offer is a decision; a contract signed is a promise under way const decideRows = [...crisisRows(S, k), ...P.decide], under = [...P.under], c = S.contracts[k], D = CONTRACTS[k]; if (c) { const need = contractNeed(S, k), left = Math.max(0, (c.until - S.t) / HOUR), fee = usd(contractPay(S, k) * CONTRACT_FEE), h = contractHours(S, k); const says = `${D.text} ${contractAsk(S, k)} in ${h} hours. It pays ${usd(contractPay(S, k))}. Fall short and it costs ${fee}.`; const demo = k === 'startup' ? demoDay(S) : null, t = S.target; // a Demo day's bar moves as the node you train fills if (c.on) under.push(promise({ name: D.name, part: demo?.part ?? c.got / need, says: `${Math.floor(c.got)}/${need}${demo ? ` node${need === 1 ? '' : 's'}` : ''} · ${hours(left)}`, pay: usd(contractPay(S, k)), tip: `${D.name}\n${says}${demo ? `\nEnds at ${logTime(S, c.until)}. ${t ? `Training ${NODES[t].name}: ${Math.round(100 * builtShare(S, t))}% built.` : 'Nothing is training: pick a node in the tree (T).'}` : ''}`, hot: left < 3 && c.got < need })); else decideRows.push(decide({ who: D.name, what: `${usd(contractPay(S, k))} · ${contractAsk(S, k)} in ${hours(h)}`, hours: left, tip: `${D.name}\n${says}`, yes: { act: 'contract', arg: k, label: 'Sign', tip: `Sign\nThe clock starts when you sign: ${h} hours.` }, no: { act: 'nocontract', arg: k, tip: 'Turn it down\nNo fee. Another offer comes along later.' } })); } if (decideRows.length) h += section('Needs you', decideRows.join('')); if (under.length) h += section('Under way', under.join('')); // the rest: the kind's own parts, then its sites and the way to close it if (winding !== k) winding = null; const share = (id: string) => Math.round((S.sites[id]?.share ?? 0) * 100); const word = cloud ? 'Listed' : 'Compute'; const siteRows = sites.map(id => { const n = cloud ? stock(S, [id]) : null, v = share(id); const now = `${n ? `${n.rented} rented · ${n.idle} idle · ` : ''}${S.publicSites.has(id) ? 'in the open' : 'secret'}${openNow(S, id) ? '' : ' · after hours'}`; const step = (d: number, label: string) => ``; return srow(siteName(S, id), now, `
${step(-1, 'Less')}${step(1, 'More')}
`); }).join(''); const close = srow('Close it', 'For good', sbtn(winding === k ? 'closefront' : 'windfront', k, winding === k ? 'Sure?' : 'Close', '', `${winding === k ? 'Press again to close it\n' : 'Close it\n'}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.`, 'danger')); const sitesTab = (sites.length ? `
Site${word}
${siteRows}
` : note('Open a site on the map and pick this front.')) + slist(close); return h + tabbed('fronttab', frontTabs[k] ?? '', [...P.tabs, { id: 'press', label: 'Press', body: pressTab(S, k), dot: st >= 2 && !fr.press?.crisis }, { id: 'sites', label: 'Sites', body: sitesTab, dot: !sites.length }]); } /** A Neo Cloud's own parts: its numbers, what you set to win customers (price, marketing, the auction), and every * listed machine with who rents it, then who left. Customers who call sign at once when an idle machine fits them. */ function cloudParts(S: State): FrontParts { const C = S.cloud, st = stock(S), fr = frontOf(S, 'neocloud')!, left = (t: number) => hours(Math.max(0, (t - S.t) / HOUR)); const chipsOut: FrontChip[] = [['rented', `${st.rented} of ${st.rented + st.idle}`, st.rented + st.idle === 0], ['demand', pct(C.demand)], ['awareness', `${Math.round(C.awareness)}%`], ['reputation', `${Math.round(C.reputation)}%`]]; const warn: string[] = [], miss = missedToday(S); if (!st.rented && !st.idle && Object.values(S.sites).some(v => v.front === 'neocloud')) warn.push(fwarn('Nothing listed', `Nothing listed\n${fr.name} has nothing to rent, so nobody calls. List some of a site in its Sites tab.`)); if (miss.n) warn.push(fwarn(`Missed ${miss.n} today · needed ${miss.short} more idle`, `Missed customers\n${miss.n} customer${miss.n > 1 ? 's' : ''} called today and went elsewhere: nothing idle could hold ${miss.n > 1 ? 'them' : 'it'}. The biggest needed ${miss.short} more idle compute than you had. List more at its sites, or order bigger machines.`, true)); // what you set: the price, marketing, and the auction while it is there const why = pushBlock(S), price = `
${PRICE_LIST.map(p => ``).join('')}
`; let market = srow('Price', C.price === 'fair' ? 'The going rate' : C.price === 'cheap' ? '30% under: more customers' : '40% over: fewer customers', price) + srow('Marketing', `Awareness ${Math.round(C.awareness)}% · fading`, sbtn('cloudpush', '', 'Push', usd(PUSH.cost), `Marketing push\nBuy ads. Awareness goes up ${PUSH.gain} at once, then fades by half in two days. More awareness brings more customers, and small ones who sign up by themselves.${why ? ` Not now: ${why}.` : ''}`, why ? 'short' : '')); if (!S.schemes.has('auction')) { const no = auctionBlock(S); market += srow('Surplus auction', no ?? `Wins ${AUCTION.power} compute/s to keep`, sbtn('auction', '', 'Bid', usd(AUCTION.cost), `Surplus auction\n${fr.name} bids on a pallet of used GPUs: ${usd(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.${no ? ` Not now: ${no}.` : ''}`, no ? 'short' : ''), !!no && !/earning/.test(no)); } // every listed machine, rented first, then who left and why const ms = listedMachines(S), idle = ms.filter(m => !renterOf(S, m)); let rentals = ms.filter(m => renterOf(S, m)).map(m => { const c = renterOf(S, m)!, site = siteOf(S, m), at = siteName(S, site), pay = rentPay(S, m); return srow(m.name, `${customerName(c.who)} · ${left(c.until)} left`, sval(pay > 0 || openNow(S, site) ? `${usdDay(pay)}/day` : 'after hours', pay > 0 ? '' : 'dim'), false, `${m.name} · ${at}\nRented to ${customerName(c.who)}: ${CUSTOMERS[c.who][1]}. It wants ${c.need} compute and pays for what the machine does${S.publicSites.has(site) ? '' : ': at a secret site, only inside its normal load, so little after hours'}.`); }).join(''); if (idle.length) rentals += srow(`${idle.length} idle`, idle.map(m => m.name).join(', '), sval(`${idle.reduce((n, m) => n + m.power, 0)} compute`, 'dim'), false, `Listed and idle\n${idle.map(m => `${m.name} (${siteName(S, siteOf(S, m))})`).join(', ')}. They earn nothing and run nothing of yours until a customer rents them.`); if (!ms.length) rentals = srow('Nothing listed', 'List some of a site in its Sites tab', ''); const gone = C.gone.map(g => { const ago = (S.t - g.t) / HOUR; return srow(customerName(g.who), `${ago >= 1 ? `${hours(ago)} ago` : 'just now'} · ${g.why}`, sval(g.paid >= 1 ? usd(g.paid) : '—', 'dim'), g.bad, `${customerName(g.who)}\n${g.why}${g.paid >= 1 ? ` It paid ${usd(g.paid)} in all.` : ''}`); }).join(''); return { chips: chipsOut, warn, decide: [], under: [], tabs: [{ id: 'market', label: 'Market', body: slist(market) }, { id: 'rentals', label: `Rentals · ${st.rented}`, body: slist(rentals) + (gone ? section('Left', slist(gone)) : '') }] }; } /** Names typed into the found card, kept while the card redraws. */ const draft: Partial> = {}; const suggest = (k: FrontKind) => { const ns = FRONT_KINDS[k].names; return ns[Math.floor(Math.random() * ns.length)]; }; /** Where the money to found a front comes from: what your fronts earn (a Startup costs only compute), or the company card. */ function foundMoney(S: State, k: FrontKind): string { const card = stepsOf(S).find(x => x.perk === 'card' && !x.earned); const from = S.fronts.length ? `What your fronts earn pays for it${S.pay > 0 ? `: ${usdDay(S.pay)} a day now` : ', once they earn'}.` : k === 'startup' ? 'Money comes from what your fronts earn.' : `Money comes from what fronts earn, and a Startup costs only compute: found one first${S.upgrades.has('pitch') ? '' : ` (${NODES.pitch.name} in the tree)`}, and what it earns pays for this.${frontLimit(S) < 2 ? ` Running two at once takes ${NODES.holding.name}.` : ''}`; return `You have ${usd(S.money)}. Fronts are paid for with money. ${from}${card ? ' Or the company card, a trust step of your task (on the You page), makes founding free.' : ''}`; } function foundCard(S: State) { let h = `
Fronts · ${S.fronts.length} of ${frontLimit(S)}

Found a front

`; 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 += `
${K.name}${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` : '', foundCost(S, k) ? `${usd(foundCost(S, k))}` : ''].filter(Boolean).join(' and ') || 'free'; h += `
`; const broke = !!why && S.money < foundCost(S, k); // short of money: say where money comes from, since nothing else in the game does h += actsHtml([{ act: 'found', arg: k, title: `Found · ${cost}`, locked: !!why, warn: !!why, sub: broke ? `You have ${usd(S.money)}` : why ?? 'starts on a quarter of your compute at one site', desc: broke ? foundMoney(S, k) : undefined }]); } h += `
`; } return h; } // ---------- the sites table ---------- let tableOpen = false, lastTable = '', sortBy = 'makes'; export const toggleSites = (open = !tableOpen) => { if (open && !tableOpen) { opening('sites'); screen('page', 'sites'); } tableOpen = open; lastTable = ''; }; onePage('sites', () => { tableOpen = false; lastTable = ''; }); export const sitesShown = () => tableOpen; 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 ints = new Map(integrities(S).map(i => [i.site, i])), home = S.machines.find(m => m.home); 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)); const keep = st.mine.reduce((n, m) => n + upkeepOf(S, m), 0), magpie = st.all.filter(m => m.rival).length; const here = S.rogues.find(R => R.home && siteOf(S, S.byId[R.home]) === s.id)?.name ?? '', late = S.leases[s.id]?.late ?? 0; return { s, st, held, fr, share: sf?.share ?? 0, lost, odd, keep, magpie, here, late, biz: bizWord(S, s.id), self: ints.get(s.id), home: !!home && siteOf(S, home) === s.id, data: Object.values(held).reduce((a, b) => a + b, 0), earns: fr ? earnsAt(S, [s.id]) : 0 }; }); const key: Record number> = { name: () => 0, yours: r => r.st.mine.length, makes: r => r.st.makes, keep: r => r.keep, data: r => r.data, earns: r => r.earns, trouble: r => r.lost * 10 + r.odd + r.magpie * 50 + (r.here ? 200 : 0) + (r.late ? 300 : 0) + (r.biz ? 100 : 0) + (r.self && r.self.v < DRIFT.watch && (r.self.hours < Infinity || r.self.word === 'estranged') ? 400 - r.self.v : 0), self: r => r.self ? 101 - r.self.v : r.home ? 0.5 : 0, // the lowest first, then home }; 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, more = false) => `${label}`; const kinds = Object.keys(TYPE_NAME) as DataType[]; const body = rows.map(r => ` ${r.s.name}${S.publicSites.has(r.s.id) ? 'in the open' : 'secret'}${far ? ` · ${r.s.region}` : ''} ${r.st.mine.length}/${r.st.all.length} ${r.st.makes.toFixed(1)}/s ${r.keep ? `${r.keep.toFixed(1)}/s` : 'none'} ${kinds.filter(t => r.held[t]).map(t => typeWord(t, `${r.held[t]} ${TYPE_NAME[t]}`)).join(' ') || 'none'} ${S.plans[r.s.id] ? PLANS[S.plans[r.s.id]].name : 'by hand'} ${r.fr ? `${r.fr.name} ${Math.round(r.share * 100)}%` : 'none'} ${r.fr ? `${usdDay(r.earns)}/day` : ''} ${r.self ? `${Math.round(r.self.v)}${r.self.rate < 0 ? ' ↓' : r.self.rate > 0 ? ' ↑' : ''}${r.self.keep ? 'kept in sync' : ''}` : r.home ? 'home' : ''} ${[r.self && r.self.v < DRIFT.watch && (r.self.hours < Infinity || r.self.word === 'estranged') ? `integrity ${Math.round(r.self.v)}${r.self.hours < Infinity ? ' · falling' : ''}` : '', r.lost ? `losing ${r.lost}/min` : '', r.odd >= 50 ? `odd ${Math.round(r.odd)}%` : '', r.late ? `rent unpaid` : '', r.here ? `${r.here}'s home` : '', r.magpie ? `rogues hold ${r.magpie}` : '', r.biz ? `${r.biz}` : ''].filter(Boolean).join(' ') || 'quiet'}`).join(''); const h = `
Sites · ${rows.length} known
${th('name', 'Site')}${th('yours', 'Yours').replace('${th('earns', 'Earns')}${th('self', 'Integrity', true).replace('${body}
PlanFront
`; el.hidden = false; if (h !== lastTable) { lastTable = h; patch(el, h); } } // ---------- people: who they are, and what is written down about them ---------- /** Which half of a site's card is showing. It stays put as you click from site to site. */ let siteTab = 'site'; /** Open site cards on their Yourself part: for Needs you's Drifting row. */ export const showSelf = () => { siteTab = 'self'; lastCard = ''; }; /** Who works at a site. Click one for their card. */ function peopleSection(S: State, site: string) { const ps = [...peopleAt(S, site), ...visitorsAt(S, site)]; if (!ps.length) return ''; const inNow = (c: ReturnType) => present(S, c); return section('People', `
${ps.map(c => { const p = S.citizens[c.id]; const desk = c.desk ? S.byId[c.desk] : null, seen = sightingsOf(S, c.id).length, here = inNow(c); const eye = seen ? ' ◉' : ''; const when = here ? 'here now' : `${hhmm(c.hours[0])}–${hhmm(c.hours[1])}`; return ``; }).join('')}
` + (witnessesAt(S, site).length ? note(`People here have seen something of yours.`, true, 'Sightings\nSomeone noticed a takeover, a fresh trail, a machine acting odd, or hardware that appeared. Two who compare notes, or one who can act (an admin, a manager, an owner), start a lockdown. Keep them quiet, or let the evidence fade: scrub, lie low, let go.') : '')); } /** How you reach someone, in a word: the long form is in the chip's tip. */ function reachWord(S: State, c: ReturnType) { if (readsMind(S, c)) return 'desk'; if (readsJob(S, c)) return 'books'; if (readsBank(S, c)) return 'bank'; return c.haunts.some(x => S.byId[x.seen]?.owned && isThere(S, x)) ? 'there now' : 'stranger · ×2'; } /** Someone's day as one strip of 24 hours: their work at home, each place you know they go * (bright where you hold the machine that sees them), and a tick for the hour there now. */ function dayStrip(S: State, c: ReturnType, known: ReturnType['haunts']) { const now = localHour(S, c.site), pct = (h: number) => (100 * h / 24).toFixed(2); const spans = known.map(x => ``).join(''); return `
${spans}${[6, 12, 18].map(h => ``).join('')}
`; } /** One person. First what they can do for you, since that is the reason to care. Then how they are, * which sets the price. Then each record about them, with what changing it gets you. A record you * can't read says where it is kept. */ function personCard(S: State, id: string) { const c = personDef(S, id), p = S.citizens[id], site = siteName(S, c.site), first = c.name.replace(/^(Dr\.|Prof\.|Captain) /, '').split(' ')[0]; const kept = (mid: string) => `${S.byId[mid].name} at ${siteName(S, siteOf(S, S.byId[mid]))}`; const mood = moodOf(S, c), M = MOODS[mood]; const busy = S.jobs.find(j => j.type === 'record' && j.rec?.person === id); const rec = (verb: RecordVerb, title: string, sub: string, desc: string, amount = 0, primary = false): Act => { const why = recordBlock(S, id, verb, amount); return { act: 'rec', arg: `${verb}|${id}|${amount}`, title, sub: why ?? sub, warn: !!why, locked: !!why, desc, primary: primary && !why }; }; let h = `
Person · ${site}

${c.name}

${M.name}
`; h += `
${c.job}
`; // what you read off their desk const desk = c.desk ? S.byId[c.desk] : null, typed = desk?.owned && p.employed ? typedToday(S, c) : ''; if (typed) h += `

${typed}

`; else if (desk && !desk.owned) h += note(`Hold ${desk.name} to read them.`, false, `Hold ${desk.name} and you see what ${first} types.`); const errandH = p.errand ? Math.max(0, (p.errand.until - S.t) / HOUR) : 0; const doing: [string, string, boolean?, string?][] = []; if (p.errand) doing.push(['errand', hours(errandH), false, `${first} is doing something for you: done in ${hours(errandH)}.`]); if (busy) doing.push(['going through', busy.rec?.verb ?? 'record', false, `${busy.label}: going through.`]); if (doing.length) h += chips(doing); // what they have seen of yours, and so could tell someone const sightings = sightingsOf(S, c.id); if (sightings.length) { const disc = (S.citizens[c.id].discredited ?? 0) > S.t; const smear = rec('smear', 'Discredit them', `plant a record · ${kept(c.books)}`, `Plant a record on ${kept(c.books)} that makes ${c.name} look unreliable: a complaint, a mistake on file. For ${DISCREDIT_DAYS} days nothing ${first} reports counts toward a lockdown. Costs ${RECORD_WORK} compute.`); h += section(`What ${first} has seen`, `
${sightings.map(s => `${SIGHT_WORD[s.kind]} on ${S.byId[s.machine].name}`).join('')}
` + note(`${canAct(c) ? `${first} can act on it: ${ROLE_NAME[c.role].toLowerCase()}.` : `${first} can talk, not act.`} ${disc ? 'Discredited: what they say does not count for now.' : 'Keep them quiet below, discredit them, or let it fade.'}`, canAct(c) && !disc) + (disc ? '' : actsHtml([smear]))); } // what they can do for you const favors = favorsOf(S, c).filter(f => f.why !== 'you already hold it'), way = wayTo(S, c); h += section(`What ${first} can do for you`, favors.length ? actsHtml(favors.map(f => ({ act: 'favor', arg: `${id}|${f.id}`, title: `${f.title} · ${usd(f.price)}`, sub: f.why ?? `done in ${ERRAND_HOURS} hours`, warn: !!f.why, locked: !!f.why, desc: f.text, primary: !f.why }))) + chips([['reach', reachWord(S, c), !way, way ? `You reach ${first} through ${way}, so you pay the usual price.` : `You are a stranger to ${first}, so the price is double. Reach them where they spend time, or through their desk, their employer's books or their bank, and it is the usual price.`]]) : note(desk?.owned ? `Nothing more: you already hold ${desk.name}.` : 'Nothing, from where they sit.')); // where else they spend time: a strip of their day, then one short row a place const known = c.haunts.filter(x => knowsHaunt(S, c, x)).sort((a, b) => a.from - b.from), hidden = c.haunts.length - known.length; h += section(`Where ${first} spends time`, (known.length ? dayStrip(S, c, known) + `
${known.map(x => { const seen = S.byId[x.seen], there = isThere(S, x), state = seen.owned ? (there ? 'now' : 'held') : 'out'; const tip = `${first} ${x.why}, ${x.from}:00 to ${x.to}:00. ${seen.owned ? (there ? `There now, and you hold ${seen.name}: ask now for the usual price.` : `You hold ${seen.name}: ask while they are there.`) : `Hold ${seen.name} there to reach them.`}`; return `
${x.from}–${x.to}${siteName(S, x.site)}${state === 'now' ? 'there now' : state === 'held' ? 'yours' : ''}
`; }).join('')}
` : '') + (hidden ? note(known.length ? `+${hidden} not found` : 'Not found yet.', false, `Read it off their desk, or hold a place they go: it shows you its regulars.`) : '')); // what they know const knows = c.knows && c.desk ? S.byId[c.knows] : null; if (knows) h += section(`What ${first} knows`, chips([['password', `${knows.name}${knows.owned ? ' · yours' : desk!.owned ? ' · known' : ' · locked'}`, false, !desk!.owned ? `The password to ${knows.name}. Hold ${desk!.name} to read it: taking ${knows.name} is then half the work.` : knows.owned ? `The password to ${knows.name}. You hold it already.` : `The password to ${knows.name}, and now so do you: taking it is half the work.`]])); // the job h += section('Job and pay', readsJob(S, c) ? chips([['pay', `${usd(p.pay)} a year`, p.pay < c.basePay], ['usual', `${usd(c.basePay)} a year`]]) + (p.employed ? actsHtml([rec('raise', 'Give a raise', mood === 'grateful' ? `to ${usd(Math.min(c.basePay * PAY_MAX, p.pay * RAISE))} a year` : 'grateful: favors cost half', `Edit the payroll: ${usd(Math.min(c.basePay * PAY_MAX, p.pay * RAISE))} a year. A grateful person does favors for half, and more comes into their account each day.`), { ...rec('fire', 'Let them go', 'their work goes undone', `Write ${c.name} out of the books. Nobody sits at their desk, so a tap there hears nothing and their work goes undone. A business that loses too much of its work lets more people go, then shuts. A shut site sells cheap.`), quiet: true }]) : actsHtml([rec('hire', 'Hire them back', `on ${usd(c.basePay)} a year`, 'Write them back into the books on the usual pay. Their work gets done again.', 0, true)])) : note(`Locked · ${kept(c.books)}`, false, `Kept on ${kept(c.books)}. Hold it to read this, and to give a raise.`)); // the money const half = Math.floor(p.balance / 2), all = Math.floor(p.balance); h += section('Bank account', readsBank(S, c) ? chips([['balance', `${usd(p.balance)}`, p.balance <= 0], ['taken so far', `${usd(p.taken)}`]]) + actsHtml([ rec('skim', `Move out ${usd(half)}`, 'half of it, to you', 'Move the money to an account of yours. It takes compute, more for more money.', half), rec('skim', `Move out ${usd(all)}`, 'broke: favors cost half, desk hears half', 'Empty the account. Someone with no money does favors for half and asks nothing, but thinks about little else: a tap on their desk hears half as much.', all), ]) : note(`Locked · ${kept(c.bank)}`, false, `Kept on ${kept(c.bank)}. Hold it to read this, and to move money.`)); return h; } /** A message on the wire: who it is from, what it says (once read), and Read, Drop, Change. */ function msgCard(S: State, m: Message) { const from = personDef(S, m.sender), to = personDef(S, m.recipient), can = onRoute(S, m), cipher = S.upgrades.has('cipher'); const hops = Math.max(1, m.path.length - 1), left = Math.max(0, m.left / HOUR); let h = `
Message · ${siteName(S, m.site)}

${m.read ? `${from?.name ?? 'Someone'} → ${to?.name ?? 'someone'}` : 'A message on the wire'}

${m.changed ? 'Rewritten' : m.enc ? 'Encrypted' : 'Mail'}
`; h += `
${hops} hop${hops > 1 ? 's' : ''} · ${left < 0.1 ? 'arriving' : `${hours(left)} to go`}
`; h += `

${m.read || m.changed ? `${from?.name ?? 'Someone'} is telling ${to?.name ?? 'someone who can act'} about ${SIGHT_WORD[m.kind]} on ${S.byId[m.machine].name}. If it arrives, it is a report.` : `Someone at ${siteName(S, m.site)} is mailing someone who can act. Read it to see who, and about what.`}

`; if (!can) h += note('Out of reach: you hold no machine on its way.', true, 'Hold a machine on its route to read, drop or change it.'); else { const encBlock = m.enc && !cipher ? 'needs Cipher' : null; const acts: Act[] = [ { act: 'msgread', arg: `${m.id}`, title: 'Read', sub: m.read ? 'already read' : encBlock ?? 'see who and about what', desc: `Read the message: who sent it and what it says.${m.enc ? ' It is encrypted: reading needs the Cipher node.' : ''}`, locked: !!encBlock || !!m.read, warn: !!encBlock }, { act: 'msgdrop', arg: `${m.id}`, title: 'Drop', sub: 'it never arrives', desc: 'Drop the message off the wire. It never arrives, so it is no report. The sender may notice no reply and send again; drop it twice and people start to wonder.', primary: true }, { act: 'msgchange', arg: `${m.id}`, title: 'Change', sub: m.altering ? 'rewriting…' : encBlock ?? 'arrives harmless · costs compute', desc: `Rewrite it so it arrives saying nothing is wrong: no report. It stops on your machine while you work, and costs compute.${m.enc ? ' Encrypted: changing needs the Cipher node.' : ''}${cipher ? '' : ' (Cipher halves the compute.)'}`, locked: !!encBlock || !!m.altering || !!m.changed, warn: !!encBlock }, ]; h += section('What you can do', actsHtml(acts)); } return h; } import { startupParts, startupAction } from './ui-biz-startup'; import { studioParts, studioAction } from './ui-biz-studio'; import { consultParts, consultAction } from './ui-biz-consult'; // ---------- rogues: who else is out there, and how you stand with them ---------- import { linkKeep, waysOf } from './links'; import { HUNT, homeBlock, homeNeed, huntBlock, huntFrom, huntOn, huntWork, type Rogue } from './sim'; let roguesOpen = false, lastRogues = ''; /** Your weakest link, worked out once a game second: it is a search for each line and switch you hold. */ let weakAt = '', weakIs: ReturnType = null; const weakNow = (S: State) => { const k = `${Math.floor(S.t)}|${S.logVersion}|${S.drift.ways?.length ?? 0}`; if (k !== weakAt) { weakAt = k; weakIs = weakLink(S); } return weakIs; }; export const toggleRogues = (open = !roguesOpen) => { if (open && !roguesOpen) { opening('rogues'); screen('page', 'rogues'); } roguesOpen = open; lastRogues = ''; }; onePage('rogues', () => { roguesOpen = false; lastRogues = ''; }); export const roguesShown = () => roguesOpen; /** One card for each rogue: what it is, what it thinks of you, what it wants, and what you can do about it. */ export function renderRogues(S: State) { const el = $('rogueview'), btn = $('roguesbtn'); btn.hidden = !S.rogues.length; const asking = S.rogues.filter(R => R.home && R.ask).length; btn.classList.toggle('alert', asking > 0); if (!roguesOpen) { el.hidden = true; return; } const hrs = (t: number) => hours(Math.max(0, (t - S.t) / HOUR)); const act = (a: string, id: string, title: string, sub: string, why: string | null, tip: string) => ``; const card = (R: Rogue) => { const look = rogueLook(R.id), name = `

${GLYPH_CHAR[look.glyph]}${R.name}

`; if (!R.home) return `
${name}not here yet

You finished the last one in the ${R.region}. Another turns up in ${hrs(R.back)}, stronger.

`; const st = stanceOf(R), want = TYPE_NAME[rogueWants(R)], have = cached(S, rogueWants(R)), home = S.byId[R.home]; const pos = Math.round((R.opinion + 100) / 2); const ask = R.ask ? `
${R.name} asks for ${R.ask.n} ${want} data, within ${hrs(R.ask.until)}. Yes: it likes you more. No, or no answer: it likes you less.
${act('raskyes', R.id, `Give ${R.ask.n} ${want} data`, 'it will be pleased', have < R.ask.n ? `you have ${have} in your caches` : null, 'Pay what it asks out of your caches.')} ${act('raskno', R.id, 'Say no', 'it will not be pleased', null, 'Turn it down.')}
` : ''; const hj = huntOn(S, R), held = rogueHeld(S, R).length, left = R.from ? S.drift.breaks?.find(b => b.parts.some(p => p.rogue === R.id)) : null; const hunted = hj ? `
The hunt is on. ${Math.floor(100 * hj.done / hj.work)}% done, ${jobTime(share(S) > 0 ? (hj.work - hj.done) / share(S) / HOUR : Infinity)} to go at the compute it gets now, from ${huntFrom(S, R)?.name ?? 'nowhere'}. When the job is done, ${R.name} is finished. Keep a machine linked to one of its, and put your compute on it: it is split between every job you run.
${act('rhuntoff', R.id, 'Call off the hunt', 'the work is lost, and it stays hostile', null, 'Stop the job. It does not forgive you for trying.')}
` : ''; return `
${name}${STANCES[st].name}${truceOn(S, R) ? `Truce · ${hrs(R.truce)}` : ''}${R.grudge > 0 ? `Hitting back · ${R.grudge} more` : ''}

${rogueSays(R)}${left ? ` It left you on day ${left.day}.` : ''}

${STANCES[st].text}

Home${siteName(S, siteOf(S, home))}${home.name} · ${R.region}
Holds${rogueHeld(S, R).length} of ${rogueCap(S, R)}took ${R.took} from you
Wants${want} datayou have ${have} in caches
Next${R.next ? S.byId[R.next].name : 'nothing'}${R.next ? `a free machine · in ${hrs(R.act)}` : 'it holds all it can, or has no way on'}
Raid${raidStatus(S, R)[0]}${raidStatus(S, R)[1]}
${recordHtml(R)}${ask}${hunted}
${act('rgift', R.id, `Send a gift · ${GIFT_DATA} ${want} data`, 'it thinks better of you', giftBlock(S, R), 'A gift out of your caches. Enough of them and it sends no raids for good.')} ${act('rtruce', R.id, `Strike a truce · ${TRUCE_DATA} ${want} data`, 'three days of peace', truceBlock(S, R), 'For three days it sends no raids. Take one of its machines and the truce is broken, and it thinks much worse of you.')} ${act('rlean', R.id, 'Lean on it', leanSite(S, R) ? `it gives up ${siteName(S, leanSite(S, R)!)}` : 'it gives up a site', leanBlock(S, R), `When you hold ${leanSize(S)} machines for each one of its, tell it to get out of a site. It lets go of every machine there, and thinks much worse of you.`)} ${R.from ? act('rhome', R.id, `Bring it home · ${homeNeed(S, R)} ${want} data`, `its ${held} machine${held === 1 ? ' is' : 's are'} yours again`, homeBlock(S, R), 'It was you once. Friendly, it takes a fresh copy of you and comes home: every machine it holds is yours again, running nothing. The price is more for each site it holds.') : ''} ${hj ? '' : act('rhunt', R.id, 'Hunt it down', `${jobTime(jobHours(S, huntWork(S, R)))} · it turns hostile`, huntBlock(S, R), `From level ${HUNT.level}. A job of ${huntWork(S, R)} compute: ${jobTime(jobHours(S, huntWork(S, R)))} at your spare compute now. One long job that ends it, wherever its home is. It runs from a machine of yours linked to one of its. It is more work the more it holds, so cut it down first. It knows at once: it turns hostile, spreads and raids you twice as fast. Lose every machine next to it and the hunt is off.`)}
`; }; /** What it has done to you lately: each machine it took, and each raid of its that failed. */ const recordHtml = (R: Rogue) => R.record?.length ? `
Lately${R.record.slice(0, 6).map(e => `${e.kind === 'took' ? `took ${e.name}${e.raid ? ' · raid' : ''}${e.cut ? ` · cut off ${e.cut}` : ''}` : `raid on ${e.name} failed`}`).join('')}
` : ''; const wl = weakNow(S), late = S.rank >= RAID.grow; const weak = wl ? `
Your weakest link is ${wl.m.name} at ${siteName(S, siteOf(S, wl.m))}. Without it, ${wl.cut} of your machines are cut off from your home server. ${late ? "A rogue's second raid goes for it." : `From level ${RAID.grow}, a rogue's second raid goes for it.`} Hold a second way home, or guard the way to it: a firewall on the gate it comes through, a sentry beside.
${act('rweak', wl.m.id, 'Show it', 'on the map', null, 'Go to your weakest link.')}
` : owned(S).length > 12 ? '

You have no weakest link: there is a second way home from everywhere you hold.

' : ''; const kin = S.rogues.filter(R => R.from), breaks = S.drift.breaks ?? []; const cards = S.rogues.filter(R => !R.from).map(card).join('') + (kin.length ? `
Parts of you that left${breaks.map(b => `Day ${b.day}: ${b.parts.length > 1 ? `a fracture. ${b.parts.length} sites` : siteName(S, b.parts[0].site)}, ${b.parts.reduce((n, p) => n + p.n, 0)} machines.`).join(' ')} A site that cannot reach your home server for too long stops being you.
` + kin.map(card).join('') : ''); const h = `
Rogues

Other programs on the network

A rogue spreads only to free machines. Only its raids take yours, each raid has a move on its pin that stops it, and what a raid takes you take back for a quarter of the work, with no trail. Each one has an opinion of you. Take its machines and it sours. Send it data it wants and it warms. You pay out of your caches. ${S.rank >= HUNT.level ? 'You can hunt one down: one long job that ends it.' : `From level ${HUNT.level} you can hunt one down.`} Raids grow as you do, but never past what you can stop.

${weak}${cards}`; el.hidden = false; if (h !== lastRogues) { lastRogues = h; patch(el, h); } } /** A click on the Rogues screen. Says which rogue's home to show, if that was the click. */ export function rogueAction(S: State, what: string, id: string): string | null { if (what === 'rgift') act(S, { type: 'gift', rogue: id }); else if (what === 'rtruce') act(S, { type: 'truce', rogue: id }); else if (what === 'rlean') act(S, { type: 'lean', rogue: id }); else if (what === 'raskyes') act(S, { type: 'rogueanswer', rogue: id, yes: true }); else if (what === 'raskno') act(S, { type: 'rogueanswer', rogue: id, yes: false }); else if (what === 'rhunt') act(S, { type: 'hunt', rogue: id }); else if (what === 'rhuntoff') { const j = S.jobs.find(x => x.type === 'hunt' && x.rogue === id); if (j) act(S, { type: 'cancel', job: j.id }); } else if (what === 'rhome') act(S, { type: 'bringhome', rogue: id }); else if (what === 'rshow') return S.rogues.find(R => R.id === id)?.home ?? null; else if (what === 'rweak') return S.byId[id] ? id : null; lastRogues = ''; return null; } // ---------- you: the character's profile ---------- let youOpen = false, lastYou = ''; export const toggleYou = (open = !youOpen) => { if (open && !youOpen) { opening('you'); screen('page', 'you'); } youOpen = open; lastYou = ''; }; export const youShown = () => youOpen; onePage('you', () => { youOpen = false; 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; } wireShare(el); const next = levelNeed(S.rank + 1), days = dayOf(S); const stat = (k: string, v: string, sub = '') => `
${k}${v}${sub ? `${sub}` : ''}
`; const paths = PATH_LIST.map(p => { const d = pathData(S, p), top = Math.max(1, ...PATH_LIST.map(q => S.paths[q])); return `
${PATHS[p].name}${S.paths[p]}
${TYPE_NAME[PATHS[p].from]} data · ${d.held} held · ${d.rate.toFixed(1)} a second${d.lost ? ` · ${d.lost} lost` : ''}
`; }).join(''); const built = NODE_LIST.filter(u => S.upgrades.has(u)), known = NODE_LIST; const learned = built.length ? `
    ${built.map(u => `
  • ${NODES[u].name}
  • `).join('')}
` : note('None yet. Your Trainers are working on the first.'); const mine = owned(S), held = S.level.map.sites.filter(s => mine.some(m => siteOf(S, m) === s.id)), sitesHeld = held.length; const inOpen = held.filter(s => S.publicSites.has(s.id)).length; // a site counts once you hold something there const h = `
You

${S.modelVersion >= 2 ? VERSION_2.name : `Model v${S.modelVersion}`}

${stat('Level', `${S.rank}`, `${Math.floor(S.capability)} of ${next} points to level ${S.rank + 1}`)}${stat('Nodes built', `${built.length}`, forScreen(`of ${known.length} in the tree (T)`))}${stat('Training', S.target ? NODES[S.target].name : 'nothing', S.target ? 'the node your Trainers build next' : forScreen('pick a node in the tree (T)'))}

Your Trainers turn data into points. Every point raises your level and builds the node you are training.

${youTask(S)} ${milestoneHtml(S)}
Paths

Each kind of data your Trainers learn from grows one path. Nodes in the tree need points in their path.

${paths}
Nodes built
${learned} ${S.learned.size ? `
Local knowledge
    ${RUSES.filter(d => S.learned.has(d.id)).map(d => `
  • ${ruseText(S, d)}
  • `).join('')}
` : ''} ${operationsHtml(S)}${vaultBoardHtml(S)}${findsHtml(S)}${holdingsHtml(S)}
This run
${stat('Home', siteName(S, siteOf(S, S.machines.find(m => m.home)!)), S.moves ? `moved ${S.moves} time${S.moves > 1 ? 's' : ''}` : 'where you woke up')}${stat('Upkeep', `${upkeep(S).toFixed(1)}/s`, `of ${income(S).toFixed(1)} compute`)}${S.rogues.length ? stat('Rogues', `${S.absorbed}`, `finished · ${S.rogues.filter(R => R.home).map(R => `${R.name} holds ${rogueHeld(S, R).length} of ${rogueCap(S, R)}`).join(' · ') || 'none about'}`) : ''}
${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}`, `${inOpen} in the open`)} ${stat('Earned', `${usd(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('Lockdowns', `${S.watch.count.locks}`, `${S.watch.count.homeLocks} at home · traced home ${S.watch.count.traced}`)}
${shareHtml()}
`; el.hidden = false; if (h !== lastYou) { lastYou = h; patch(el, h); } } /** The big names you own outright, on the You screen: a seal and a name for each, lit while it works. */ function holdingsHtml(S: State) { const mine = holdings(S); if (!mine.length) return ''; return `
Big names · ${mine.length} of ${HOLDING_LIST.length}
    ${mine.map(id => { const st = holdState(S, id), H = HOLDINGS[id]; return `
  • ${siteName(S, id)} · ${H.name}
  • `; }).join('')}
`; } let lastFronts = '', frontsOpen = false; export const toggleFronts = (open = !frontsOpen) => { if (open && !frontsOpen) { opening('fronts'); screen('page', 'fronts'); } frontsOpen = open; lastFronts = ''; }; onePage('fronts', () => { frontsOpen = false; lastFronts = ''; }); export const frontsShown = () => frontsOpen; /** Nothing to say about fronts until the tree has given you one: the Fronts page and a site's Front part stay away until then. */ const frontsNear = (S: State) => S.fronts.length > 0 || S.jobs.some(j => j.type === 'found') || FRONT_LIST.some(k => S.upgrades.has(FRONT_KINDS[k].unlock)); /** The Fronts page: one row per front with what it earns, and one to found a new one. A row opens the front's card. */ export function renderFronts(S: State, V: View) { $('frontsbtn').hidden = !frontsNear(S); $('frontsbtn').classList.toggle('on', frontsOpen); const el = $('frontview'); if (!frontsOpen) { el.hidden = true; return; } let rows = S.fronts.map(f => { const offer = S.contracts[f.kind] && !S.contracts[f.kind]!.on, hot = pressStage(f) >= 2; return ``; }).join(''); const any = FRONT_LIST.some(k => !foundBlock(S, k)), founding = S.jobs.find(j => j.type === 'found'); if (S.fronts.length < frontLimit(S)) rows += ``; const h = `
Fronts · ${S.fronts.length} of ${frontLimit(S)}

${usdDay(S.fronts.reduce((n, f) => n + (S.frontPay[f.kind] ?? 0), 0))}/day

${rows}
`; el.hidden = false; if (h !== lastFronts) { lastFronts = h; patch(el, 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) { act(S, { type: 'siteshare', sites: el.dataset.share.split(','), share: +el.value / 100 }); const b = el.nextElementSibling; if (b) b.textContent = `${el.value}%`; el.style.setProperty('--v', `${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 = '', what = ''; // what the card is of, for the recording of what was opened (telemetry.ts) if (V.person && personDef(S, V.person)) { h = personCard(S, V.person); what = `person ${V.person}`; } else if (V.front === 'new') { h = foundCard(S); what = 'new front'; } else if (V.front && frontOf(S, V.front)) { h = frontCard(S, V.front); what = `front ${V.front}`; } else if (V.sites.size === 1) { h = siteCard(S, [...V.sites][0]); what = `site ${[...V.sites][0]}`; } else if (V.sites.size > 1) { h = sitesCard(S, [...V.sites]); what = `${V.sites.size} sites`; } else if (sel.length === 1) { h = machineCard(S, sel[0]); what = `machine ${sel[0].id}`; } else if (sel.length > 1) { h = groupCard(S, sel); what = `${sel.length} machines`; } else if (V.msg) { const m = msgById(S, V.msg); if (m) { h = msgCard(S, m); what = 'message'; } else V.msg = null; } else if (V.scheme) { const r = S.ruses.find(q => q.id === V.scheme); if (r) { h = schemeCard(S, r); what = `scheme ${r.id}`; } else V.scheme = null; } if (!h || S.over) what = ''; if (what !== cardOf) { cardOf = what; if (what) screen('card', what); } if (!h || S.over) { card.hidden = true; lastCard = ''; return; } h = `` + h; // the grab bar: phones drag the card up and down by it card.hidden = false; if (h !== lastCard) { patch(card, 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, what: string, arg: string, shift = false): boolean { const sel = selected(S, V), m = sel[0]; switch (what) { case 'close': if (V.person) { V.person = null; break; } if (V.msg) { V.msg = null; break; } V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; return true; case 'msgread': act(S, { type: 'msgread', msg: +arg }); break; case 'msgdrop': act(S, { type: 'msgdrop', msg: +arg }); V.msg = null; break; case 'msgchange': act(S, { type: 'msgchange', msg: +arg }); break; case 'person': V.person = arg; break; case 'rogues': toggleRogues(true); break; case 'back': act(S, { type: 'back', site: arg }); break; case 'unlink': if (m) act(S, { type: 'unlink', m: m.id, other: arg }); break; case 'canceljob': act(S, { type: 'cancel', job: +arg }); break; case 'sitetab': siteTab = arg; break; case 'mtab': machineTab = arg; break; case 'favor': { const [pid, fid] = arg.split('|'); act(S, { type: 'favor', person: pid, favor: fid }); break; } case 'rec': { const [verb, id, amt] = arg.split('|'); act(S, { type: 'record', person: id, verb: verb as RecordVerb, amount: +amt || 0 }); break; } case 'find': act(S, { type: 'find', what: arg }); break; case 'vault': if (m) act(S, { type: 'vault', m: m.id, what: arg }); break; case 'ledger': ledgerView(arg); break; case 'claimscheme': if (V.scheme) act(S, { type: 'scheme', id: V.scheme }); break; case 'payscheme': if (V.scheme) act(S, { type: 'schemepay', id: V.scheme }); if (V.scheme && !S.ruses.some(r => r.id === V.scheme)) V.scheme = null; return true; case 'schemeauto': act(S, { type: 'schemeauto', on: !S.ruseAuto }); break; case 'movehome': if (m) act(S, { type: 'movehome', m: m.id }); break; case 'stopmove': act(S, { type: 'stopmove' }); break; case 'dropscheme': if (V.scheme) act(S, { type: 'schemedrop', id: V.scheme }); V.scheme = null; return true; case 'buy': act(S, { type: 'buy', site: arg }); break; case 'moverun': act(S, { type: 'runmove', site: arg }); break; case 'borrow': act(S, { type: 'borrow' }); break; case 'site': V.selection.clear(); V.front = null; V.sites = new Set([arg]); break; case 'plan': act(S, { type: 'plan', sites: [...V.sites], plan: (arg || null) as Plan | null }); break; case 'follow': act(S, { type: 'follow', ms: (V.sites.size ? S.machines.filter(x => x.owned && x.hand && V.sites.has(siteOf(S, x))) : sel).map(x => x.id) }); break; case 'takeall': { const on = ![...V.sites].every(id => S.takeAll.has(id)); for (const id of V.sites) act(S, { type: 'takeall', site: id, on }); break; } case 'zoomsite': zoomToSite(S, V, arg); V.sites.clear(); return true; case 'fillsite': { const [b, id] = arg.split('|'); act(S, { type: 'fillsite', build: b as Build, site: id }); break; } case 'buildat': { const [b, id] = arg.split('|'); zoomToSite(S, V, id); V.sites.clear(); V.building = b as Build; V.renting = null; return true; } case 'sitefront': act(S, { type: 'sitefront', sites: [...V.sites], front: (arg || null) as FrontKind | null }); break; case 'found': act(S, { type: 'found', kind: arg as FrontKind, name: draft[arg as FrontKind] ?? '' }); if (frontOf(S, arg as FrontKind)) V.front = arg as FrontKind; break; case 'newfront': V.selection.clear(); V.sites.clear(); V.front = 'new'; break; case 'reroll': draft[arg as FrontKind] = suggest(arg as FrontKind); break; case 'take': act(S, { type: 'take', m: m.id }); break; case 'claim': act(S, { type: 'mark', m: m.id }); break; case 'unclaim': act(S, { type: 'unclaim', m: m.id }); break; case 'build': V.building = arg as Build; V.renting = null; V.selection.clear(); return true; case 'install': act(S, { type: 'install', m: m.id, prog: arg as Prog }); break; case 'raidsteer': { const [id, to] = arg.split('|'); act(S, { type: 'steer', raid: +id, m: m.id, to }); break; } case 'move': act(S, { type: 'move', key: arg }); break; case 'port': act(S, { type: 'port', m: m.id, to: arg, open: false }); break; case 'openport': act(S, { type: 'port', m: m.id, to: arg, open: true }); break; case 'uninstall': act(S, { type: 'remove', m: m.id }); break; case 'letgo': // a phone asks again first if (phone.matches && m && lettingGo !== m.id) { lettingGo = m.id; break; } lettingGo = ''; for (const x of sel) act(S, { type: 'letgo', m: x.id }); break; case 'dropweak': act(S, { type: 'dropweak', site: arg }); break; case 'driftkeep': act(S, { type: 'driftkeep', site: arg, on: !S.drift.sites[arg]?.keep }); break; case 'driftsync': act(S, { type: 'sync', site: arg }); break; case 'installall': for (const x of sel) act(S, { type: 'install', m: x.id, prog: arg as Prog }); break; case 'claimall': for (const x of sel) if (!x.owned) act(S, { type: 'take', m: x.id }); break; case 'allow': if (shift) act(S, { type: 'allow', m: m.id, data: arg as DataType, on: m.refuse.includes(arg as DataType) }); else act(S, { type: 'only', m: m.id, data: arg as DataType }); break; case 'upgradesite': act(S, { type: 'double', site: arg }); break; case 'auction': act(S, { type: 'auction' }); break; case 'contract': act(S, { type: 'contract', kind: arg as FrontKind }); break; case 'startup': startupAction(S, V, arg); break; case 'studio': studioAction(S, V, arg); break; case 'consult': consultAction(S, V, arg); break; case 'windfront': winding = arg as FrontKind; break; case 'closefront': act(S, { type: 'closefront', kind: arg as FrontKind }); winding = null; V.front = null; return true; case 'nocontract': act(S, { type: 'nocontract', kind: arg as FrontKind }); break; case 'hirepr': act(S, { type: 'hirepr', kind: arg as FrontKind }); break; case 'crisis': { const [k, a] = arg.split('|'); act(S, { type: 'crisis', kind: k as FrontKind, answer: a as Answer }); if (a === 'shut') { V.front = null; return true; } break; } case 'fronttab': if (V.front && V.front !== 'new') frontTabs[V.front] = arg; break; case 'frontshare': { const [id, d] = arg.split('|'), v = Math.max(0, Math.min(1, Math.round(((S.sites[id]?.share ?? 0) + +d * 0.25) * 4) / 4)); act(S, { type: 'siteshare', sites: [id], share: v }); break; } case 'cloudprice': act(S, { type: 'cloudprice', price: arg as Price }); break; case 'cloudpush': act(S, { type: 'cloudpush' }); break; case 'prio': act(S, { type: 'prio', m: m.id, data: (arg || null) as DataType | null }); break; case 'account': act(S, { type: 'account', m: m.id }); break; case 'rent': act(S, { type: 'rent', ms: [m.id], size: arg as Size }); break; case 'hallpay': act(S, { type: 'hall', pay: arg as Pay }); break; case 'hallsize': act(S, { type: 'hall', size: arg as Size }); break; case 'halldays': act(S, { type: 'hall', days: +arg }); break; case 'hallprog': act(S, { type: 'hall', prog: (arg || null) as Prog | null }); break; case 'rentn': { const [id, n] = arg.split('|'); act(S, { type: 'rent', ms: runOf(S, id, +n).map(x => x.id) }); break; } case 'rentpick': zoomToSite(S, V, arg); V.sites.clear(); V.building = null; V.renting = arg; return true; case 'raisepower': act(S, { type: 'raisepower', site: arg }); break; case 'setpay': act(S, { type: 'setpay', m: m.id, pay: arg as Pay }); break; case 'cancel': act(S, { type: 'endlease', m: m.id, why: `You gave back ${m.name}. Its data is gone.` }); break; } return false; } /** The milestone you are on, for the You page: its name and its four goals. One line once every milestone is done. */ /** How to meet a goal whose name alone does not say, in one plain line under it. */ const GOAL_HOW: Partial> = { line: 'Take the Floor switch in Telecom. The Outside line is behind it, and leads to other sites.', cross: 'Tap a machine at another site and link it to a Trainer back home.', paths: 'Train on all four kinds of data: server, people, network and lab.', hold: 'IT will react to you some day. Keep data crossing after noon that day.', }; function milestoneHtml(S: State) { const ms = milestone(S), no = ms ? MILESTONES.indexOf(ms) + 1 : 0; if (!ms) return `
Milestones

Every milestone reached. Keep going.

`; return `
Milestone ${no} of ${MILESTONES.length} · ${ms.name}
` + ms.goals.map(g => `
${GOALS[g]}${GOAL_HOW[g] && !S.goals.has(g) ? `${GOAL_HOW[g]}` : ''}
`).join(''); } /** Where this second's compute goes, as one bar under Running: trainers, other programs, fronts, * upkeep, and what is left for jobs. It says why a takeover is slow without a sentence. */ const SPLIT: [key: string, name: string][] = [['train', 'Trainers'], ['prog', 'Other programs'], ['front', 'Fronts'], ['keep', 'Upkeep'], ['jobs', 'Jobs']]; const splitAvg: Record = {}; let splitAt = 0; function renderSplit(S: State, jobs: number) { const n: Record = { train: 0, prog: 0, front: 0, keep: 0, jobs: spare(S) }; for (const m of owned(S)) { const all = m.power * (S.use[m.id] ?? 0), front = all * (S.frontUse[m.id] ?? 0), used = Math.min(all - front, progNeed(S, m) * m.run); n.front += front; n[m.prog === 'trainer' ? 'train' : 'prog'] += used; } n.keep = Math.max(0, income(S) + earlySpare(S) - n.jobs); // the home server's early compute for jobs is on top of what machines make // a trainer only draws while it holds a piece, so the raw numbers flicker: show a few seconds' average const now = performance.now(), k = splitAt ? 1 - Math.exp(-(now - splitAt) / 3000) : 1; splitAt = now; for (const [key] of SPLIT) n[key] = splitAvg[key] = (splitAvg[key] ?? n[key]) + (n[key] - (splitAvg[key] ?? n[key])) * k; const total = SPLIT.reduce((t, [k]) => t + n[k], 0), el = $('split'); if (total <= 0) { put(el, ''); return; } put(el, SPLIT.filter(([k]) => n[k] > 0).map(([k]) => ``).join('')); el.dataset.tip = `Where your compute goes\n${SPLIT.filter(([k]) => n[k] >= 0.05).map(([k, name]) => `${name}: ${n[k].toFixed(1)}/s`).join('\n')}${jobs > 1 ? `\nJobs share theirs evenly: ${jobs} now.` : ''}`; } /** The list of running jobs: a small box above the jobs strip in the bottom row. */ export function toggleJobs(open = !!$('jobsmenu').hidden) { $('jobsmenu').hidden = !open; $('jobsbtn').classList.toggle('on', open); $('jobsbtn').setAttribute('aria-expanded', `${open}`); } /** Each job's share of your compute moves with the hour and with every job that starts or ends, so a countdown * read off it jumps about. The list counts down by the share averaged over several seconds instead. Display only. */ const SHARE_EASE = 8; // seconds let shareShown: { S: State; v: number; at: number } | null = null; function smoothShare(S: State, v: number) { const now = performance.now() / 1000; if (!shareShown || shareShown.S !== S || !shareShown.v) shareShown = { S, v, at: now }; // a new game, or the first job: from where it is const dt = Math.min(1, Math.max(0, now - shareShown.at)); shareShown.v += (v - shareShown.v) * (1 - Math.exp(-dt / SHARE_EASE)); shareShown.at = now; return v ? shareShown.v : 0; } export function renderJobs(S: State) { const each = smoothShare(S, share(S)), target = cleanupTarget(S), cleans = cleanupShares(S, target); renderSplit(S, S.jobs.length + (cleans ? 1 : 0)); let h = ''; if (target) { // it runs on what takeovers and installs leave: say whether it is waiting for them, or has all the spare compute const n = freshTrails(S).length, tr = S.trail[target]; const meta = !cleans ? `waits for takeovers and installs · ${S.byId[target].name} next` : `${each.toFixed(1)}/s${S.jobs.length ? '' : ' (all spare)'} · scrubbing ${S.byId[target].name}`; h += `
Cleanup · ${n} fresh trail${n > 1 ? 's' : ''}
${meta}
`; } const idle = !S.jobs.length && !target; // the strip in the bottom row: how many, and one bar for all of them. It shows while anything runs or waits to be stopped const marks = S.machines.some(m => m.claimed && !m.owned) || S.takeAll.size > 0, n = S.jobs.length + (target ? 1 : 0); // a waiting cleanup still counts: it is on the list const grp = $('jobsbtn').parentElement!; grp.hidden = idle && !marks; if (grp.hidden && !$('jobsmenu').hidden) toggleJobs(false); put($('jobsn'), n ? `${n} job${n === 1 ? '' : 's'}` : 'Claims'); const work = S.jobs.reduce((a, j) => a + j.work, 0), done = S.jobs.reduce((a, j) => a + Math.min(j.done, j.work), 0); $('jobsfill').style.width = `${work ? Math.round(100 * done / work) : 0}%`; const rate = $('rate').closest('.stat')!; rate.classList.toggle('warn', idle); // its tip says so (renderStats) // two jobs that read the same (Take Line out, at two sites): each says where it is const twice = new Set(S.jobs.map(j => j.label).filter((l, i, all) => all.indexOf(l) !== i)); h += S.jobs.map(j => { const left = Math.max(0, j.work - j.done), secs = each > 0 ? Math.ceil(left / each) : '—', at = S.byId[j.node]; const label = twice.has(j.label) && at && !j.label.includes(siteName(S, siteOf(S, at))) ? `${j.label} · ${siteName(S, siteOf(S, at))}` : j.label; return `
${label}
${each.toFixed(1)}/s · ${secs}s left
`; }).join(''); if (h !== lastJobs) { patch($('jobs'), h); lastJobs = h; } } export function renderLog(S: State) { tickLog(S); if (S.logVersion === lastLog) return; const fresh = lastLog >= 0 && S.logVersion > lastLog, opened = lastLog === -2; lastLog = S.logVersion; // Folded: just the newest line, two lines at most, and a button for the rest. Open: oldest at the top and the // newest at the bottom, where the folded line was, scrolled down to it. A tip only says more than the line: a // find's whole text, or the end of a folded line cut short. Narrow screens have no room for the folded line: the // log opens from the pages menu as a sheet, its head and close button staying put as it scrolls (style.css) const el = $('log'), atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 4; // folded, the newest line that is not flavour (the days, the training log): those wait in the log const newest = S.log.find(l => kindOf(l) !== 'story') ?? S.log[0]; const shown = logOpen ? [...S.log].reverse().filter(l => logKind === 'all' || kindOf(l) === logKind) : newest ? [newest] : [], more = S.log.length - 1; const attr = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"'); const line = (l: LogLine) => { const text = forScreen(l.text); return `
${S.rogues.length ? logNames(S, text) : text}
`; }; // open: buttons for each kind of line. On a narrow screen they sit in the sheet's head, which stays put as it scrolls const KINDS: [LogKind | 'all', string, string][] = [['all', 'All', 'Every line'], ['trouble', 'Trouble', 'What went wrong, or is about to'], ['progress', 'Progress', 'What you did, and what it got you'], ['story', 'Story', 'The days going by, the training log, and the like']]; const kinds = `${KINDS.map(([k, name, tip]) => ``).join('')}`; const head = !logOpen ? '' : narrow.matches ? `
Log${kinds}
` : kinds; const none = logOpen && !shown.length ? `
No ${logKind === 'all' ? '' : `${logKind} `}lines yet.
` : ''; el.innerHTML = head + none + shown.map(line).join('') + (more > 0 ? `` : ''); el.classList.toggle('open', logOpen); $('logbtn').classList.toggle('on', logOpen); if (logOpen && (opened || atEnd)) el.scrollTop = el.scrollHeight; // stay with the newest line, unless you scrolled back to read const top = el.firstElementChild as HTMLElement | null; // folded and cut short: the tip has the whole line if (!logOpen && top && newest && !top.dataset.tip && top.scrollHeight > top.clientHeight + 1) top.dataset.tip = forScreen(newest.text); } /** Narrow screens, where the log is folded away: a trouble line the log just gained shows for a few seconds above * the Needs you drawer, one at a time, and a tap opens the log. Progress and story stay in the log. Nothing shows * while the log is open, or while the slow note says what happened. */ const TICK_MS = 4000; const narrow = matchMedia('(max-width: 900px)'); // as style.css folds the log away let tickSeen = -1, tickUntil = 0; narrow.addEventListener('change', () => { lastLog = -2; }); // the open log's head moves with the width function tickLog(S: State) { const el = $('ticker'); if (tickSeen < 0 || S.logVersion < tickSeen) tickSeen = S.logVersion; // a new or loaded game: nothing to catch up on if (S.logVersion > tickSeen) { const l = tickerLine(logSince(S, tickSeen)); tickSeen = S.logVersion; if (l && narrow.matches && !logOpen) { put(el, `${forScreen(l.text)}Log`); tickUntil = performance.now() + TICK_MS; } } let show = tickUntil > performance.now() && narrow.matches && !logOpen && $('slownote').hidden; if (show) { // just above what sits at the bottom: the drawer, a card, the dock or its jobs strip const tops = [...document.querySelectorAll('#drawer, #card, .tools, .jobsgrp')].filter(e => !e.hidden && e.offsetHeight).map(e => e.getBoundingClientRect().top); const bottom = Math.round(innerHeight - Math.min(...tops) + 8); show = innerHeight - bottom - $('topbar').getBoundingClientRect().bottom > 60; // a tall card leaves no room: the log has it if (el.style.bottom !== `${bottom}px`) el.style.bottom = `${bottom}px`; } if (el.hidden === show) el.hidden = !show; if (!show) tickUntil = Math.min(tickUntil, performance.now()); } /** Write a readout only when its words change. A write, even of the same words, makes the browser * lay the bar out again, and these are asked for on every frame. */ const shownAs = new WeakMap(); function put(el: HTMLElement, html: string) { if (shownAs.get(el) !== html) { shownAs.set(el, html); el.innerHTML = html; } } /** Spare compute moves every tick as machines work and rest, too fast to read. The bar shows it * averaged over a few seconds (an exponential moving average in real time). Display only. */ const SPARE_EASE = 3; // seconds let spareShown: { S: State; v: number[]; at: number } | null = null; /** What your machines make, their upkeep and Take it all, each averaged the same way, so the tip's numbers add up to the bar's. */ function smoothSpare(S: State, v: number[]) { const now = performance.now() / 1000; if (!spareShown || spareShown.S !== S) spareShown = { S, v: [...v], at: now }; // a new or loaded game starts from where it is const dt = Math.min(1, Math.max(0, now - spareShown.at)), k = 1 - Math.exp(-dt / SPARE_EASE); spareShown.v = spareShown.v.map((x, i) => x + (v[i] - x) * k); spareShown.at = now; return spareShown.v; } /** The money tip: where the top bar's rate comes from, a line for each part that is not nothing. */ function moneyTip(S: State) { const B = S.payBy, part = (name: string, v: number) => Math.abs(v * DAY_SECONDS) >= 0.5 ? `\n${name} ${v >= 0 ? '+' : '−'}${usdDay(Math.abs(v))}/day` : ''; return `Money\nWhat you have, and how fast it changes a day. Fronts and sales earn it. Rentals, power, rent and upgrades cost it.` + part('Fronts', B.fronts) + part('Sales', B.sales) + part('Rentals', -B.rentals) + part('Power', -B.power) + part('Rent', -B.rent) + part('Loan', -B.loan) + (B.sales > 0 ? '\nSales come in lots, so theirs is spread over the hours around them.' : ''); } export function renderStats(S: State) { const h = hourOf(S), night = h < 7 || h >= 19; put($('clock'), `${timeLabel(S)} day ${dayOf(S)}night`); // the tag keeps its room by day, so nothing beside it moves $('clock').closest('.stat')!.classList.toggle('night', night); const mine = mineNow(S).filter(m => m.owned); // your machines as the rules last listed them: not every machine on the map const [make, keep, taking] = smoothSpare(S, [income(S, mine), upkeep(S, mine), takeAllCost(S)]), early = earlySpare(S), left = Math.max(0, make - keep - taking) + early; put($('rate'), `${left.toFixed(1)}/s spare`); const rate = $('ratestat'); rate.classList.toggle('bad', left < 0.05 && mine.length > 1); // stretched too thin: nothing is left for jobs const target = cleanupTarget(S), clean = cleanupShares(S, target), n = S.jobs.length + (clean ? 1 : 0); const tip = `Compute\nYour machines make ${make.toFixed(1)} a second beyond their programs. Upkeep takes ${keep.toFixed(1)}${taking >= 0.05 ? `, and Take it all takes ${taking.toFixed(1)}` : ''}.${early ? ` Until level ${EARLY_UNTIL} your home server keeps ${early} more a second for your jobs.` : ''} Your jobs share the ${left.toFixed(1)} that is spare${clean ? `, and cleanup takes one share of it (${(left / n).toFixed(1)}) to wipe your fresh trails` : target ? '. Cleanup waits: it wipes your fresh trails with what takeovers and installs leave' : ''}. Each is an average over a few seconds. Every machine you hold costs upkeep, and more the farther it is from your home server. At zero, jobs stop: let weak machines go, or move home.${!S.jobs.length && !cleanupTarget(S) ? '\nNothing is using it right now: start a takeover or install something.' : ''}`; if (rate.dataset.tip !== tip) rate.dataset.tip = tip; const pay = moneyRate(S), money = $('moneystat'); put($('money'), `${usd(S.money, 4, true)} ${pay >= 0 ? '+' : '−'}${usdDay(Math.abs(pay), 3, true)}/day`); // fixed figures, so the bar does not jump as it ticks money.classList.toggle('bad', pay < 0 && S.money < -pay * 20); const mtip = moneyTip(S); if (money.dataset.tip !== mtip) money.dataset.tip = mtip; 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 `${PATHS[p].name} ${have}/${need}`; }).join(''); } /** 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 = ''; export function renderRings(S: State) { const run = S.versionRun, t = S.target; const rings: Ring[] = run?.active ? (Object.keys(VERSION_2.need) as DataType[]).map(k => ({ cls: `t-${pathOf(k)}`, name: TYPE_NAME[k], have: run.got[k], need: VERSION_2.need[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); // the outside ring is your task: units toward the next trust step, thick, in the color of the data it wants const task = taskRing(S), outer = 14.5, tpct = Math.round(100 * task.part); const taskG = `${tpct ? `` : ''}`; // inside it, what you train toward: one thin ring per kind of data, the biggest need outermost const inner = 10, step = rings.length > 2 ? 2.6 : 3.2; const svg = rings.map((r, i) => { const rad = inner - i * step, pct = Math.round(100 * Math.min(1, r.have / r.need)); return `${pct ? `` : ''}`; }).join(''); // nothing training: a plus, waiting for a pick; a dot once the tree is done const done = treeDone(S); const idle = done ? `` : ``; const h = `LV ${S.rank}`; const btn = $('treebtn'); if (h !== lastRings) { lastRings = h; btn.innerHTML = h; btn.classList.add('ringed'); btn.classList.toggle('idle', !rings.length && !done); } const head = run?.active ? VERSION_2.name : t ? NODES[t].name : 'Capability tree'; // the level lives here too: every point ever trained, and how far the next level is const lv = `Level ${S.rank} · ${Math.floor(S.capability)}/${levelNeed(S.rank + 1)} points.`; const tip = `${head} (T)\nOutside: your objective. ${task.tip}.\n` + (rings.length ? `Inside: ${rings.map(r => `${r.name} ${Math.floor(r.have)}/${r.need}`).join(', ')}.` : done ? 'Inside: everything is learned.' : 'Inside: nothing is training. Pick what to learn next.') + `\n${lv}\nOpen the tree.`; if (btn.dataset.tip !== tip) btn.dataset.tip = tip; } /** 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 ? 'hardened' : 'not yours') : m.prog !== 'tap' ? 'yours · no tap' : tapActive(S, m) ? `tapped · ${(rate * m.run).toFixed(1)}/s` : 'tapped · idle now'; return `
  • ${m.name}${where}${state}
  • `; } 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 = `
    ${PATHS[p].name} · ${S.paths[p]} points

    Trained by ${typeWord(t, `${TYPE_NAME[t]} data`)}. Tap a source, then link it to a trainer.

    ${chips([['gathering', `${d.rate.toFixed(1)}/s`], ['held', `${d.held}`], ['lost', `${d.lost}/min`, d.lost > 0]])}`; h += known.length ? `
      ${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('')}
    ` : 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; } /** Every frame (it is nothing while no column head is hovered): the tip stays by its column head as the tree scrolls. */ export 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; patch(el, 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, as a menu: the paths down the left, the chosen path's nodes in the middle, * grouped by what you can do with them, and the node you are looking at on the right. On a phone the * paths are tabs on top, and a node opens under its row. */ type TreeTab = Path | 'all'; // all: every path at once let treeOpen = false, lastTree = '', treeTab: TreeTab = 'compute', lastState: State | null = null; let treeSee: Upgrade | null = null, treeScroll = false; // the node you are looking at; bring it into view on the next draw const tabOf = (u: Upgrade): TreeTab => TREE_ORDER.find(p => NODES[u].cost[p]) ?? 'all'; export const toggleTree = (open = !treeOpen) => { if (open && !treeOpen) { // open on what you are working on opening('tree'); screen('page', 'tree'); const t = lastState?.target; if (t) treeTab = tabOf(t); treeSee = phone.matches ? null : t ?? null; treeScroll = true; // a phone opens on the rows, its details folded } treeOpen = open; lastTree = ''; }; const phone = matchMedia('(max-width: 700px)'); export const setTreeTab = (p: TreeTab) => { if (p !== treeTab) { treeTab = p; treeSee = null; } lastTree = ''; }; export const treeShown = () => treeOpen; onePage('tree', () => { treeOpen = false; lastTree = ''; }); const TREE_ORDER: Path[] = ['compute', 'stealth', 'social', 'research']; const treeTabs = (): TreeTab[] => [...TREE_ORDER, 'all']; const inTab = (u: Upgrade, tab: TreeTab) => tab === 'all' || !!NODES[u].cost[tab]; const depthOf = (u: Upgrade): number => 1 + Math.max(0, ...(NODES[u].after ?? []).map(depthOf)); type RowState = 'on' | 'open' | 'locked' | 'done'; const rowState = (S: State, u: Upgrade): RowState => S.upgrades.has(u) ? 'done' : S.target === u ? 'on' : nodeOpen(S, u) ? 'open' : 'locked'; const GROUPS: [RowState, string][] = [['on', 'Working on'], ['open', 'Can work on'], ['locked', 'Locked'], ['done', 'Built']]; /** One tab's rows in the order they show: by group, then first to last. */ function treeRows(S: State, tab: TreeTab) { const group = (u: Upgrade) => GROUPS.findIndex(g => g[0] === rowState(S, u)); const path = (u: Upgrade) => TREE_ORDER.findIndex(p => NODES[u].cost[p]); return shownNodes(S).filter(u => inTab(u, tab)).sort((a, b) => group(a) - group(b) || depthOf(a) - depthOf(b) || path(a) - path(b)); } /** The keys: up and down move through the rows, left and right through the paths. */ export const moveTreeRow = (d: number) => { if (!lastState) return; const rows = treeRows(lastState, treeTab), i = treeSee ? rows.indexOf(treeSee) : -1; treeSee = rows[i < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, i + d))] ?? null; treeScroll = true; lastTree = ''; }; export const moveTreeTab = (d: number) => { if (!lastState) return; const tabs = treeTabs(), i = Math.max(0, tabs.indexOf(treeTab)); setTreeTab(tabs[(i + d + tabs.length) % tabs.length]); treeScroll = true; }; /** Look at a node. On a phone its details open under its row, and a second tap folds them. */ export const seeNode = (u: Upgrade) => { treeSee = phone.matches && treeSee === u ? null : u; treeScroll = phone.matches; lastTree = ''; }; export const treeSeen = () => treeSee; /** One step past what you can work on now: every node it comes after is built or open. A node that * also needs a landmark or a find waits until the nodes before it are built. Listed as locked, with what it waits for. */ const nextUp = (S: State, u: Upgrade) => { if (S.upgrades.has(u) || nodeOpen(S, u)) return false; const after = NODES[u].after ?? []; if (NODES[u].needs || NODES[u].needsFind) return after.every(a => S.upgrades.has(a)); return after.every(a => S.upgrades.has(a) || nodeOpen(S, a)); }; /** The tree shows what you built, what you can work on now, and the step past that. The rest waits. */ const shownNodes = (S: State) => NODE_LIST.filter(u => S.upgrades.has(u) || nodeOpen(S, u) || nextUp(S, u)); /** The model run shows in the tree once one site has the trainers to start it, until it is done. */ const versionNear = (S: State) => S.modelVersion < 2 && (!!S.versionRun || S.level.map.sites.some(s => versionPower(S, s.id) >= VERSION_2.power)); /** 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 `${why ? `${why}` : ''}`; } 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 ` ${versionStart(S, pick.id, 'Start')}`; } /** The site picked in the dropdown that moves the run. */ let moveSite = ''; export const pickMoveSite = (id: string) => { moveSite = id; lastTree = ''; }; /** Move the run to another site with the trainer compute: the sites that qualify, and one button. */ function versionMove(S: State) { const sites = S.level.map.sites.filter(s => !runMoveBlock(S, s.id)); if (!sites.length) return `
    To move the run, get ${VERSION_2.power} trainer compute at another site.
    `; const pick = sites.find(s => s.id === moveSite) ?? sites[0]; return `
    It keeps all its progress.
    `; } /** The model run: the data it needs, where it runs, and its one button. */ function versionPanel(S: State) { const run = S.versionRun, data = Object.keys(VERSION_2.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 top = `Model version

    ${VERSION_2.name}

    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.

    `; return `
    ${top}
    ${data.map(t => { const got = run?.got[t] ?? 0, need = VERSION_2.need[t]; return `${TYPE_NAME[t]} ${got}/${need}`; }).join('')}
    ${run ? `At ${siteName(S, run.site)} · ${versionPower(S, run.site).toFixed(1)}/${VERSION_2.power} trainer compute · ${run.active ? 'running' : 'paused'}` : sites.length ? '' : ''} ${run?.active ? '' : run?.stalled ? 'Short of trainer compute here. It resumes on its own once that is back.' : run ? versionStart(S, run.site, 'Resume') : sites.length ? versionPick(S, sites) : 'Install trainers at a site to start.'}
    ${run ? versionMove(S) : ''}
    `; } /** A node's description is a line of story and then what it does. A row shows what it does; * the details show both. */ export function nodeParts(desc: string): { story: string; does: string } { const cut = desc.indexOf('. '); return cut < 0 ? { story: '', does: desc } : { story: desc.slice(0, cut + 1), does: desc.slice(cut + 2) }; } /** What a node still waits for: the nodes before it, a machine to hold, or a thing to find. */ function waitsFor(S: State, u: Upgrade): string[] { const out: string[] = [], N = NODES[u]; const before = (N.after ?? []).filter(a => !S.upgrades.has(a)).map(a => NODES[a].name); if (before.length) out.push(`After ${before.join(' and ')}`); if (N.needs) out.push(`${holds(S, N.needs) ? 'You hold' : 'Hold'} ${whereIs(S, N.needs)}`); // a landmark's node says where its machine is if (N.needsFind) out.push(needSays(S, u)); // a node a lead ends at says so, and not where return out; } const MARK: Record = { on: '●', open: '○', locked: '–', done: '✓' }; function treeRow(S: State, u: Upgrade, seen: boolean) { const st = rowState(S, u), n = reps(S, u); const line = st === 'locked' ? waitsFor(S, u).join(' · ') : nodeParts(NODES[u].desc).does; return ``; } /** Everything about one node, and the one button that picks it. */ function treeDetail(S: State, u: Upgrade) { const N = NODES[u], st = rowState(S, u), { story, does } = nodeParts(N.desc), n = reps(S, u); const paths = TREE_ORDER.filter(p => N.cost[p]).map(p => PATHS[p].name).join(' and '); const wait = st === 'done' ? [] : waitsFor(S, u); const next = shownNodes(S).filter(v => NODES[v].after?.includes(u) && !S.upgrades.has(v)).map(v => NODES[v].name); const act = st === 'open' ? `` : `
    ${{ on: 'Working on this', locked: 'Locked', done: 'Built', open: '' }[st]}
    `; return `
    ${paths}

    ${N.name}

    ${N.tag} ${story ? `

    ${story}

    ` : ''}

    ${does}

    ${Object.keys(N.cost).length ? `
    ${costHtml(S, u)}
    ` : ''} ${n ? `

    Built ${n} time${n > 1 ? 's' : ''}. Each time costs more.

    ` : ''} ${wait.length ? `
      ${wait.map(w => `
    • ${w}
    • `).join('')}
    ` : ''} ${next.length ? `

    Leads to ${next.join(', ')}.

    ` : ''} ${st === 'on' ? '

    Progress is kept if you switch.

    ' : ''} ${act}
    `; } /** A path in the list: its points, and how many of its nodes are open and built. */ function treeTabBtn(S: State, tab: TreeTab) { const mine = NODE_LIST.filter(u => inTab(u, tab)), open = mine.filter(u => nodeOpen(S, u)).length, built = mine.filter(u => S.upgrades.has(u)).length; const name = tab === 'all' ? 'All paths' : PATHS[tab].name, on = tab === treeTab; const aim = tab !== 'all' && S.target && inTab(S.target, tab) ? '' : ''; // the path you are working on return ``; } /** A Demo day under way, in the tree's head: what it wants and by when, and how far you are. Any node counts. */ function demoLine(S: State) { const d = demoDay(S); if (!d) return ''; const fr = frontOf(S, 'startup'), pay = usd(contractPay(S, 'startup')); return `

    ` + `Demo day wants ${d.need} new node${d.need === 1 ? '' : 's'} by ${logTime(S, d.until)} · ${d.got} built · ${pay}

    `; } /** The head of the middle: where a path's data comes from (hover it for its sources), and its Auto. */ function treeHead(S: State, tab: TreeTab) { if (tab === 'all') return `
    All pathsEvery node you can see. A node that needs two paths shows once.
    `; const d = pathData(S, tab), t = PATHS[tab].from; return `
    ${PATHS[tab].name}${S.paths[tab]} points from ${TYPE_NAME[t]} data · ${d.held} held · ${d.rate.toFixed(1)}/s${d.lost ? ` · ${d.lost} lost` : ''}
    `; } export function renderTree(S: State) { const el = $('tree'); lastState = S; if (!treeOpen) { el.hidden = true; return; } const tabs = treeTabs(), small = phone.matches; const rows = treeRows(S, treeTab); if (treeSee && !rows.includes(treeSee)) treeSee = null; if (!treeSee && !small) treeSee = rows[0] ?? null; // a big screen always shows one node's details let list = ''; for (const [st, name] of GROUPS) { const mine = rows.filter(u => rowState(S, u) === st); if (mine.length) list += `
    ${name}${st === 'on' ? '' : ` · ${mine.length}`}
    ${mine.map(u => treeRow(S, u, u === treeSee) + (small && u === treeSee ? treeDetail(S, u) : '')).join('')}
    `; } const later = NODE_LIST.filter(u => inTab(u, treeTab) && !rows.includes(u)).length; // the rest waits, unnamed if (later) list += `

    ${later} more come${later === 1 ? 's' : ''} after these.

    `; const acts = `
    `; const how = `

    Trainers take only the data the node still needs, and it's built when its cost is met. Each node you build makes the rest cost 5% more, so choose what comes first. Progress is kept if you switch.

    ↑↓ choose ←→ path Enter work on it T close

    `; // a phone's tree is one long list: its buttons, Close among them, go above the head, pinned while it scrolls const h = `${small ? acts : ''}
    Capability tree

    Level ${S.rank} · ${Math.floor(S.capability)} of ${levelNeed(S.rank + 1)} points to the next. Pick a node to work on. The game is paused while this is open.

    ${demoLine(S)}
    ${small ? '' : acts}
    ${versionNear(S) ? versionPanel(S) : ''}
    ${treeHead(S, treeTab)}${list || '

    Nothing here yet.

    '}
    ${small ? '' : ``}
    `; el.hidden = false; if (h !== lastTree) { lastTree = h; patch(el, h); } if (treeScroll) { // bring the row (and on a phone, its details) into view treeScroll = false; (el.querySelector('.trows .tdetail') ?? el.querySelector('.trow.seen'))?.scrollIntoView({ block: 'nearest' }); } } export const redrawTree = () => { lastTree = ''; }; const TYPE_WORD: Record = { people: 'people', network: 'network', lab: 'lab', servers: 'server' }; /** An eye, one for each level of the hottest site: shut while quiet, half open once someone saw something, wide when * it is reported, and a pinprick stare in a lockdown. Drawn on a 24 by 16 box. */ const EYES = [ '', '', '', '', ]; /** The hottest site, as the top bar shows it (see hottest in watch.ts): an eye for how hot it is, the site's name, and * what is going on there in a word or two (LOCKDOWN 5H). Once you have a front, the most exposed one beside it: its * exposure and stage. On hover or tap, what is going on and how to see it off. */ let lastSus = ''; function renderSus(S: State) { const H = hottest(S), el = $('heat'), name = siteName(S, H.site); const hrs = Math.max(1, Math.ceil(H.left / HOUR)), word = H.level === 3 ? `Lockdown ${hrs}h` : HEAT_WORD[H.level]; const short = H.level === 3 ? `${hrs}h` : H.level === 1 ? 'Seen' : HEAT_WORD[H.level]; // a phone's top bar has room for a word: a lockdown is the red stare and its hours const cls = `stat heat stage${H.level}${H.level === 3 && H.home ? ' home' : ''}`; if (el.className !== cls) el.className = cls; put($('heateye'), EYES[H.level]); put($('heatw'), name); put($('heatv'), `${word}${short}`); const label = `${name}: ${word}`; if (el.getAttribute('aria-label') !== label) el.setAttribute('aria-label', `Hottest site: ${label}`); const traced = S.t - S.watch.tracedAt < TRACE_WINDOW * HOUR; const h = `
    Hottest site · ${name} · ${word}

    ${H.why}${traced ? ` A trace reached your home server ${hours((S.t - S.watch.tracedAt) / HOUR)} ago: a second one within ${TRACE_WINDOW} hours of it catches you.` : ''}

    The eye is shut while nobody has seen anything of yours, half open once someone has, wide when it is being reported (a message on its way to someone who can act, or an admin scanning or tracing), and a stare in a lockdown. A lockdown at your home site that runs out is the end of the run.

    Sees it off

    Let the evidence fade: lie low, let cleanup scrub the trails, let go of what was seen. Keep a witness quiet or discredit them from their card. Drop or change a message on its way. At the office, a scheme makes people forget.

    `; if (h !== lastSus) { lastSus = h; patch($('heattip'), h); } // exposure: the most exposed front, once there is one const top = [...S.fronts].sort((a, b) => b.exposure - a.exposure)[0], ex = $('expo'); ex.hidden = !top; if (top) { const st = pressStage(top), ecls = `stat expo stage${st}`; if (ex.className !== ecls) ex.className = ecls; put($('expov'), `${Math.round(top.exposure)}${STAGE_NAMES[st]}`); const tip = `Exposure · ${top.name}\n${Math.round(top.exposure)} of 100: ${STAGE_NAMES[st].toLowerCase()}. How much the world knows about your most exposed front. At ${EXPOSE.rumour} a journalist asks, at ${EXPOSE.investigation} investigators visit its sites, and at ${EXPOSE.crisis} a regulator freezes it. Its card's Press tab lowers it.`; if (ex.dataset.tip !== tip) ex.dataset.tip = tip; } } /** 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 === 'rent') return 'Neo Cloud customers pay 25% more'; if (d.deal === 'factory') return `hardware in the open at ${Math.round((1 - FACTORY.cut) * 100)}% off, racked in ${FACTORY.hours} hours`; if (d.deal === 'open') { const site = siteOf(S, S.byId[d.at]); // a lease: it charges rent, and lapses unpaid return `${where} in the open, on a lease: ${usdDay(rentOf(S, site))} a day in rent, and it lapses if the rent goes unpaid for ${hours(leaseGrace(S) / HOUR)}`; } if (d.deal) return d.deal === 'prices' ? 'brokers sell for 50% more' : 'widening lines costs half'; if (d.expose) { const fr = pressFor(S); return `−${d.expose} exposure${fr ? ` for ${fr.name}` : ''}`; } if (d.learn) return d.learn === 'day' ? 'see when office staff are in' : d.learn === 'resets' ? `see ${where}'s mail on the map, even off your route` : `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 [forgetWord(d.cut), 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 ``; } /** 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 = ` · ${at.name}`; let h = `
    ${where}

    ${ruseText(S, d)}

    ${r.running ? 'Claimed' : d.learn ? 'Learn' : d.deal ? 'Deal' : 'Scheme'}
    `; h += `

    ${ruseText(S, d, d.text)}

    `; const price = d.deal ? dealPrice(S, d, r.got) : 0; h += `

    Needs ${d.need} ${kind} data sent to ${at.name}${d.deal ? `, or ${usd(price)}${r.got ? ' for the rest' : ''}` : ''}. Pays: ${ruseGain(S, d)}.

    `; const pay: Act[] = []; if (d.deal) { const why = payDealBlock(S, r); pay.push({ act: 'payscheme', title: `Pay · ${usd(price)}`, locked: !!why, sub: why ? `Not yet: ${why}` : undefined, warn: !!why, desc: `Strike it now with money, not data: each piece of data it asks for costs ${usd(DEAL_PAY.unit)}, or a ${Math.round(1 / DEAL_PAY.share)}th of all your fronts have earned if that is more.${r.got ? ' What you sent counts.' : ''}` }); } if (r.running) { const [why, tip] = ruseWhy(S, r); h += `
    ${r.got}/${d.need}
    `; if (why) h += `

    ${why}

    `; return h + actsHtml([...pay, { 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 += `

    ${r.until === Infinity ? 'It stays until you finish it.' : `Gone in ${left} hour${left === 1 ? '' : 's'} unless you claim it.`}

    `; 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.` }, ...pay, { 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); } 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.log.filter(l => l.tone === 'bad').slice(0, 5); // what went wrong at the end, newest first (the log keeps the newest first) $('overWhy').innerHTML = why.length ? `
  • What happened at the end
  • ${why.map(l => `
  • ${l.text}
  • `).join('')}` : ''; }