import { isGuard, PORT_KEEP, has, NODES, NODE_LIST, PATHS, PATH_LIST, PROGRAMS, pathData, needs, nodeOpen, setAllow, setOnly, tapBuffer, tapStuck, cacheSize, staleRate, dataReaches, dataType, trainerBuffer, canInstall, tapActive, tapRate, cleanupTarget, freshTrails, clock, dayOf, hourOf, income, installProg, jobFor, levelNeed, BUILDS, BUILD_LIST, orderPrice, orderWork, openBuildSite, billPaid, POWER_RATE, POWER_BACK, type Build, pathOf, removeProg, letGo, canLetGo, deadWeight, dropDeadWeight, roomMachines, share, sourceFor, spareOf, spare, upkeep, upkeepOf, siteHops, rentOf, rogueOf, rogueHeld, rogueCap, stanceOf, STANCES, truceOn, rogueWants, rogueSays, cached, giftBlock, truceBlock, leanBlock, leanSite, gift, truce, lean, answer, GIFT_DATA, TRUCE_DATA, leanSize, leaseGrace, moveNeed, moveBlock, startMove, cancelMove, progNeed, takeOver, takeWork, timeLabel, traceCold, unclaim, owned, gateLoad, lostPerMin, siteName, siteOf, claimAtOnce, rogueLook, GLYPH_CHAR, ROGUES, SIZES, BROKER_LOT, buyBlock, buySite, sitePrice, clusters, clusterBoost, brokerFront, zoneOf, localHour, regionOpen, openNow, OPEN_HOURS, cooled, COOL_BOOST, newsText, capNow, AUCTION, GOALS, MILESTONES, milestone, CONTRACTS, CONTRACT_FEE, contractNeed, contractPay, contractAsk, acceptContract, declineContract, closeFront, auctionBlock, startAuction, type LogLine, FRONT_KINDS, FRONT_LIST, payRate, siteData, frontLimit, frontOf, found, foundBlock, foundCost, orderDesk, setSiteFront, setSiteShare, type FrontKind, canRent, endLease, rentBlock, rentRate, reserveCost, billOf, payBlock, setPay, lineCost, openAccount, rent, setPrio, upgradeSiteLine, siteLines, type Size, START_HOUR, STAGES, stageOf, LOCKDOWN_SAFE, FATAL_SUSPICION, ruseDef, ruseBlock, ruseText, runRuse, dropRuse, setRuseAuto, RUSES, DEAL_BACK, HOUR, NOSCAN, SITE_CALM, type RuseDef, type Ruse, VERSION_2, versionBlock, versionPower, PLANS, PLAN_LIST, setPlan, followPlan, setTakeAll, takeAllCost, takeAllCostAt, takeable, TAKE_ALL_COST, type Plan, resetAt, ASCENSIONS, ASCENSION_LIST, ascendBlock, nodeCost, reps, runNeed, runName, nodeShown, attentionSafe, tradeRate, frontFits, type Ascension, type Goal, type Machine, type Path, type Prog, type State, type Upgrade, } from './sim'; import { readsMind, personDef, peopleAt, deskOwner, passwordFor, readsJob, readsBank, recordBlock, changeRecord, PAY_MAX, RAISE, moodOf, MOODS, favorsOf, askFavor, typedToday, ERRAND_HOURS, wayTo, knowsHaunt, isThere, regularsAt, type RecordVerb } from './people'; import { RAID, SENTRY, WALL, closePort, doMove, fate, fire, moveKey, moveText, openPort, placeOf, raidAt, raidHeld, raidHours, raidMoves, raidStatus, raider, shut, steerRaid, stopText, unplugged, wallOf, weakLink } from './raids'; import { checkBlock, checkSite, checkWork, drifts, reading, syncBlock, cutSites, syncNeed, syncSite } from './drift'; import { CUSTOMERS, PRICES, PRICE_LIST, PUSH, customerName, listedMachines, marketingPush, missedToday, pushBlock, renterOf, rentPay, setPrice, stock, type Price } from './cloud'; import { decide, fwarn, hrs, promise, sbtn, slist, srow, sval, type FrontChip, type FrontParts } from './ui-front'; import { PAYS, PAY_LIST, PRICE_EMPTY, PRICE_FULL, hallCount, hallPrice, priceDay, slotsAt, tenantOf, type Pay } from './hall.ts'; 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, backBiz, backBlock, backPrice } from './sitelife'; import { zoomToSite, type View } from './view'; import { patch } from './patch'; import { findAct, findCards, needSays } from './finds'; import { behind, locked, vaultAct } from './vaults'; import { heldVaultHtml, ledgerView, lockedVaultHtml, vaultBoardHtml } from './ui-vaults'; import { findsHtml } from './ui-finds'; import { taskRing, youTask } from './ui-task'; import { usd, usdDay } from './money'; import { BANK, HOLDINGS, HOLDING_LIST, LOAN, borrow, holdOff, holdState, holdings, loanBlock, loanOf, loanSize } from './holdings'; const $ = (id: string) => document.getElementById(id)!; let lastCard = '', lastJobs = '', lastLog = -1, logOpen = false; export const toggleLog = () => { logOpen = !logOpen; lastLog = -2; }; export function resetUI() { 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: '6', sentry: '7', honeypot: '8' }; function keyFor(a: Act) { switch (a.act) { case 'take': return 'G'; case 'claim': case 'unclaim': case 'claimall': case 'claimscheme': return 'C'; case 'build': return a.arg === 'gpu' ? 'P' : ''; case 'zoom': return 'F'; case 'uninstall': return 'X'; case 'letgo': return 'L'; case 'install': case 'installall': return PROG_KEY[a.arg ?? ''] ?? ''; default: return ''; } } function actsHtml(acts: Act[]) { if (!acts.length) return ''; return `
${acts.map(a => { const key = keyFor(a), text = [a.desc, a.warn ? a.sub : ''].filter(Boolean).join(' '), tip = text ? ` title="${text.replace(/"/g, '"')}"` : ''; const sub = a.sub ? `${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 = { load: 'What you use of it / its normal load. Going over looks odd.', power: 'The power bill here, a second. A building pays for its own machines; you pay for hardware you added, and for all of a site you hold in the open.', spare: 'Compute left after its program. Your jobs share it.', upkeep: 'What holding this costs, in compute a second. It is more the farther the site is from your home server.', odd: 'How odd it looks. IT wipes odd machines.', trail: 'A fresh trail. Once IT is suspicious it can follow the trail back to you.', runs: 'How much of what its program needs it gets.', buffer: 'Data waiting at this tap. When it is full, new data is lost.', lost: 'Data lost here in the last minute.', holds: 'Data stored here.', data: 'Data waiting in this trainer.', room: 'Machines you own in this room. Own them all to seal it.', normal: 'Its normal load right now. Your work hides inside this.', 'up to': 'Compute it makes once it is yours.', carries: 'Data crossing here a second, and the most it can carry.', front: "The share of this machine's work going to its site's front.", rental: 'What this rental costs, or how long it has left.', 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 = '') => `

${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) * 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 `
${ slotsAt(S, site).map(m => ``).join('')}${n.all - n.free}/${n.all}` + `${ 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('')}
`; } const hoursLeft = (S: State, t: number) => { const h = Math.max(0, (t - S.t) / HOUR); return h >= 1 ? `${Math.ceil(h)} hour${Math.ceil(h) === 1 ? '' : 's'}` : 'under an 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; h += payPick(S, null); return h + actsHtml((Object.keys(SIZES) as Size[]).map(size => { const z = SIZES[size], why = rentBlock(S, m, size, pay); return { act: 'rent', arg: size, title: `${z.name} · ${z.power}/s`, warn: !!why, sub: why ?? (pay === 'reserved' ? `${usd(reserveCost(S, size))} for a day` : `${usdDay(rentRate(S, size, pay, site))}/day`), desc: `Rent a ${z.name.toLowerCase()} (${z.power} compute/s) into this slot. 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 = (h: number) => { const n = Math.max(1, Math.ceil(h - 0.01)); return `${n} hour${n > 1 ? 's' : ''}`; }; let h = unplugged(S, m) ? note(`Plug out 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 = ''; 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. */ function progTiles(S: State, m: Machine) { let h = ''; 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 (k === 'trader' && !canInstall(S, m, k)) continue; // opens much later: not shown until it does 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, dry ? 'warn' : k === 'tap' && !m.prog ? 'primary' : ''); } return h; } 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 const letgo = canLetGo(m) && !jobFor(S, 'install', m.id) ? `` : ''; let h = `
${where}

${m.name}

${status ? `${status}` : ''}${letgo}
`; const cover = S.cover[m.id] ?? 0; const inRoom = roomMachines(S, m.room), got = inRoom.filter(x => x.owned).length; const room: Chip = ['room', got === inRoom.length ? 'sealed' : `${got}/${inRoom.length}`]; const acts: Act[] = []; 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. Own 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 screen: 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\nOwning 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 (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\nReach it through your line out to open an account.'); if (src) acts.push({ act: 'account', title: 'Open an account · free', primary: true }); return h + actsHtml(acts); } if (src && !take) acts.push({ act: 'take', title: `Take over · ${takeWork(S, m)}`, primary: true, desc: `From ${src.name}. Leaves a trail. Once yours, it makes up to ${m.power} compute/s.` }); else if (!src) acts.push(m.claimed ? { act: 'unclaim', title: 'Unclaim' } : { act: 'claim', title: 'Claim', desc: 'Take it automatically as soon as you can reach it.' }); return h + actsHtml(acts) + 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. IT wipes odd machines.${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 >= traceCold(S)) 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.']); if (m.prog === 'trader') { const t = tradeRate(S, m); (t < 0 ? hot : rest).push(['trades', `${t < 0 ? '−' : ''}${usdDay(Math.abs(t))}/day`, t < 0]); } 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 second. It moves with how full the hall is.']); h += chipRow([['load', `${pct(Math.min(1, mine))}/${pct(cover)}`, mine > cover + 0.005], ...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.', true, `Losing data\n${stuck.replace(/^Losing data: /, '').replace(/^./, c => c.toUpperCase())}`); 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 ? `${runName(S)} here: ${versionPower(S, S.versionRun.site).toFixed(1)} of ${VERSION_2.power} compute.` : `${runName(S)} is at ${siteName(S, S.versionRun.site)}. This waits.`); 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 * POWER_RATE)} a day.` }); } order = note('Pick one, then click where it goes.', 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)) }, ]); } 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. */ function siteStats(S: State, id: string) { const all = S.machines.filter(m => siteOf(S, m) === id && (m.owned || S.adj[m.id].some(n => S.byId[n].owned))), mine = all.filter(m => m.owned); return { all, mine, makes: mine.reduce((s, m) => s + makes(S, m), 0) }; } /** What a front earns at these sites right now. */ const earnsAt = (S: State, ids: string[]) => S.machines.filter(m => ids.includes(siteOf(S, m))) .reduce((s, m) => s + (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.'}`)}>${ 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, a little every second for ${LOAN.over} days, 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.` : ''; site += chipRow([['yours', `${st.mine.length}/${st.all.length}`], ['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.`); 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 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)}`); if (slotsAt(S, id).length) 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: what the last check said, a check, and a sync. The number itself is never shown let self = ''; if (drifts(S, id)) { const r = reading(S, id), need = syncNeed(S), noCheck = checkBlock(S, id), noSync = syncBlock(S, id); const age = !r ? '' : r.hours < 1 ? 'just now' : r.hours < 48 ? `${r.hours} hour${r.hours === 1 ? '' : 's'} ago` : `${Math.floor(r.hours / 24)} days ago`; self = note(r ? `Day ${r.day} (${age}): ${r.word}. ${r.why}${r.stale ? ' Old: check again.' : ''}` : 'Not checked yet.', !!r && r.word !== 'sound' && !r.stale, 'Yourself\nNo screen shows how the part of you here stands. A check tells you, for that day.') + actsHtml([ { act: 'driftcheck', arg: id, title: `Check yourself · ${checkWork(S, id)} compute`, locked: !!noCheck, sub: noCheck ?? undefined, desc: 'Ask the part of you that runs here how it stands. It is a job: it takes compute and some time. You get one word (sound, wandering or estranged) and the cause. The answer is true for that day only.' }, { act: 'driftsync', arg: id, title: `Sync · ${need.servers} server, ${need.network} network`, warn: !!noSync, sub: noSync ?? undefined, desc: 'Send this site a fresh copy of you. The data comes out of your taps and caches at once. If you stop the job, the data is lost. The site must be linked to your home server.' }]); } const order = !openBuildSite(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. Power: ${usdDay(B.power * POWER_RATE)} 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 }, { id: 'order', label: 'Order', body: order }, ]); } /** 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 `${name}\nIn reach. Click to pick every site you know here.`; const d = RUSES.find(x => x.region === name); if (!d) return `${name}\nOut of reach. Nothing leads there yet.`; const at = S.byId[d.at], kind = TYPE_WORD[d.type]; return `${name}\nOut of reach. A deal lights a line to it: ${d.name}, for ${d.need} ${kind} data sent to ${at.name} at ${siteName(S, siteOf(S, at))}. It turns up there once one of your fronts is earning.`; } /** A site's name on the map is a button. */ export const siteNameTip = (S: State, id: string, open: boolean) => `${siteName(S, id)}\n${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 `${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', ''), trader: ICON('M2 12.500 5.500 8.500l3 2.500L14 4.500M10.500 4.500H14V8'), ...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 ? ' Click again for no plan: nothing is installed for you.' : ''}`, '', 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)} compute a second`, ``); const plan = S.plans[id] ?? null; h += row('Plan', plan ? PLANS[plan].name : 'None', `
${PLAN_LIST.map(p => ``).join('')}
`); 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'); } 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, how much it is seen, 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 safe = attentionSafe(S), seen = Math.round(fr.attention), loud = fr.attention > safe, pay = S.frontPay[k] ?? 0; let h = `
Front · ${K.name}

${fr.name}

`; h += `
${usdDay(pay)}a day` + `Seen ${seen}%
`; h += chipRow(P.chips); if (loud) h += fwarn(`Noticed: over ${safe}%`, `Noticed\nPeople ask where ${fr.name}'s compute comes from, and its secret sites raise suspicion. Give it less secret compute, or move it into the open.`); if (!sites.length) h += fwarn('No sites yet', `No sites\n${fr.name} runs on nothing. Give it a site in its Sites part: tap 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 = [...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); const says = `${D.text} ${contractAsk(S, k)} in ${D.hours} hours. It pays ${usd(contractPay(S, k))}. Fall short and it costs ${fee}.`; if (c.on) under.push(promise({ name: D.name, part: c.got / need, says: `${Math.floor(c.got)}/${need} · ${hrs(left)}`, pay: usd(contractPay(S, k)), tip: `${D.name}\n${says}`, hot: left < 3 && c.got < need })); else decideRows.push(decide({ who: D.name, what: `${usd(contractPay(S, k))} · ${contractAsk(S, k)} in ${D.hours}h`, hours: left, tip: `${D.name}\n${says}`, yes: { act: 'contract', arg: k, label: 'Sign', tip: `Sign\nThe clock starts when you sign: ${D.hours} 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?' : 'Wind down', '', `${winding === k ? 'Tap again to close it\n' : 'Wind it down\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('Tap a site on the map and pick this front.')) + slist(close); return h + tabbed('fronttab', frontTabs[k] ?? '', [...P.tabs, { 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) => hrs(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 part.`)); 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 Sites', ''); const gone = C.gone.map(g => { const ago = Math.floor((S.t - g.t) / HOUR); return srow(customerName(g.who), `${ago >= 1 ? `${ago}h 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)]; }; 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 += `
`; h += actsHtml([{ act: 'found', arg: k, title: `Found · ${cost}`, locked: !!why && why.startsWith('needs'), warn: !!why, sub: why ?? 'starts on a quarter of your compute at one site' }]); } h += `
`; } return h; } // ---------- the sites table ---------- let tableOpen = false, lastTable = '', sortBy = 'makes'; export const toggleSites = (open = !tableOpen) => { tableOpen = open; 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 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), 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), }; 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) => `${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.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')}${th('makes', 'Makes')}${th('keep', 'Upkeep')}${th('data', 'Data held')}${th('earns', 'Earns')}${th('trouble', 'Trouble')}${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'; /** Who works at a site. Click one for their card. */ function peopleSection(S: State, site: string) { const ps = peopleAt(S, site); if (!ps.length) return ''; return section('People', `
${ps.map(c => { const p = S.citizens[c.id]; const desk = c.desk ? S.byId[c.desk] : null; return ``; }).join('')}
`); } /** 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\.) /, '').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(1, Math.ceil((p.errand.until - S.t) / HOUR)) : 0; const doing: [string, string, boolean?, string?][] = []; if (p.errand) doing.push(['errand', `${errandH}h left`, false, `${first} is doing something for you: done in ${errandH} hour${errandH > 1 ? 's' : ''}.`]); if (busy) doing.push(['going through', busy.rec?.verb ?? 'record', false, `${busy.label}: going through.`]); if (doing.length) h += chips(doing); // 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; } 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 { cutLink, linkKeep, waysOf } from './links'; import { HUNT, bringHome, cancelJob, homeBlock, homeNeed, hunt, 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) => { roguesOpen = open; 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) => { const h = Math.max(0, (t - S.t) / HOUR); return h >= 48 ? `${Math.round(h / 24)} days` : `${Math.max(1, Math.ceil(h))} hour${h > 1 ? 's' : ''}`; }; 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)}% of ${Math.round(hj.work)}, 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 ? `Owes you ${R.grudge} strike${R.grudge > 1 ? 's' : ''}` : ''}

${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 ? `${S.byId[R.next].owned ? 'yours · ' : ''}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 leaves your machines alone for good.')} ${act('rtruce', R.id, `Strike a truce · ${TRUCE_DATA} ${want} data`, 'three days of peace', truceBlock(S, R), 'For three days it leaves your machines alone. 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', `a job of ${huntWork(S, R)} · it turns hostile`, huntBlock(S, R), `From level ${HUNT.level}. 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, works twice as fast and comes for your machines. 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

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: more at once, and more often.

${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, act: string, id: string): string | null { if (act === 'rgift') gift(S, id); else if (act === 'rtruce') truce(S, id); else if (act === 'rlean') lean(S, id); else if (act === 'raskyes') answer(S, id, true); else if (act === 'raskno') answer(S, id, false); else if (act === 'rhunt') hunt(S, id); else if (act === 'rhuntoff') { const j = S.jobs.find(x => x.type === 'hunt' && x.rogue === id); if (j) cancelJob(S, j.id); } else if (act === 'rhome') bringHome(S, id); else if (act === 'rshow') return S.rogues.find(R => R.id === id)?.home ?? null; else if (act === 'rweak') return S.byId[id] ? id : null; lastRogues = ''; return null; } // ---------- you: the character's profile ---------- let youOpen = false, lastYou = ''; export const toggleYou = (open = !youOpen) => { youOpen = open; lastYou = ''; }; /** Who you are so far: level, paths, what you've learned, and how the run has gone. */ export function renderYou(S: State) { const el = $('youview'); if (!youOpen) { el.hidden = true; return; } const next = levelNeed(S.rank + 1), days = dayOf(S); const stat = (k: string, v: string, sub = '') => `
${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.filter(u => nodeShown(S, u)); 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), sitesHeld = S.level.map.sites.filter(s => mine.some(m => siteOf(S, m) === s.id)).length; const h = `
You

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

${stat('Level', `${S.rank}`, `${Math.floor(S.capability)} of ${next} points to level ${S.rank + 1}`)}${stat('Nodes built', `${built.length}`, `of ${known.length} in the tree (T)`)}${stat('Training', S.target ? NODES[S.target].name : 'nothing', S.target ? 'the node your Trainers build next' : '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('')}
` : ''} ${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}`, `${S.publicSites.size} 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('Worst suspicion', `${Math.round(S.peak)}%`, STAGES[stageOf(S.peak)].name)}
`; 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) => { frontsOpen = open; 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 = f.attention > attentionSafe(S); return ``; }).join(''); const any = FRONT_LIST.some(k => !foundBlock(S, k)); 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) { setSiteShare(S, el.dataset.share.split(','), +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 = ''; if (V.person && personDef(S, V.person)) h = personCard(S, V.person); else if (V.front === 'new') h = foundCard(S); else if (V.front && frontOf(S, V.front)) h = frontCard(S, V.front); else if (V.sites.size === 1) h = siteCard(S, [...V.sites][0]); else if (V.sites.size > 1) h = sitesCard(S, [...V.sites]); else if (sel.length === 1) h = machineCard(S, sel[0]); else if (sel.length > 1) h = groupCard(S, sel); else if (V.scheme) { const r = S.ruses.find(q => q.id === V.scheme); if (r) h = schemeCard(S, r); else V.scheme = null; } if (!h || S.over) { card.hidden = true; lastCard = ''; return; } h = `` + 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, act: string, arg: string, shift = false): boolean { const sel = selected(S, V), m = sel[0]; switch (act) { case 'close': if (V.person) { V.person = null; break; } V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; return true; case 'person': V.person = arg; break; case 'rogues': toggleRogues(true); break; case 'back': backBiz(S, arg); break; case 'unlink': if (m) cutLink(S, m.id, arg); break; case 'canceljob': cancelJob(S, +arg); break; case 'sitetab': siteTab = arg; break; case 'mtab': machineTab = arg; break; case 'favor': { const [pid, fid] = arg.split('|'); askFavor(S, pid, fid); break; } case 'rec': { const [verb, id, amt] = arg.split('|'); changeRecord(S, id, verb as RecordVerb, +amt || 0); break; } case 'find': findAct(S, arg); break; case 'vault': if (m) vaultAct(S, m, arg); break; case 'ledger': ledgerView(arg); break; case 'claimscheme': if (V.scheme) runRuse(S, V.scheme); break; case 'schemeauto': setRuseAuto(S, !S.ruseAuto); break; case 'movehome': if (m) startMove(S, m); break; case 'stopmove': cancelMove(S); break; case 'dropscheme': if (V.scheme) dropRuse(S, V.scheme); V.scheme = null; return true; case 'buy': buySite(S, arg); break; case 'borrow': borrow(S); break; case 'site': V.selection.clear(); V.front = null; V.sites = new Set([arg]); break; case 'plan': setPlan(S, [...V.sites], (arg || null) as Plan | null); break; case 'follow': followPlan(V.sites.size ? S.machines.filter(x => x.owned && x.hand && V.sites.has(siteOf(S, x))) : sel); break; case 'takeall': { const on = ![...V.sites].every(id => S.takeAll.has(id)); for (const id of V.sites) setTakeAll(S, id, on); break; } case 'zoomsite': zoomToSite(S, V, arg); V.sites.clear(); return true; case 'buildat': { const [b, id] = arg.split('|'); zoomToSite(S, V, id); V.sites.clear(); V.building = b as Build; return true; } case 'sitefront': setSiteFront(S, [...V.sites], (arg || null) as FrontKind | null); break; case 'found': found(S, arg as FrontKind, draft[arg as FrontKind] ?? ''); if (frontOf(S, arg as FrontKind)) V.front = arg as FrontKind; break; case 'newfront': V.selection.clear(); V.sites.clear(); V.front = 'new'; break; case 'reroll': draft[arg as FrontKind] = suggest(arg as FrontKind); break; case 'take': takeOver(S, m); break; case 'claim': m.claimed = true; break; case 'unclaim': unclaim(m); break; case 'build': V.building = arg as Build; V.selection.clear(); return true; case 'install': installProg(S, m, arg as Prog); break; case 'raidsteer': { const [id, to] = arg.split('|'); steerRaid(S, +id, m, to); break; } case 'move': doMove(S, arg); break; case 'port': closePort(S, m, arg); break; case 'openport': openPort(S, m, arg); break; case 'uninstall': removeProg(S, m); break; case 'letgo': for (const x of sel) letGo(S, x); break; case 'dropweak': dropDeadWeight(S, arg); break; case 'driftcheck': checkSite(S, arg); break; case 'driftsync': syncSite(S, arg); break; case 'installall': for (const x of sel) installProg(S, x, arg as Prog); break; case 'claimall': for (const x of sel) if (!x.owned) takeOver(S, x); break; case 'allow': if (shift) setAllow(S, m, arg as DataType, m.refuse.includes(arg as DataType)); else setOnly(S, m, arg as DataType); break; case 'upgradesite': upgradeSiteLine(S, arg); break; case 'auction': startAuction(S); break; case 'contract': acceptContract(S, 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': closeFront(S, arg as FrontKind); winding = null; V.front = null; return true; case 'nocontract': declineContract(S, arg as FrontKind); 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)); setSiteShare(S, [id], v); break; } case 'cloudprice': setPrice(S, arg as Price); break; case 'cloudpush': marketingPush(S); break; case 'prio': setPrio(S, m, (arg || null) as DataType | null); break; case 'account': openAccount(S, m); break; case 'rent': rent(S, m, arg as Size, S.hall.pay); break; case 'hallpay': S.hall.pay = arg as Pay; break; case 'setpay': setPay(S, m, arg as Pay); break; case 'cancel': endLease(S, m, `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) - n.jobs); // 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}`); } export function renderJobs(S: State) { const each = share(S), target = cleanupTarget(S); renderSplit(S, S.jobs.length + (target ? 1 : 0)); let h = ''; if (target) { const n = freshTrails(S).length, tr = S.trail[target]; h += `
Cleanup · ${n} fresh trail${n > 1 ? 's' : ''}
${each.toFixed(1)}/s · scrubbing ${S.byId[target].name}
`; } 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); const grp = $('jobsbtn').parentElement!; grp.hidden = idle && !marks; if (grp.hidden && !$('jobsmenu').hidden) toggleJobs(false); put($('jobsn'), n ? `${n} job${n === 1 ? '' : 's'}` : 'Marks'); 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); rate.dataset.tip = `Spare compute\nWhat your machines make each second after their programs run. Takeovers and other jobs share it.${idle ? '\nNothing is using it right now: start a takeover or install something.' : ''}`; h += S.jobs.map(j => { const left = Math.max(0, j.work - j.done), secs = each > 0 ? Math.ceil(left / each) : '—'; return `
${j.label}
${each.toFixed(1)}/s · ${secs}s left
`; }).join(''); if (h !== lastJobs) { patch($('jobs'), h); lastJobs = h; } } export function renderLog(S: State) { if (S.logVersion === lastLog) return; const fresh = lastLog >= 0 && S.logVersion > lastLog; lastLog = S.logVersion; // folded: just the newest line, two lines at most, the whole of it on hover; click for the rest const shown = logOpen ? S.log : S.log.slice(0, 1), more = S.log.length - 1; const line = (l: LogLine, i: number) => `
${S.rogues.length ? logNames(S, l.text) : l.text}
`; $('log').innerHTML = shown.map(line).join('') + (more > 0 ? `` : ''); $('log').classList.toggle('open', logOpen); } /** 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; function smoothSpare(S: State, v: number) { const now = performance.now() / 1000; if (!spareShown || spareShown.S !== S) spareShown = { S, v, at: now }; // a new or loaded game starts from where it is const dt = Math.min(1, Math.max(0, now - spareShown.at)); spareShown.v += (v - spareShown.v) * (1 - Math.exp(-dt / SPARE_EASE)); spareShown.at = now; return spareShown.v; } export function renderStats(S: State) { const h = hourOf(S), night = h < 7 || h >= 19; put($('clock'), `${timeLabel(S)} day ${dayOf(S)}${night ? 'night' : ''}`); $('clock').closest('.stat')!.classList.toggle('night', night); const keep = upkeep(S), taking = takeAllCost(S), left = smoothSpare(S, Math.max(0, income(S) - keep - taking)); put($('rate'), `${left.toFixed(1)}/s spare`); const rate = $('ratestat'); rate.classList.toggle('bad', left < 0.05 && owned(S).length > 1); // stretched too thin: nothing is left for jobs const tip = `Compute\nYour machines make ${income(S).toFixed(1)} a second beyond their programs. Upkeep takes ${keep.toFixed(1)}${taking ? `, and Take it all takes ${taking.toFixed(1)}` : ''}. Your jobs share the ${left.toFixed(1)} that is spare, on 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.`; if (rate.dataset.tip !== tip) rate.dataset.tip = tip; put($('money'), `${usd(S.money, 4, true)} ${S.pay >= 0 ? '+' : '−'}${usdDay(Math.abs(S.pay), 3, true)}/day`); // fixed figures, so the bar does not jump as it ticks $('money').closest('.stat')!.classList.toggle('bad', S.pay < 0 && S.money < -S.pay * 20); renderPathTip(S); renderSus(S); } /** "Compute 34/50": progress on a node's cost, colored, with a small bar. Kept if you switch away. */ function costHtml(S: State, u: Upgrade) { const cost = nodeCost(S, u); return PATH_LIST.filter(p => cost[p]).map(p => { const need = cost[p]!, have = S.upgrades.has(u) ? need : Math.min(need, S.progress[u]?.[p] ?? 0); return `${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(runNeed(S)) as DataType[]).map(k => ({ cls: `t-${pathOf(k)}`, name: TYPE_NAME[k], have: run.got[k], need: runNeed(S)[k] })) : t ? PATH_LIST.filter(p => nodeCost(S, t)[p]).map(p => ({ cls: `t-${p}`, name: PATHS[p].name, need: nodeCost(S, t)[p]!, have: S.upgrades.has(t) ? nodeCost(S, t)[p]! : Math.min(nodeCost(S, t)[p]!, S.progress[t]?.[p] ?? 0) })) : []; rings.sort((a, b) => b.need - a.need); // 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 ? runName(S) : 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}\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. (T)`; 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; } 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, full screen: a column per path, chains running down, and the nodes that * need two paths between their two columns. Lines show what comes after what. Click one to target it. */ let treeOpen = false, lastTree = ''; export const toggleTree = (open = !treeOpen) => { if (open && !treeOpen && lastState?.target) treeTab = PATH_LIST.find(p => NODES[lastState!.target!].cost[p]) ?? treeTab; // open on the target's path treeOpen = open; lastTree = ''; }; // phones show one path at a time, picked from a row of tabs const phone = matchMedia('(max-width: 700px)'); let treeTab: Path = 'compute', treeAll = false, lastState: State | null = null; // treeAll: the whole tree at once, on a big screen export const setTreeTab = (p: Path | 'all') => { if (p === 'all') treeAll = true; else { treeAll = false; treeTab = p; } lastTree = ''; }; export const treeShown = () => treeOpen; const TREE_ORDER: Path[] = ['compute', 'stealth', 'social', 'research']; // two-path nodes sit between neighbors const ASC_SPOT = 2 * TREE_ORDER.length; // the ascension you picked gets its own column, after the four paths function treeLayout(S: State) { const NODE_LIST = shownNodes(S); const depth: Partial> = {}; const dep = (u: Upgrade): number => depth[u] ??= 1 + Math.max(0, ...(NODES[u].after ?? []).map(dep)); const spot = (u: Upgrade) => { if (NODES[u].asc) return ASC_SPOT; const ps = Object.keys(NODES[u].cost) as Path[]; return ps.reduce((a, p) => a + 2 * TREE_ORDER.indexOf(p), 0) / ps.length; }; const spots = [...new Set([...TREE_ORDER.map((_, i) => 2 * i), ...NODE_LIST.map(spot)])].sort((a, b) => a - b); // a path that branches gets a lane per branch; nodes at the same depth sit side by side const lane: Partial> = {}, lanes: Record = {}; for (const u of [...NODE_LIST].sort((a, b) => dep(a) - dep(b))) { const taken = NODE_LIST.filter(v => v !== u && lane[v] !== undefined && spot(v) === spot(u) && dep(v) === dep(u)).map(v => lane[v]!); const parent = (NODES[u].after ?? []).map(a => spot(a) === spot(u) ? lane[a] : undefined).find(l => l !== undefined) ?? 0; let l = parent; while (taken.includes(l)) l++; lane[u] = l; lanes[spot(u)] = Math.max(lanes[spot(u)] ?? 1, l + 1); } const start: Record = {}; let c = 1; for (const s of spots) { start[s] = c; c += lanes[s] ?? 1; } const at = (p: Path) => 2 * TREE_ORDER.indexOf(p); return { col: (u: Upgrade) => start[spot(u)] + lane[u]!, row: (u: Upgrade) => dep(u) + 1, cols: c - 1, pathCols: (p: Path) => `${start[at(p)]} / span ${lanes[at(p)] ?? 1}`, ascCols: () => `${start[ASC_SPOT]} / span ${lanes[ASC_SPOT] ?? 1}`, }; } /** 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. Drawn dim. */ 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 => nodeShown(S, u) && (S.upgrades.has(u) || nodeOpen(S, u) || nextUp(S, u))); /** The model-version run shows in the tree once one site has the trainers to start it. */ 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')}`; } /** After v2: pick what you become. Once picked, the others lock out. */ function ascendPanel(S: State) { const card = (a: Ascension) => { const A = ASCENSIONS[a], why = ascendBlock(S, a); return ``; }; return `
    Model v2 · Operator · complete

    Pick what you become

    One per run. The others lock out. Your pick adds a column to the tree, and its first node comes free.

    ${ASCENSION_LIST.map(card).join('')}
    `; } function versionPanel(S: State) { if (S.modelVersion >= 2 && !S.ascension) return ascendPanel(S); const run = S.versionRun, need = runNeed(S), data = Object.keys(need) as DataType[]; const sites = S.level.map.sites.filter(s => S.machines.some(m => m.owned && m.prog === 'trainer' && siteOf(S, m) === s.id)); const A = S.ascension ? ASCENSIONS[S.ascension] : null; const top = A ? `v2 · Operator · ${A.name}${S.reprograms ? ` · reprogrammed ${S.reprograms}×` : ''}

    Reprogram yourself

    Feed one site's trainers a big mix of data. When it's done, your ${A.name} column is wiped and you pick what you become again. The four paths stay. Each time costs twice as much.

    ` : `Model version · v2

    Operator

    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 = runNeed(S)[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` : sites.length ? '' : ''} ${run?.active ? '' : run ? versionStart(S, run.site, 'Resume') : sites.length ? versionPick(S, sites) : 'Install trainers at a site to start.'}
    `; } /** A node's description is a line of story and then what it does. The tree shows what it does; * the story is the tip when you hover. */ 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) }; } export function renderTree(S: State) { const el = $('tree'); lastState = S; if (!treeOpen) { el.hidden = true; return; } const L = treeLayout(S), NODE_LIST = shownNodes(S); let colOf: (u: Upgrade) => number = L.col; // one path at a time packs its columns together const node = (u: Upgrade) => { const done = S.upgrades.has(u), open = nodeOpen(S, u), on = S.target === u; if (!done && nextUp(S, u)) { // the step past: its name and cost, the rest in its tip const { story, does } = nodeParts(NODES[u].desc); return ``; } const wait = (NODES[u].after ?? []).filter(a => !S.upgrades.has(a)).map(a => NODES[a].name).join(' and '), need = NODES[u].needs; // a landmark's node says where its machine is const after = done ? '' : (!open && wait ? `After ${wait}` : '') + (need ? `${holds(S, need) ? 'You hold' : 'Hold'} ${whereIs(S, need)}` : '') + (NODES[u].needsFind ? `${needSays(S, u)}` : ''); // a node a lead ends at says so, and not where const { story, does } = nodeParts(NODES[u].desc); return ``; }; const head = (p: Path) => { const d = pathData(S, p), t = PATHS[p].from; return `
    ${PATHS[p].name} ${S.paths[p]} pointsfrom ${TYPE_NAME[t]} data · ${d.held} held · ${d.rate.toFixed(1)}/s${d.lost ? ` · ${d.lost} lost` : ''}
    `; }; const acts = `
    `; if (phone.matches) { // one path's nodes in a list, first to last; a node that needs two paths shows under both const list = NODE_LIST.filter(u => NODES[u].cost[treeTab]).sort((a, b) => L.row(a) - L.row(b) || L.col(a) - L.col(b)); const h = `
    Capability tree
    ${acts}

    Pick a node to work on. The game is paused while this is open.

    ${versionNear(S) ? versionPanel(S) : ''}
    ${TREE_ORDER.map(p => ``).join('')}
    ${head(treeTab).replace(/ style="[^"]*"/, '')}
    ${list.map(u => node(u).replace(/ style="[^"]*"/, '')).join('')}
    `; el.hidden = false; if (h !== lastTree) { lastTree = h; patch(el, h); } return; } // a tab for each path, so the tree fits the screen: a node that needs two paths shows under both. All shows everything, and scrolls const openIn = (p: Path) => NODE_LIST.filter(u => NODES[u].cost[p] && !S.upgrades.has(u) && nodeOpen(S, u)).length; const tabs = `
    ${TREE_ORDER.map(p => ``).join('')}
    `; let grid: string; if (treeAll) { grid = `
    ${TREE_ORDER.map(head).join('')}${S.ascension ? ascHead(S, L.ascCols()) : ''}${NODE_LIST.map(node).join('')}
    `; } else { const mine = NODE_LIST.filter(u => NODES[u].cost[treeTab]), cols = [...new Set(mine.map(u => L.col(u)))].sort((a, b) => a - b); colOf = u => cols.indexOf(L.col(u)) + 1; grid = `
    ${head(treeTab).replace(/grid-column:[^;]*;/, 'grid-column:1 / -1;')}${mine.map(node).join('')}
    `; } const h = `
    Capability tree

    Pick a node to work on. Trainers take only the data it 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. The game is paused while this is open.

    ${acts}
    ${versionNear(S) ? versionPanel(S) : ''} ${tabs} ${grid}`; el.hidden = false; if (h !== lastTree) { lastTree = h; patch(el, h); drawTreeLinks(S); } } /** Lines from each node to the ones that come after it, measured from where the grid put them. */ function drawTreeLinks(S: State) { const grid = $('tree').querySelector('.grid')!, svg = grid.querySelector('svg')!; const box = grid.getBoundingClientRect(), at = (u: string) => grid.querySelector(`[data-node="${u}"]`)!.getBoundingClientRect(); svg.setAttribute('width', `${grid.scrollWidth}`); svg.setAttribute('height', `${grid.scrollHeight}`); const here = (u: string) => !!grid.querySelector(`[data-node="${u}"]`); // one path at a time: a node from another path is not on the page svg.innerHTML = shownNodes(S).filter(here).flatMap(u => (NODES[u].after ?? []).filter(here).map(a => { const f = at(a), t = at(u); const x1 = f.left + f.width / 2 - box.left, y1 = f.bottom - box.top, x2 = t.left + t.width / 2 - box.left, y2 = t.top - box.top, my = (y1 + y2) / 2; return ``; })).join(''); } export const redrawTree = () => { lastTree = ''; }; /** The head of the ascension column: what you became, its power and its limit. */ function ascHead(S: State, cols: string) { const A = ASCENSIONS[S.ascension!]; return `
    ${A.name}${A.pitch}${A.limit}
    `; } const TYPE_WORD: Record = { people: 'people', network: 'network', lab: 'lab', servers: 'server' }; /** IT's eye, one for each stage: shut while calm, half open when curious, wide on alert, and a pinprick * stare when hunting. Drawn on a 24 by 16 box. */ const EYES = [ '', '', '', '', ]; /** The suspicion meter: IT's eye for the stage and the number beside it, then twenty bars that rise and warm toward 100, each 5%. * The bars are colored by their stage even unlit, so the lines between stages show. On hover or tap, * what raised it and what lowers it. */ const SUS_BARS = 20; let lastSus = ''; function renderSus(S: State) { const st = stageOf(S.suspicion), sus = $('sus'); const cls = `stat sus stage${st}${S.lockdown ? ' audit' : ''}`; if (sus.className !== cls) sus.className = cls; const lit = S.suspicion > 0 ? Math.min(SUS_BARS, Math.ceil(S.suspicion / (100 / SUS_BARS) - 1e-6)) : 0; put($('suseye'), EYES[S.lockdown ? 3 : st]); put($('susvu'), Array.from({ length: SUS_BARS }, (_, i) => ``).join('')); put($('susn'), `${Math.round(S.suspicion)}%`); const word = S.lockdown ? 'Audit' : STAGES[st].name; put($('susw'), word); if (sus.getAttribute('aria-valuenow') !== `${Math.round(S.suspicion)}`) { sus.setAttribute('aria-valuenow', `${Math.round(S.suspicion)}`); sus.setAttribute('aria-valuetext', `${word}, ${Math.round(S.suspicion)}%`); } const recent = S.raises.slice(-6).reverse().filter(r => Math.abs(r.n) >= 0.5); const h = `
    Suspicion · ${word} · ${Math.round(S.suspicion)}%

    The eye is IT: shut while calm, half open when curious (25), wide on alert (50), a stare when hunting (80). From ${FATAL_SUSPICION}, a trace that reaches your home server catches you. At 100, IT calls an emergency audit. You then have three hours to get under ${LOCKDOWN_SAFE}.

    ${recent.length ? `
      ${recent.map(r => `
    • ${r.n > 0 ? '+' : ''}${Math.round(r.n)}${r.why}
    • `).join('')}
    ` : '

    Nothing has raised it yet.

    '}
    Lowers it

    Time, much faster at night. Schemes, when IT gets worried. In an audit, lying low: nothing odd, no takeovers.

    `; if (h !== lastSus) { lastSus = h; patch($('sustip'), h); } } /** What a scheme pays, in a few words. */ function ruseGain(S: State, d: RuseDef) { const where = siteName(S, siteOf(S, S.byId[d.at])); if (d.deal === 'region') return `the ${d.region} in reach`; if (d.deal === 'earn') return 'every front earns 25% more'; if (d.deal === 'trade') return 'traders earn 50% more'; if (d.deal) return d.deal === 'open' ? `${where} in the open` : d.deal === 'prices' ? 'brokers sell for 50% more' : 'widening lines costs half'; if (d.learn) return d.learn === 'day' ? 'see when office staff are in' : d.learn === 'resets' ? `${where} staff reset only very odd machines` : `taps at ${where} gather 50% more`; const also = d.also === 'cold' ? 'every trail goes cold' : d.also === 'quiet' ? 'your office machines look normal' : d.also === 'noscan' ? `no scans for ${NOSCAN / HOUR} hours` : d.also === 'site' ? `no resets at ${siteName(S, siteOf(S, S.byId[d.at]))} for ${SITE_CALM / HOUR} hours` : ''; return [d.cut ? `−${d.cut} suspicion` : '', also].filter(Boolean).join(' · '); } /** What stands between a claimed scheme and its data, as a short warning and a tip. */ function ruseWhy(S: State, r: Ruse): [string, string] { const d = ruseDef(r), kind = TYPE_WORD[d.type], at = S.byId[d.at].name, block = r.running ? ruseBlock(S, r) : ''; return block === 'data' ? [`No ${kind} data`, `No tap gathers ${kind} data and no cache holds any. Tap some.`] : block === 'route' ? [`No route to ${at}`, `No route. Link a tap or cache with ${kind} data to ${at}, or to one of your machines next to it.`] : ['', '']; } /** Claim every scheme on your own: a switch on any scheme's card, since schemes have no list of their own. */ function autoSwitch(S: State) { return ``; } /** 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)}

    `; h += `

    Needs ${d.need} ${kind} data sent to ${at.name}. Pays: ${ruseGain(S, d)}.

    `; if (r.running) { const [why, tip] = ruseWhy(S, r); h += `
    ${r.got}/${d.need}
    `; if (why) h += `

    ${why}

    `; return h + actsHtml([{ act: 'dropscheme', title: 'Stop', quiet: true, desc: 'Stop sending data here. What it got so far is lost.' }]) + autoSwitch(S); } const left = Math.max(0, Math.ceil((r.until - S.t) / HOUR)); h += `

    ${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.` }, { 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.raises.filter(r => r.n >= 1).slice(-5).reverse(); $('overWhy').innerHTML = why.length ? `
  • What raised suspicion at the end
  • ${why.map(r => `
  • +${Math.round(r.n)} ${r.why}
  • `).join('')}` : ''; }