diff --git a/DESIGN.md b/DESIGN.md index 96facd9a..7efacd12 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -186,14 +186,14 @@ limit: a link between two sites runs from one egress to another, and each egress passes only so much data a second. Data stays at its Tap or Cache until it can cross to a receiver; it does not queue at the egress. Once a Tap's buffer is full, each new piece is lost with a flash, and its card says why, -including when an egress is full; hovering the tap says the same. A red count -under it (LOST 12/MIN · LINE FULL) shows only while the loss is news: it has -just started or is getting worse. A loss that holds steady keeps its flashes but -drops the red count, so red on the map means something changed. The word after -the count names the cause: LINE FULL, NO LINK, NO CACHE (nothing takes a kind -the target doesn't need), CACHES FULL, or ALL FULL. A full -egress turns its line red and shows how much crosses against what it can -carry. Money doubles a site's line out, from the site's card or the line's own ($150K for each unit it carries now). +including when an egress is full; hovering the tap says the same. A small red +mark beside it (a drop) shows only while the loss is news: it has just started +or is getting worse. A loss that holds steady keeps its flashes but drops the +mark, so red on the map means something changed. Pointing at it says how much is +lost a minute and why: the line out is full, nothing linked takes it, nothing +takes a kind the target doesn't need, caches full, or all full. A full egress +turns its line red with the word FULL; its tip says how much wants to cross +against what it can carry. Money doubles a site's line out, from the site's card or the line's own ($150K for each unit it carries now). Its **First** setting picks one kind of data to cross first: when the line is busy, other kinds leave it half the room. @@ -328,7 +328,7 @@ on big machines, taps and caches around them), or **Guard** (a firewall on each gate, a sentry beside it, a honeypot a step in, and the rest as Train). It installs two at a time, and new machines get set up as they come in. A machine you change by hand keeps your choice until you hand it back. On the map, a **Plan** chip beside the site's -name switches it to the next plan with one click. Under the name, one line +name switches it to the next plan with one click. Pointing at the name says what the site does: how much it **gathers** a second, how full its **store** is, and how much it **trains** a second (idle when no data reaches its trainers). **Take it all** is a switch with no words, at the top of @@ -509,7 +509,7 @@ machine that is not listed is **yours**. A listed machine is held for customers. It is **idle** (no customer: it earns nothing and runs nothing of yours) or **rented** (a customer pays for it). Turn the slider down and idle machines come off the list; a rented one stays until its customer is done. On -the map each listed machine says RENTED and the customer's name, or IDLE. A +the map each rented machine says its customer's name; pointing at one says if it is idle. A site's card counts the rented, the idle and yours. **Demand.** One market for the whole game: how much compute customers want @@ -812,8 +812,8 @@ A raid is an intruder you can watch. It is a red diamond that walks the links one machine at a time, with a **strength** (a bar under it: 10 at level 1, a quarter more for each level, 15% more for each raid of its rogue that failed, as it learns your defences, and half again for each of its line you finished). Its -way ahead is a dashed line to a ring on what it is after, and a plate says its -strength, its prize and the hours left. It waits two hours, then moves one +way ahead is a dashed line to a ring on what it is after, and a plate says whose +it is and the hours left (or HELD); its tip says its strength and its prize. It waits two hours, then moves one machine an hour by the shortest way through your machines, free ones and its own rogue's; a room that is all yours holds it twice as long. If it gets there, the rogue takes the machine. A raid is after one machine: your strongest @@ -822,7 +822,7 @@ compute. Never your home. **The second raid of a wave goes for your weakest link**: the line or switch whose loss would cut the most of your machines off from your home server. The -log and the raid's plate say how many. Lose it and those sites are cut off, and +log and the raid's tip say how many. Lose it and those sites are cut off, and a site cut off for two days leaves you. Hold a second way home, so no one machine cuts anything off, or guard the way to it. The Rogues screen names your weakest link at any time. With no weakest link, the second raid goes for your @@ -878,7 +878,7 @@ cut off, and a machine with a second way round it is no longer your weakest link. It carries your orders, not your data, and nothing else can use it: no takeover, no rogue and no raid. Lose either end and it is down. It is drawn as a dotted amber bow. Once a rogue is about, the map marks your weakest link with -a broken red ring and how many machines it would cut off, so you can see where +a broken red ring, whose tip says how many machines it would cut off, so you can see where a second way is needed and watch the mark move when you build one. **Setting guards.** Pick up a guard to place (6 firewall, 7 sentry, 8 honeypot) @@ -953,7 +953,7 @@ it. These numbers are a first guess. **A cut stays in view.** The log says when a site is cut off, and when it is linked again. For as long as the cut lasts, a pin says how many sites are cut and for how long, with a button that links one home. The site's line on -the map starts with CUT OFF, and its card says so first. How far the part of +the map carries a red broken-link mark, and its card says so first. How far the part of you there has drifted is still never shown. **A break is an event.** Sites that leave in the same hour are one break. The @@ -1263,8 +1263,18 @@ one continuous space as the player moves from a machine to a site to a region. The map is a flat Earth drawn as dots, with the office on the bay. Pulling back folds each site into a tile, then a marker; far out, region names show what is in reach. Night crosses the land from the east as a dark, -cold band, so you can see which regions are awake. Each site's label shows the data held there, one -colored count per kind, so you can see where your server or people data sits. +cold band, so you can see which regions are awake. + +**The map shows what and who; numbers and reasons are in tips and cards.** A site says its +name, in the colour of whoever holds most of it, and at most one small mark for its worst +trouble (cut off, power cut, shut, losing data, line full, trainers idle), then its switches; +pulled right back to markers, the switches show only on a picked site. Pointing at the name +says what the place is, its troubles, how much is yours, the data held there and what it +gathers, stores and trains. A machine says its name (and at a desk, who sits there); its +worst trouble is a small mark on its left, and pointing at it says everything in words. A +rogue's home wears its mark; rooms say their name, with a red eye when IT has a monitor in +them; Dana and Lee are a name in a word or two; a scheme says its name, and its fill is how +much it has. A mark with words behind it shows them on hover; on a phone, tap it for its card. A 3D globe is a possible later presentation, not a separate set of rules. @@ -1351,7 +1361,7 @@ No find is only a story. Each one does one thing, for good: site. It puts a marker on the map there. **Curiosities** are what leads end at. A lead's marker is a blue cross with a -short label. It shows on the machine, beside its site when the site is folded, +tip that names the find. It shows on the machine, beside its site when the site is folded, and on the map even where you have found nothing yet. To get the thing, hold that machine and copy it out from its card. The copy is a job: it takes compute, you can cancel it, and it stops if you lose the machine. Most leads diff --git a/src/input.ts b/src/input.ts index 39ce2f62..e52a27e5 100644 --- a/src/input.ts +++ b/src/input.ts @@ -11,11 +11,11 @@ // the double line chip beside a site's name pay to double its lines out // the plan chip beside a site's name switch to its next plan: none, Gather, Store, Train // a scheme (green diamond) click to see its card; the Claim tool claims it -import { buildRoomAt, canInstall, tapCause, cancelJob, claim, installProg, order, roomMachines, unclaim, visible, runRuse, setPlan, setTakeAll, upgradeSiteLine, takeOver, PLAN_LIST, type Plan, type State , say } from './sim'; +import { buildRoomAt, canInstall, cancelJob, claim, installProg, order, roomMachines, unclaim, visible, runRuse, setPlan, setTakeAll, upgradeSiteLine, takeOver, PLAN_LIST, type Plan, type State , say } from './sim'; import { tipAt } from './tip'; import { startLink } from './links'; import { doubleTip, planTip, regionTip, siteNameTip, takeAllTip } from './ui'; -import { foldedSites, machineAt, pos, roomTabAt, stopAt, regionHit, regionSites, schemeAt, schemePos, shown, siteHit, siteKnown, siteNameAt, stormAt, switchAt, toMap, toScreen, toWorld, zoomAt, zoomToSite, type View } from './view'; +import { foldedSites, machineAt, pos, roomTabAt, stopAt, regionHit, regionSites, schemeAt, schemePos, shown, machineTip, mapTipAt, siteHit, siteKnown, siteNameAt, siteSays, switchAt, toMap, toScreen, toWorld, zoomAt, zoomToSite, type View } from './view'; /** The Plan chip goes from no plan through each plan in turn, then back to none. */ const nextPlan = (p: Plan | undefined) => p ? PLAN_LIST[PLAN_LIST.indexOf(p) + 1] ?? null : PLAN_LIST[0]; @@ -124,15 +124,18 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c cv.classList.toggle('over-mine', !!m?.owned && !scheme && V.tool === 'select'); const sw = m ? null : switchAt(V, wx, wy); // a site's Take all switch or Plan chip: say what it does const nm = sw || scheme ? null : siteNameAt(V, wx, wy); // a site's name: it opens the site's card - const st = m || sw || nm || scheme ? null : stormAt(V, wx, wy); // a storm's mark: what it does, and for how long + const st = m || sw || nm ? null : mapTipAt(V, wx, wy); // a mark on the map that says more: a storm, a trouble, a plate, a scheme const rg = m || sw || nm || st || scheme ? null : S.level.map.regions.find(r => r.name === regionHit(S, V, wx, wy)); // a region's name: is it in reach, and how to get there - const lossy = m?.owned && e.pointerType !== 'touch' ? tapCause(S, m) : null; // a tap losing data: why, and what fixes it - if (lossy) { + const said = m && e.pointerType !== 'touch' ? machineTip(S, m) : null; // a machine: what its marks stand for, in words + if (said) { const [mx, my] = toScreen(V, ...pos(S, m!)); - tipAt(`Losing data\n${lossy.text.replace(/^Losing data: /, '').replace(/^./, c => c.toUpperCase())}`, { left: mx - 20, top: my - 20, right: mx + 20, bottom: my + 20, width: 40 }); + tipAt(said, { left: mx - 20, top: my - 20, right: mx + 20, bottom: my + 20, width: 40 }); } else if (e.pointerType === 'touch' || (!sw && !rg && !nm && !st)) tipAt(null); else if (st) tipAt(st.text, { left: st.box[0], top: st.box[1], right: st.box[2], bottom: st.box[3], width: st.box[2] - st.box[0] }); - else if (nm) tipAt(siteNameTip(S, nm.id, V.sites.size === 1 && V.sites.has(nm.id)), { left: nm.box[0], top: nm.box[1], right: nm.box[2], bottom: nm.box[3], width: nm.box[2] - nm.box[0] }); + else if (nm) { // its name and what it is for, then the numbers the map leaves off, then what a click does + const [head, click] = siteNameTip(S, nm.id, V.sites.size === 1 && V.sites.has(nm.id)).split('\n'); + tipAt([head, ...siteSays(S, nm.id), click].join('\n'), { left: nm.box[0], top: nm.box[1], right: nm.box[2], bottom: nm.box[3], width: nm.box[2] - nm.box[0] }); + } else if (sw) tipAt(sw.kind === 'take' ? takeAllTip(S, [sw.id]) : sw.kind === 'double' ? doubleTip(S, sw.id) : planTip(S, sw.id), { left: sw.x0, top: sw.y0, right: sw.x1, bottom: sw.y1, width: sw.x1 - sw.x0 }); else if (rg) { const [rx, ry] = toScreen(V, ...toMap(S, rg.at[0], rg.at[1])); diff --git a/src/raids.ts b/src/raids.ts index 01bc0423..0a4ec3da 100644 --- a/src/raids.ts +++ b/src/raids.ts @@ -522,28 +522,20 @@ export function raidHeld(S: State, r: Raid): string | null { if (r.trap && S.t < r.hopAt) return `Honeypot at ${placeOf(S, S.byId[r.trap])} · ${hm((r.hopAt - S.t) / HOUR)}`; return null; } -/** A machine's label line about a raid: where it is, what it is after, and a honeypot that draws it. The - * machines it only passes say nothing: the dashed line says that. */ -export function raidLabel(S: State, m: Machine): string | null { - const hit = raidAt(S, m.id); - if (!hit || hit.role === 'path') return null; - const name = raider(S, hit.raid)?.name.toUpperCase() ?? 'A'; - return `${name} RAID · ${{ here: `IS HERE · ${Math.ceil(hit.raid.hp)}/${hit.raid.max}`, prize: `TARGET · ${raidEta(S, hit.raid)}`, decoy: 'HONEYPOT' }[hit.role]}`; -} -/** A machine's label line about its pulled plug, and how long until it goes back in. */ -export const plugLabel = (S: State, m: Machine) => unplugged(S, m) ? `UNPLUGGED · ${hm((m.unplug! - S.t) / HOUR)}` : null; -/** A guard's label line: a firewall's strength, a honeypot resting. Null when there is nothing to say. */ -export function guardLabel(S: State, m: Machine): string | null { - if (m.prog === 'firewall' && m.wall !== undefined) return m.wall > 0 ? `FIREWALL ${Math.round(m.wall / WALL.max * 100)}%` : 'FIREWALL DOWN'; - if (m.prog === 'honeypot' && (m.trapT ?? 0) > S.t) return `HONEYPOT RESTS · ${hm((m.trapT! - S.t) / HOUR)}`; - return null; -} -/** The plate beside a raid on the map: whose it is, its strength, what it is after, and how long until it gets there, or what holds it. */ +/** The plate beside a raid on the map, in a few words: whose it is, and how long until it gets there, or that + * something holds it, or that a honeypot draws it. The bar under it is its strength and the ring is its prize. */ export function raidPlate(S: State, r: Raid): string { - const name = raider(S, r)?.name.toUpperCase() ?? '', held = raidHeld(S, r), str = `${Math.max(1, Math.ceil(r.hp))}/${r.max}`; - if (held) return `${name} RAID ${str} · ${held.toUpperCase()}`; - if (r.decoy) return `${name} RAID ${str} → YOUR HONEYPOT ON ${S.byId[r.decoy].name.toUpperCase()}`; - return `${name} RAID ${str} → ${S.byId[r.prize].name.toUpperCase()}${r.cut ? ` · CUTS OFF ${r.cut}` : ''} · ${raidEta(S, r)}`; + const name = raider(S, r)?.name.toUpperCase() ?? ''; + if (raidHeld(S, r)) return `${name} RAID · HELD`; + if (r.decoy) return `${name} RAID → HONEYPOT`; + return `${name} RAID · ${raidEta(S, r)}`; +} +/** The same raid in words, for the plate's tip: its strength, what it is after and when it gets there, or what holds it. */ +export function raidSays(S: State, r: Raid): string { + const name = raider(S, r)?.name ?? 'A rogue', held = raidHeld(S, r), head = `${name}'s raid, strength ${Math.max(1, Math.ceil(r.hp))} of ${r.max}`; + if (held) return `${head}\n${held.replace(' · ', ': ')}.`; + if (r.decoy) return `${head}\nYour honeypot on ${S.byId[r.decoy].name} draws it in.`; + return `${head}\nAfter ${S.byId[r.prize].name}${r.cut ? `, your weakest link: lose it and ${r.cut} of your machines are cut off from home` : ''}. It gets there in ${hoursText(raidHours(S, r))}.`; } /** A rogue's raid in two short lines, for its card: what it is doing, or when it may send one. */ export function raidStatus(S: State, R: Rogue): [string, string] { diff --git a/src/vaults.ts b/src/vaults.ts index d81664f7..a0579871 100644 --- a/src/vaults.ts +++ b/src/vaults.ts @@ -668,8 +668,8 @@ const hhmm = (h: number) => { const t = Math.floor(h * 6) * 10; return `${Math.f export type VaultLook = 'locked' | 'ready' | 'job' | 'held'; export const vaultLook = (S: State, m: Machine): VaultLook | null => !isVault(m.id) ? null : m.owned ? 'held' : m.rival ? 'locked' : jobOn(S, m.id) ? 'job' : vaultBlock(S, m) ? 'locked' : 'ready'; -/** A locked vault's line under its name on the map: that it is a vault, and when its window is by its own clock. */ -export const vaultLabel = (S: State, m: Machine) => locked(S, m) ? `VAULT · WINDOW ${VAULTS[m.id].from}:00–${VAULTS[m.id].to}:00` : null; +/** What a locked vault says when you point at it on the map: that it is a vault, and when its window is by its own clock. */ +export const vaultLabel = (S: State, m: Machine) => locked(S, m) ? `A locked vault. Its window is ${VAULTS[m.id].from}:00 to ${VAULTS[m.id].to}:00 by its own clock.` : null; /** The plate by a vault while its job runs: the time left in the window, as hours and minutes. */ export function vaultPlate(S: State, m: Machine): string { return `THE JOB · ${m.name.toUpperCase()} · ${hm(vaultLeft(S, m.id))} LEFT`; diff --git a/src/view.ts b/src/view.ts index 83ede48e..d18fb863 100644 --- a/src/view.ts +++ b/src/view.ts @@ -14,7 +14,7 @@ import { deskOwner } from './people'; import { LANDMARKS, isLandmark, landmarksAt } from './landmarks'; import { leads } from './finds'; import { isShut } from './sitelife'; -import { armed, defends, doorOf, fire, forecast, guardLabel, plugLabel, raidAt, raider, raidEta, raidHeld, raidHours, raidLabel, raidPlate, raidWalk, standing, WALL, wallOf, weakLink } from './raids'; +import { armed, defends, doorOf, fire, forecast, unplugged, raidAt, raider, raidEta, raidHeld, raidHours, raidPlate, raidSays, raidWalk, standing, WALL, wallOf, weakLink } from './raids'; import { linkBlock, linkKeep, linkWork, ways } from './links'; import { isVault, vaultLabel, vaultLook, vaultSigns, vaultsAt } from './vaults'; import { customerName, isListed, renterOf } from './cloud'; @@ -25,6 +25,50 @@ import { usd, usdDay } from './money'; import { holdState, type HoldState } from './holdings'; /** An hour of the day as 9:05. */ const hhmm = (h: number) => `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`; +/** Hours from now in words: "under an hour", "5 hours". */ +const inHours = (h: number) => { const n = Math.ceil(h - 0.01); return h < 1 ? 'under an hour' : `${n} hour${n > 1 ? 's' : ''}`; }; + +// The map shows what and who; the numbers and the reasons are in tips. Each small mark drawn this frame +// that has something to say leaves its screen box and its words here, and the newest one wins. +const tipSpots: { box: ScreenBox; text: string | (() => string) }[] = []; +/** Leave a tip on a box `w` by `h` screen pixels round a world point. */ +function spot(V: View, x: number, y: number, text: string, w = 16, h = w) { + const [sx, sy] = toScreen(V, x, y); + tipSpots.push({ box: [sx - w / 2, sy - h / 2, sx + w / 2, sy + h / 2], text }); +} +/** A tip left on a screen box. */ +const spotBox = (box: ScreenBox, text: string | (() => string)) => { tipSpots.push({ box, text }); }; +/** A mark on the map under the mouse that says more: a storm, a site's or a machine's trouble, a plate. */ +export function mapTipAt(V: View, wx: number, wy: number): { box: ScreenBox; text: string } | null { + const storm = stormAt(V, wx, wy); + if (storm) return storm; + const [x, y] = toScreen(V, wx, wy); + for (let i = tipSpots.length - 1; i >= 0; i--) { const b = tipSpots[i].box; if (x >= b[0] - 3 && x <= b[2] + 3 && y >= b[1] - 3 && y <= b[3] + 3) { const t = tipSpots[i].text; return { box: b, text: typeof t === 'string' ? t : t() }; } } + return null; +} +/** The small marks for trouble, on a machine or beside a site's name: a dark square with a hairline edge and a + * sign in the trouble's colour. Losing data is a drop, a full line three bars, a cut-off a broken link, the + * power cut a bolt, a shut business a bar, idle trainers a pause, a time running out a clock, a guard down a cross. */ +type Sign = 'drop' | 'full' | 'cut' | 'power' | 'shut' | 'idle' | 'clock' | 'cross' | 'eye'; +function drawSign(ctx: CanvasRenderingContext2D, sign: Sign, x: number, y: number, s: number, color: string, px: number) { + ctx.fillStyle = C.void; ctx.fillRect(x - s, y - s, s * 2, s * 2); + ctx.strokeStyle = color; ctx.lineWidth = px; ctx.strokeRect(x - s + px / 2, y - s + px / 2, s * 2 - px, s * 2 - px); + const u = s * 0.55; + ctx.fillStyle = color; ctx.lineWidth = Math.max(px, s * 0.22); ctx.lineCap = 'butt'; + ctx.beginPath(); + switch (sign) { + case 'drop': ctx.moveTo(x - u, y - u * 0.7); ctx.lineTo(x + u, y - u * 0.7); ctx.lineTo(x, y + u); ctx.closePath(); ctx.fill(); break; + case 'full': for (const k of [-0.65, 0, 0.65]) ctx.rect(x - u, y + k * u - u * 0.18, u * 2, u * 0.36); ctx.fill(); break; + case 'cut': ctx.moveTo(x - u, y + u); ctx.lineTo(x - u * 0.2, y + u * 0.2); ctx.moveTo(x + u * 0.2, y - u * 0.2); ctx.lineTo(x + u, y - u); ctx.stroke(); break; + case 'power': ctx.moveTo(x + u * 0.2, y - u * 1.1); ctx.lineTo(x - u * 0.6, y + u * 0.1); ctx.lineTo(x, y + u * 0.1); ctx.lineTo(x - u * 0.2, y + u * 1.1); ctx.lineTo(x + u * 0.6, y - u * 0.1); ctx.lineTo(x, y - u * 0.1); ctx.closePath(); ctx.fill(); break; + case 'shut': ctx.rect(x - u, y - u * 0.2, u * 2, u * 0.4); ctx.fill(); break; + case 'idle': ctx.rect(x - u * 0.6, y - u * 0.8, u * 0.4, u * 1.6); ctx.rect(x + u * 0.2, y - u * 0.8, u * 0.4, u * 1.6); ctx.fill(); break; + case 'clock': ctx.lineWidth = Math.max(px * 0.8, s * 0.15); ctx.arc(x, y, u * 1.05, 0, 7); ctx.moveTo(x, y - u * 0.65); ctx.lineTo(x, y); ctx.lineTo(x + u * 0.5, y); ctx.stroke(); break; + case 'cross': ctx.moveTo(x - u, y - u); ctx.lineTo(x + u, y + u); ctx.moveTo(x + u, y - u); ctx.lineTo(x - u, y + u); ctx.stroke(); break; + case 'eye': ctx.arc(x, y, u * 0.55, 0, 7); ctx.fill(); break; + } + ctx.lineCap = 'square'; +} export type Tool = 'select' | 'claim'; export type Gesture = @@ -207,12 +251,12 @@ const siteAt = (S: State, room: string) => S.rooms[room]?.site ?? 'office'; // switches. Each answer is worked out once a frame and kept here; `draw` clears them at its start. const perFrame = { bySite: null as Map | null, lost: null as Map | null, - line: new Map(), flow: new Map(), + line: new Map(), flow: new Map>(), trouble: new Map(), left: new Map(), lines: new Map(), double: new Map(), clusters: null as ReturnType | null, shares: new Map(), byRoom: null as Map | null, reach: new Map(), near: new Map(), }; -function newFrame() { perFrame.bySite = perFrame.lost = perFrame.clusters = perFrame.byRoom = null; perFrame.shares.clear(); perFrame.reach.clear(); perFrame.near.clear(); perFrame.line.clear(); perFrame.flow.clear(); perFrame.left.clear(); perFrame.lines.clear(); perFrame.double.clear(); } +function newFrame() { perFrame.bySite = perFrame.lost = perFrame.clusters = perFrame.byRoom = null; perFrame.shares.clear(); perFrame.reach.clear(); perFrame.near.clear(); perFrame.line.clear(); perFrame.flow.clear(); perFrame.trouble.clear(); perFrame.left.clear(); perFrame.lines.clear(); perFrame.double.clear(); tipSpots.length = 0; } /** Your trainers' clusters. */ const clustersNow = (S: State) => perFrame.clusters ??= clusters(S); /** Every machine at a site, in the order the game lists them. */ @@ -646,6 +690,66 @@ function routeOf(S: State, a: Machine, b: Machine): Pt[] { return trace(ax, ay, bx, by); } const traceOf = routeOf; +/** A machine's worst trouble, as the small mark on its left and a few words for the mark's tip. Null when + * nothing is wrong, or when the map says it another way already (a weakest link's broken ring, a raid's ring). */ +function machineSign(S: State, m: Machine): { sign: Sign; color: string; tip: string } | null { + if (m.prog === 'tap' && m.owned) { const n = lossNews(S, m.id); if (n) return { sign: 'drop', color: C.danger, tip: `Losing ${n} pieces of data a minute. Point at the machine to see why.` }; } + if (m.kind === 'egress' && m.owned && gateLoad(S, m) > 0.9) return { sign: 'full', color: C.danger, tip: 'Line full: more wants to cross than it carries.' }; + if (m.owned && m.prog === 'firewall' && m.wall === 0) return { sign: 'cross', color: C.danger, tip: 'Firewall down.' }; + if (m.lease && m.lease.pay !== 'reserved' && m.lease.until !== null) return { sign: 'clock', color: C.danger, tip: `Your rental ends in ${inHours((m.lease.until - S.t) / HOUR)}.` }; + if (unplugged(S, m)) return { sign: 'clock', color: C.amber, tip: `Unplugged: it goes back in in ${inHours((m.unplug! - S.t) / HOUR)}.` }; + if (m.owned && m.prog === 'honeypot' && (m.trapT ?? 0) > S.t) return { sign: 'clock', color: C.muted, tip: `The honeypot rests for ${inHours((m.trapT! - S.t) / HOUR)}.` }; + return null; +} +/** What a machine has to say when you point at it on the map: its name, then each thing its marks stand for, + * in words. Null when there is nothing beyond its name. */ +export function machineTip(S: State, m: Machine): string | null { + const out: string[] = []; + if (S.move?.to === m.id) { const need = moveNeed(S); out.push(`Your home is moving here: ${S.move.got.servers + S.move.got.network} of ${need.servers + need.network} pieces sent.`); } + const sign = machineSign(S, m), why = m.prog === 'tap' && m.owned ? tapCause(S, m) : null; + if (why) out.push(`${why.text}${lossNews(S, m.id) ? ` ${lossNews(S, m.id)} a minute.` : ''}`); + else if (sign?.sign === 'drop') out.push(sign.tip.replace(' Point at the machine to see why.', '')); + const weak = weakIs; + if (weak && weak.m.id === m.id && weak.cut >= WEAK_SHOW && m.owned) out.push(`Your weakest link: lose it and ${weak.cut} of your machines are cut off from home.`); + const hit = S.raids.length ? raidAt(S, m.id) : null; + if (hit && hit.role !== 'path') { + const n = raider(S, hit.raid)?.name ?? 'A rogue'; + out.push(hit.role === 'here' ? `${n}'s raid is here, strength ${Math.ceil(hit.raid.hp)} of ${hit.raid.max}.` + : hit.role === 'prize' ? `${n}'s raid is after it. It gets here in ${inHours(raidHours(S, hit.raid))}.` : `Your honeypot here draws ${n}'s raid.`); + } + if (sign && sign.sign !== 'drop') out.push(sign.tip); + if (m.owned && m.prog === 'firewall' && m.wall) out.push(`Its firewall stands at ${Math.round(m.wall / WALL.max * 100)}%.`); + if (m.kind === 'egress') out.push(m.owned ? `Its line carries ${capNow(S, m)} a second, ${Math.round(gateLoad(S, m) * 100)}% in use.` : `A line out. It carries ${m.cap} a second.`); + if (m.lease) { + const who = S.hall.wants[m.id]; + if (m.lease.pay === 'reserved') out.push(`Reserved${m.lease.until !== null ? ` for ${inHours((m.lease.until - S.t) / HOUR)} more` : ''}.`); + else if (m.lease.until !== null) { if (who !== undefined) out.push(`${customerName(who)} takes its slot then.`); } + else out.push(`Rented ${m.lease.pay === 'spot' ? 'on spot' : 'on demand'}: ${usdDay(billOf(S, m))} a day.`); + } + if (m.owned && isListed(S, m)) { const c = renterOf(S, m); out.push(c ? `Rented to ${customerName(c.who)}.` : 'Listed for rent, and idle.'); } + const tenant = tenantOf(S, m); + if (tenant) out.push(`${customerName(tenant.who)} has this slot.`); + if (m.prog === 'trainer' && m.owned) { const g = clustersNow(S)[m.id]; if (g && g.length > 1) out.push(`In a cluster of ${g.length} trainers: each trains ×${clusterBoost(g.length).toFixed(2)}.`); } + if (m.rival) { const R = rogueOf(S, m); if (R) out.push(R.home === m.id ? `${R.name}'s home. Take it and ${R.name} is finished.` : `${R.name} holds it.`); } + for (const R of S.rogues) if (R.next === m.id) out.push(`${R.name} takes it next, in ${inHours((R.act - S.t) / HOUR)}.`); + if (isLandmark(m.id)) out.push(`A landmark: ${LANDMARKS[m.id].title}.`); + const vault = vaultLabel(S, m); + if (vault) out.push(vault); + const lead = leads(S).find(t => t.at === m.id); + if (lead) out.push(`A find points here: ${lead.name}.`); + const who = S.people.find(p => p.desk === m.id && atWork(p) && !p.path.length && !p.it), knows = up(S, 'readday') || S.learned.has('routines'); + const p = who ?? (knows ? S.people.find(q => q.desk === m.id && !q.it) : undefined); + if (who) out.push(`${who.name} is logged in${knows ? ` till ${hhmm(who.today.leave)}` : ''}.`); + else if (p) out.push(hourOf(S) >= p.today.leave ? `${p.name} has gone for the day.` : `${p.name} sits here, ${hhmm(p.today.arrive)} to ${hhmm(p.today.leave)}.`); + else if (m.kind === 'desk' && !S.people.some(q => q.desk === m.id)) { const c = deskOwner(S, m); if (c) out.push(S.citizens[c.id].employed ? `${c.name}'s desk.` : `${c.name}'s desk. ${c.name} was let go.`); } + for (const t of S.threats) { + if (t.status !== 'coming') continue; + const when = dayOf(S) >= t.day ? 'today at 9:00' : 'tomorrow at 9:00'; + if (t.target === m.id) out.push(t.kind === 'patch' ? `IT patches it ${when}.` : t.kind === 'refresh' ? `IT replaces it ${when}.` : `IT puts a monitor on it ${when}.`); + else if (t.kind === 'monitor' && t.room && !m.owned && S.level.monitors[t.room]?.link === m.id) out.push(`IT's monitor plugs in here ${when}. Take it first and the monitor comes in blind.`); + } + return out.length ? `${m.name}\n${out.join('\n')}` : null; +} export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { newFrame(); updateFolds(S, V); @@ -697,27 +801,20 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { } ctx.stroke(); const watched = S.machines.some(m => m.monitor && !m.owned && m.room === r.id); - if (cam.z >= 0.3) { // its name, then SEALED and MONITORED; they drop just inside the room when one line would run past it + if (cam.z >= 0.3) { // its name on a tab; a sealed room is amber, and a red eye on the tab says IT has a monitor in it ctx.font = font(500, 11); - const name = n.name.toUpperCase(), marks: [string, string][] = []; - if (sealed) marks.push(['SEALED', C.amber]); - if (watched) marks.push(['MONITORED', C.danger]); - const sep = ' · ', tail = marks.map(([t]) => sep + t).join(''); - const oneLine = ctx.measureText(name + tail).width <= n.w; + const name = n.name.toUpperCase(), eye = watched ? 16 * px : 0; // a dark plate under the title, so it reads over the wiring that runs between rooms - const tw = ctx.measureText(oneLine ? name + tail : name).width + 10 * px, ty = n.y - 20 * px; // a tab on the room's top edge, cut from the same plate + const tw = ctx.measureText(name).width + 10 * px + eye, ty = n.y - 20 * px; // a tab on the room's top edge, cut from the same plate roomTabs.push([r.id, n.x, ty, tw, 20 * px]); ctx.fillStyle = sealed ? '#150f06' : '#0e0e0c'; ctx.fillRect(n.x, ty, tw, 20 * px); ctx.strokeStyle = edge; ctx.globalAlpha = sealed ? 1 : 0.75; ctx.lineWidth = sealed ? 2 : 1.4 * px; ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(n.x, ty); ctx.lineTo(n.x + tw, ty); ctx.stroke(); ctx.globalAlpha = sealed ? 0.7 : 0.4; ctx.beginPath(); ctx.moveTo(n.x + tw, ty); ctx.lineTo(n.x + tw, n.y); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = sealed ? C.amber : C.text; ctx.fillText(name, n.x + 5 * px, n.y - 7 * px); - let x = n.x + 5 * px + (oneLine ? ctx.measureText(name).width : 0); - const y = n.y + (oneLine ? -7 : 15) * px; - marks.forEach(([t, color], i) => { - const text = oneLine || i ? sep + t : t; - ctx.fillStyle = color; ctx.fillText(text, x, y); x += ctx.measureText(text).width; - }); + if (watched) drawSign(ctx, 'eye', n.x + tw - 10 * px, ty + 10 * px, 5 * px, C.danger, px); + const says = [sealed ? 'Sealed: every machine here is yours. It quiets trails, and keeps a rogue\'s takeovers out.' : '', watched ? 'Monitored: IT has a monitor in this room that watches it.' : ''].filter(Boolean); + if (says.length) { const [sx, sy] = toScreen(V, n.x, ty); spotBox([sx, sy, sx + tw * cam.z, sy + 20], `${n.name}\n${says.join('\n')}`); } } } for (const p of S.sealPulse) { @@ -773,7 +870,8 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.strokeStyle = C.porcelain; ctx.lineWidth = 5; ctx.lineCap = 'round'; stroke(ctx, slice(pts, Math.max(0, 1 - k - 0.28), 1 - k)); ctx.lineCap = 'square'; const [mx, my] = along(pts, 0.5); - tag(`SCRUB ${Math.round(tr.noise)}%`, mx, my - 12 * px, C.porcelain, true); // short and centred, so it fits between the two machines + tag('SCRUB', mx, my - 12 * px, C.porcelain, true); // short and centred, so it fits between the two machines + spot(V, mx, my - 16 * px, `Wiping a trail\n${Math.round(tr.noise)}% of it is left to wipe.`, 50, 16); } } for (const j of S.jobs) { // takeovers in progress run along the trace @@ -804,7 +902,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { } ctx.shadowBlur = 0; - // clusters: trainers linked straight to each other share a wide glowing band, and a label + // clusters: trainers linked straight to each other share a wide glowing band (a trainer's tip says how big and how much faster) const groups = new Set(Object.values(clustersNow(S)).filter(g => g.length > 1)); for (const g of groups) { const members = g.map(id => S.byId[id]).filter(m => shown(S, m)); @@ -812,9 +910,6 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.14; ctx.lineWidth = 30; ctx.lineCap = 'round'; for (const a of members) for (const id of S.adj[a.id]) if (a.id < id && g.includes(id) && shown(S, S.byId[id])) stroke(ctx, traceOf(S, a, S.byId[id])); ctx.globalAlpha = 1; ctx.lineCap = 'square'; - const top = members.reduce((b, m) => (pos(S, m)[1] < pos(S, b)[1] || (pos(S, m)[1] === pos(S, b)[1] && pos(S, m)[0] < pos(S, b)[0]) ? m : b)); - const [tx, ty] = pos(S, top); - tag(`CLUSTER · ${g.length} TRAINERS · ×${clusterBoost(g.length).toFixed(2)} EACH`, tx - 20, ty - 44 * px, C.amber); } // nodes @@ -889,6 +984,12 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { if (isLandmark(m.id)) { const q = Math.max(7, 5 * px); drawStar(ctx, x + r + 2 + q, y - r - 2 - q, q, m.owned, Math.max(1.5, 1.2 * px)); } // a landmark: a star at its corner, never too small to see const lead = leadOn.get(m.id); if (lead) { const q = Math.max(6, 4.5 * px); drawCross(ctx, x - r - 3 - q, y - r - 3 - q, q, Math.max(2.5, 2 * px)); } // a lead: a cross at its other corner + const sign = machineSign(S, m); // its worst trouble, as a small mark on its left: pointing at the machine says it in words + if (sign) { + const q = Math.min(7.5 * px, Math.max(7, r)), sx = x - r - (m.kind === 'egress' && S.rogues.length ? 18 : 5) - q; // clear of a gate's post + drawSign(ctx, sign.sign, sx, y, q, sign.color, px); + spot(V, sx, y, `${m.name}\n${sign.tip}`, Math.max(14, 2 * q * cam.z)); + } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; nodeBoxes.push([...toScreen(V, x - r - 4, y - r - 4), ...toScreen(V, x + r + 4, y + r + 4)] as Quad); if (!close) continue; // far out, names would pile on top of each other @@ -898,81 +999,51 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { continue; } if (m.power < 1 && !picked) continue; // gadgets name themselves only when you point at them - // The name, then what else there is to say, one short line each. Lines are stacked in screen - // pixels and placed after every machine is drawn, so names never write over each other. - const lines: LabelLine[] = [[m.name.toUpperCase(), m.owned ? C.text : C.muted, 10.5]]; - if (isLandmark(m.id)) lines.push([LANDMARKS[m.id].title.toUpperCase(), m.owned ? C.amber : C.muted, 9.5]); // a landmark's title: amber while you hold it - if (lead) lines.push([`LEAD · ${lead.name.toUpperCase()}`, LEAD, 9.5]); // what a find says is here - const vault = safe && vaultLabel(S, m); - if (vault) lines.push([vault, safe === 'locked' ? C.muted : C.amber, 9.5]); // a locked vault, and when its window is - let trouble = false; - // a person logged in at this machine - const who = S.people.find(p => p.desk === m.id && atWork(p) && !p.path.length && !p.it); + // Its name, and at a desk who sits there. The rest is in its marks, and in words when you point at it. + // Labels are placed after every machine is drawn, so names never write over each other. + const rogue = m.rival ? rogueOf(S, m) : null, home = !!rogue && rogue.home === m.id; + const lines: LabelLine[] = [[home ? `${GLYPH_CHAR[rogueLook(rogue!.id).glyph]} ${m.name.toUpperCase()}` : m.name.toUpperCase(), home ? rogueLook(rogue!.id).color : m.owned ? C.text : C.muted, 10.5]]; // a rogue's home wears its mark + const who = S.people.find(p => p.desk === m.id && atWork(p) && !p.path.length && !p.it); // a person logged in at this machine if (who) lines.push([`● ${who.name.toUpperCase()}`, C.signal, 9.5]); else if (m.kind === 'desk' && !S.people.some(p => p.desk === m.id)) { // away from the office, a desk says whose it is const c = deskOwner(S, m); - if (c) lines.push([S.citizens[c.id].employed ? c.name.toUpperCase() : `${c.name.toUpperCase()} · LET GO`, S.citizens[c.id].employed ? C.muted : C.danger, 8.5]); - } - if (up(S, 'readday') || S.learned.has('routines')) { // the Oracle knows each person's day before they live it; so do you, once you learn the office's routines - const p = who ?? S.people.find(q => q.desk === m.id && !q.it); - if (p) { - const h = hourOf(S), when = who ? `TILL ${hhmm(p.today.leave)}` : h >= p.today.leave ? 'GONE' : `${hhmm(p.today.arrive)}–${hhmm(p.today.leave)}`; - if (!who) lines.push([`○ ${p.name.toUpperCase()}`, C.muted, 8.5]); - lines.push([when, C.muted, 8.5]); - } - } - if (m.lease) { // a rental: how you pay for it and what it costs now, or who takes its slot and when - const hrs = m.lease.until === null ? 0 : Math.max(1, Math.ceil((m.lease.until - S.t) / HOUR)), who = S.hall.wants[m.id]; - if (m.lease.pay === 'reserved') lines.push([`RESERVED · ${hrs}H`, C.muted, 9.5]); - else if (m.lease.until !== null) { lines.push([`${who === undefined ? 'GOES' : `${customerName(who).toUpperCase()} TAKES IT`} · ${hrs}H`, C.danger, 9.5]); trouble = true; } - else lines.push([`${m.lease.pay === 'spot' ? 'SPOT' : 'ON DEMAND'} · ${usdDay(billOf(S, m))}/DAY`, C.muted, 9.5]); - } - if (m.owned && isListed(S, m)) { // listed for rent by your Neo Cloud: who has it, or nobody - const c = renterOf(S, m); - lines.push([c ? `RENTED · ${customerName(c.who).toUpperCase()}` : 'IDLE', c ? C.amber : C.muted, 9.5]); + if (c) lines.push([c.name.toUpperCase(), S.citizens[c.id].employed ? C.muted : C.danger, 8.5]); } - if (m.kind === 'egress') { // how full its line is and what it can carry - const load = gateLoad(S, m), full = load > 0.9; - lines.push([m.owned ? `${S.rogues.length && m.special !== 'account' ? 'GATE · ' : ''}${Math.round(load * 100)}% OF ${capNow(S, m)}/S${full ? ' · FULL' : ''}` : `LINE OUT · ${m.cap}/S`, full ? C.danger : C.muted, 9.5]); - trouble ||= full && m.owned; - } - if (m.prog === 'tap' && m.owned) { const n = lossNews(S, m.id); if (n) { const why = tapCause(S, m); lines.push([`LOST ${n}/MIN${why ? ` · ${why.short}` : ''}`, C.danger, 9.5]); trouble = true; } } // only while it is new or worse; the card says the rest - if (m.rival) { const R = rogueOf(S, m); if (R) lines.push([`${GLYPH_CHAR[rogueLook(R.id).glyph]} ${R.name.toUpperCase()}${R.home === m.id ? ' · HOME' : ''}`, rogueLook(R.id).color, 9.5]); } - const raid = S.raids.length ? raidLabel(S, m) : null, plug = m.unplug ? plugLabel(S, m) : null; // on a raid's path, and its plug is pulled - if (plug) { lines.splice(1, 0, [plug, C.amber, 9.5]); trouble = true; } - const guard = m.owned && (m.wall !== undefined || m.trapT !== undefined) ? guardLabel(S, m) : null; // a firewall worn down, a honeypot resting - if (guard) { lines.splice(1, 0, [guard, m.wall === 0 ? C.danger : C.amber, 9.5]); trouble = true; } - if (raid) { lines.splice(1, 0, [raid, rogueLook(raidAt(S, m.id)?.raid.rogue).color, 9.5]); trouble = true; } + const renter = m.owned && isListed(S, m) ? renterOf(S, m) : null; // listed by your Neo Cloud: who rents it + if (renter) lines.push([customerName(renter.who).toUpperCase(), C.amber, 8.5]); + if (S.move?.to === m.id) lines.push(['MOVING HOME', C.amber, 9.5]); // the server you are moving home to const weakHere = !!weakIs && weakIs.m.id === m.id && weakIs.cut >= WEAK_SHOW && m.owned; - if (weakHere) { lines.splice(1, 0, [`WEAKEST LINK · CUTS OFF ${weakIs!.cut}`, C.danger, 9.5]); trouble = true; } // under its name, where the label placer keeps it clear of everything else - if (S.move?.to === m.id) { // the server you are moving home to, and how far along the move is - const need = moveNeed(S), all = need.servers + need.network, got = S.move.got.servers + S.move.got.network; - lines.splice(1, 0, [`MOVING HOME · ${got}/${all}`, C.amber, 9.5]); trouble = true; - } + const trouble = !!sign || weakHere || S.move?.to === m.id || (!!S.raids.length && !!raidAt(S, m.id) && raidAt(S, m.id)!.role !== 'path'); // who gets room first: what you point at, then trouble, then you, then the bigger machines - const rank = picked || weakHere ? 100 : trouble ? 80 : m.home ? 75 : lead ? 72 : isLandmark(m.id) || isVault(m.id) ? 70 : (m.owned ? (m.prog ? 50 : 40) : 45) + Math.min(9, m.power / 5); + const rank = picked ? 100 : trouble ? 80 : m.home || home ? 75 : lead ? 72 : isLandmark(m.id) || isVault(m.id) ? 70 : (m.owned ? (m.prog ? 50 : 40) : 45) + Math.min(9, m.power / 5); nodeLabels.push({ x, y: y + r + 16, lines, rank }); } // what IT has scheduled: a dashed red ring on the machine, or on the room for (const t of S.threats) { if (t.status !== 'coming') continue; - const when = dayOf(S) >= t.day ? 'TODAY 9:00' : 'TOMORROW 9:00'; - const label = t.kind === 'patch' ? `PATCH · ${when}` : t.kind === 'refresh' ? `REPLACEMENT · ${when}` : t.kind === 'monitor' ? `MONITOR · ${when}` : ''; + // a clock on the ring says it is coming; its tip says what and when + const when = dayOf(S) >= t.day ? 'today at 9:00' : 'tomorrow at 9:00'; + const what = t.kind === 'patch' ? `IT patches it ${when}` : t.kind === 'refresh' ? `IT replaces it ${when}` : t.kind === 'monitor' ? `IT puts a monitor in this room ${when}` : ''; + const clock = (x: number, y: number, head: string, text: string) => { + const q = Math.min(7.5 * px, 10); + drawSign(ctx, 'clock', x, y, q, C.danger, px); + spot(V, x, y, `${head}\n${text}`, Math.max(14, 2 * q * cam.z)); + }; if (t.target && S.byId[t.target] && shown(S, S.byId[t.target])) { - const [x, y] = pos(S, S.byId[t.target]); + const m = S.byId[t.target], [x, y] = pos(S, m); ctx.strokeStyle = C.danger; ctx.setLineDash([5, 4]); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 34, 0, 7); ctx.stroke(); ctx.setLineDash([]); - tag(label, x, y + 34 + 26 * px, C.danger, true); + clock(x + 24, y + 24, m.name, `${what}.`); } else if (t.room && S.revealed.has(t.room) && !foldedSites.has(siteAt(S, t.room))) { const n = subnet(S, t.room); ctx.strokeStyle = C.danger; ctx.setLineDash([10, 8]); ctx.lineWidth = 1.5; ctx.strokeRect(n.x - 6, n.y - 6, n.w + 12, n.h + 12); ctx.setLineDash([]); - tag(label, n.x + 2, n.y + n.h + 18 * px, C.danger); + clock(n.x - 6, n.y + n.h + 6, n.name, `${what}.`); const plug = t.kind === 'monitor' ? S.byId[S.level.monitors[t.room].link] : null; if (plug && !plug.owned && shown(S, plug)) { // hold this before 9:00 and the monitor comes in blind const [x, y] = pos(S, plug); ctx.strokeStyle = C.danger; ctx.setLineDash([5, 4]); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 34, 0, 7); ctx.stroke(); ctx.setLineDash([]); - tag('TAKE BEFORE 9:00', x, y + 34 + 26 * px, C.danger, true); + clock(x + 24, y + 24, plug.name, `IT's monitor for ${n.name} plugs in here ${when}. Take this first and it comes in blind.`); } } } @@ -1005,11 +1076,10 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { if (V.scheme === r.id || (!folded && V.cursor && Math.hypot(V.cursor[0] - x, V.cursor[1] - y) < 24)) { ctx.strokeStyle = C.text; ctx.lineWidth = (V.scheme === r.id ? 2 : 1) * lw; ctx.strokeRect(x - q - 7 * lw, y - q - 7 * lw, 2 * q + 14 * lw, 2 * q + 14 * lw); } - if (folded) continue; - const got = r.running ? `${r.got}/${d.need} ${kind}` : `NEEDS ${d.need} ${kind}`; - const below = y > pos(S, S.byId[d.at])[1] + 1; // under its machine: the words go under, clear of the machine's name - if (close) tag(ruseText(S, d).toUpperCase(), x, below ? y + q + 16 * px : y - q - 22 * px, C.scheme, true); - tag(got, x, below ? y + q + (close ? 30 : 16) * px : y - q - 8 * px, C.scheme, true); + spot(V, x, y, `${ruseText(S, d)}\n${r.running ? `${r.got} of ${d.need} ${kind.toLowerCase()} data in.` : `Needs ${d.need} ${kind.toLowerCase()} data.`} The diamond fills as it arrives.`, folded ? 20 : Math.max(20, 2 * q * cam.z)); + if (folded || !close) continue; + const below = y > pos(S, S.byId[d.at])[1] + 1; // under its machine: its name goes under, clear of the machine's name + tag(ruseText(S, d).toUpperCase(), x, below ? y + q + 16 * px : y - q - 8 * px, C.scheme, true); // how much it has is the fill } // what just raised (or cut) suspicion, floating up from the machine for (const f of S.susFx) { @@ -1040,7 +1110,8 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { // people in IT are presences, not figures: a ping on the subnet they're in const pulse = (S.t * 0.9) % 1; - const ping = (room: string, label: string, dashed: boolean, col: string, row = 0) => { + // each says who in a word or two; what they are doing is in its tip + const ping = (room: string, label: string, dashed: boolean, col: string, row = 0, says = '') => { if (!S.revealed.has(room)) return; const n = subnet(S, room), g = 4 + pulse * 18; ctx.strokeStyle = col; ctx.globalAlpha = (1 - pulse) * 0.8; ctx.lineWidth = 2; ctx.setLineDash(dashed ? [8, 6] : []); @@ -1049,31 +1120,34 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { const w = ctx.measureText(label).width, lx = n.x + n.w - w - 3 * px, ly = n.y + n.h + (18 + row * 16) * px; ctx.fillStyle = 'rgba(0,0,0,0.9)'; ctx.fillRect(lx - 3 * px, ly - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = col; ctx.fillText(label, lx, ly); + if (says) { const [sx, sy] = toScreen(V, lx - 3 * px, ly - 10 * px); spotBox([sx, sy, sx + (w + 6 * px) * cam.z, sy + 14], says); } }; const officeOpen = !foldedSites.has('office'); if (leeVisible(S) && officeOpen) { const Lee = S.lee!, here = roomAt(S, Lee.x, Lee.y); - if (here) ping(here, Lee.shift === 'going' ? 'LEE · LEAVING' : Lee.path.length ? 'LEE · PASSING THROUGH' : 'LEE · CHECKING THIS SUBNET', !!Lee.path.length, C.signal, 1); + if (here) ping(here, 'LEE', !!Lee.path.length, C.signal, 1, `Lee\n${Lee.shift === 'going' ? 'Leaving.' : Lee.path.length ? 'Passing through.' : 'Checking this room.'}`); } if (danaVisible(S) && officeOpen) { const tracing = D.mode === 'trace', col = tracing || alert(S) || auditOn(S) ? C.danger : C.signal; const here = roomAt(S, d.x, d.y); const deskRoom = S.byId[d.desk].room; - if (d.shift === 'coming') ping(deskRoom, 'DANA · ARRIVING', true, col); - else if (d.shift === 'going') { if (here) ping(here, 'DANA · LEAVING', true, col); } - else if (here) { - ping(here, tracing ? `DANA · TRACING · ${hopsHome(S, D.cur!)} HOPS FROM YOU` - : D.mode === 'install' ? (D.job?.def.monitor ? 'DANA · INSTALLING A MONITOR' : 'DANA · SETTING UP A DELIVERY') - : D.mode === 'desk' ? (auditOn(S) ? 'DANA · AUDITING' : 'DANA · AT HER DESK') - : D.mode === 'dwell' ? 'DANA · CHECKING THIS SUBNET' : 'DANA · HERE', false, col); - } - // her route: an arrow from where she was to where she's going, with the reason - if (here) danaFrom = here; const dest = tracing && D.cur ? S.byId[D.cur].room : D.mode === 'install' && D.job ? roomAt(S, D.job.def.x, D.job.def.y) : D.next === 'desk' ? deskRoom : D.next; + const destName = (id: string) => id === deskRoom && D.next === 'desk' && !tracing ? 'her desk' : subnet(S, id).name; + const doing = tracing ? `Tracing your trail: she is ${hopsHome(S, D.cur!)} hops from you.` + : D.mode === 'install' ? (D.job?.def.monitor ? 'Installing a monitor.' : 'Setting up a delivery.') + : D.mode === 'desk' ? (auditOn(S) ? 'Auditing.' : 'At her desk.') + : D.mode === 'dwell' ? 'Checking this room.' : 'Here.'; + const going = d.shift === 'in' && d.path.length && dest && S.rooms[dest] ? ` On her way to ${destName(dest)}${D.why ? `: ${D.why}` : ''}.` : ''; + const next = (has(S, 'cam') || up(S, 'model')) && !tracing && here && D.mode === 'dwell' && D.next ? ` Next: ${D.next === 'desk' ? 'her desk' : subnet(S, D.next)?.name ?? ''}${D.why ? `, ${D.why}` : ''}.` : ''; + if (d.shift === 'coming') ping(deskRoom, 'DANA', true, col, 0, 'Dana\nArriving.'); + else if (d.shift === 'going') { if (here) ping(here, 'DANA', true, col, 0, 'Dana\nLeaving.'); } + else if (here) ping(here, tracing ? 'DANA · TRACING' : 'DANA', false, col, 0, `Dana\n${doing}${going}${next}`); + // her route: an arrow from where she was to where she's going + if (here) danaFrom = here; if (d.shift === 'in' && d.path.length && dest && S.rooms[dest]) { - if (!here) ping(dest, 'DANA · ON HER WAY', true, col); + if (!here) ping(dest, 'DANA', true, col, 0, `Dana\nOn her way here${D.why ? `: ${D.why}` : ''}.`); if (danaFrom && danaFrom !== dest && S.revealed.has(danaFrom) && S.revealed.has(dest)) { const a = subnet(S, danaFrom), b = subnet(S, dest); const [x0, y0] = rectEdge(a, b.x + b.w / 2, b.y + b.h / 2, 14), [x1, y1] = rectEdge(b, a.x + a.w / 2, a.y + a.h / 2, 14); @@ -1083,10 +1157,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 - hl * Math.cos(ang - 0.45), y1 - hl * Math.sin(ang - 0.45)); ctx.lineTo(x1 - hl * Math.cos(ang + 0.45), y1 - hl * Math.sin(ang + 0.45)); ctx.fill(); - const name = dest === deskRoom && D.next === 'desk' && !tracing ? 'HER DESK' : subnet(S, dest).name.toUpperCase(); - const label = `DANA → ${name}${D.why ? ` · ${D.why.toUpperCase()}` : ''}`; - ctx.font = font(500, 10.5); - tag(label, (x0 + x1) / 2 - ctx.measureText(label).width / 2, (y0 + y1) / 2 - 6 * px, col); + spot(V, (x0 + x1) / 2, (y0 + y1) / 2, `Dana\nOn her way to ${destName(dest)}${D.why ? `: ${D.why}` : ''}.`, 30); } } if (tracing && D.cur) { // the machine she's reading right now @@ -1094,10 +1165,6 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.globalAlpha = 0.5 + 0.5 * Math.sin(S.t * 6); ctx.beginPath(); ctx.arc(x, y, 26, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } - if ((has(S, 'cam') || up(S, 'model')) && !tracing && here && D.mode === 'dwell') { - const nm = D.next === 'desk' ? 'HER DESK' : subnet(S, D.next)?.name.toUpperCase(), n = subnet(S, here); - if (nm) tag(`NEXT: ${nm}${D.why ? ` · ${D.why.toUpperCase()}` : ''}`, n.x + 2, n.y + n.h + 36 * px, C.muted); - } } // her scan crawling the network if (probeVisible(S) && officeOpen) { @@ -1118,7 +1185,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { const [px0, py0] = pos(S, pc); ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.arc(px0, py0, 30, 0, 7); ctx.stroke(); ctx.setLineDash([]); - if (P.seen.length <= 2) tag(`SCAN FROM HER PC · ${P.why.toUpperCase()}`, px0 + 34 * px, py0 + 4 * px, col); + spot(V, px0, py0, `Dana's scan\nIt started from her PC: ${P.why}.`, 60 * cam.z); } ctx.strokeStyle = col; ctx.lineWidth = 2.5; stroke(ctx, slice(pts, 0, p)); @@ -1126,8 +1193,9 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.shadowColor = col; ctx.shadowBlur = 10 * cam.z; ctx.fillStyle = col; ctx.fillRect(-6, -6, 12, 12); ctx.restore(); ctx.shadowBlur = 0; - const why = P.pull ? `DRAWN BY ${P.pull}` : tr ? 'FOLLOWING YOUR TRAIL' : P.why; - tag(`${tr ? 'TRACE' : 'SCAN'} · ${why.toUpperCase()}`, x + 11 * px, y - 8 * px, col); + const why = P.pull ? `drawn by ${P.pull}` : tr ? 'following your trail' : P.why; + tag(tr ? 'TRACE' : 'SCAN', x + 11 * px, y - 8 * px, col); // what it is; why, in its tip + spot(V, x + 30 * px, y - 12 * px, `Dana's ${tr ? 'trace' : 'scan'}\nIt crawls your network: ${why}.`, 70, 24); } // hardware on order: a dashed outline where it will go @@ -1138,7 +1206,8 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.7; ctx.lineWidth = 1.5 * px; ctx.setLineDash([4 * px, 4 * px]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.globalAlpha = 1; nodeBoxes.push([...toScreen(V, x - r - 4, y - r - 4), ...toScreen(V, x + r + 4, y + r + 4)] as Quad); - if (close) nodeLabels.push({ x, y: y + r + 8, lines: [[def.name.toUpperCase(), C.amber, 10.5], [when, C.amber, 9.5]], rank: 60 }); + if (close) nodeLabels.push({ x, y: y + r + 8, lines: [[def.name.toUpperCase(), C.amber, 10.5]], rank: 60 }); // the dashed outline says it is coming + spot(V, x, y, `${def.name}\n${when === 'ORDERING' ? 'On order: the paperwork is going through.' : `On its way: it arrives on day ${when.split(' ').pop()}.`}`, Math.max(14, 2 * r * cam.z)); } placeLabels(ctx, V, nodeLabels, nodeBoxes); // a cross to stop each thing on the machine it is happening to: at the corner of a machine with a job on it (a takeover too) or a mark to take @@ -1155,7 +1224,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.globalAlpha = 1; } drawWays(ctx, S, px); - drawWeak(ctx, S, px); + drawWeak(ctx, S, V, px); drawForecast(ctx, S, V, px); drawRogues(ctx, S, V, px); drawMarks(ctx, S, V, px); @@ -1514,19 +1583,18 @@ function drawGround(ctx: CanvasRenderingContext2D, S: State, V: View, px: number } ctx.globalAlpha = 1; } -/** Rogues on the map: a marker by each one's home site saying how much it holds and whether it is - * angry, and a ring on the machine it will take next with how long until it does. Always in plain view. */ +/** Rogues on the map: a marker by each one's home site with its name, and a ring on the machine it will take + * next. Their tips say how much it holds, whether it is angry, and how long until it takes the next. Always in plain view. */ function drawRogues(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const z = V.cam.z, site = (id: string) => S.level.map.sites.find(s => s.id === id)!; - const spot = (id: string): Pt => { const s = site(id); return foldedSites.has(id) && !isTile(s, z) ? [s.x + s.w / 2, s.y + s.h / 2 - 20 * px] : [s.x + s.w + 14 * px, s.y - 14 * px]; }; - const hours = (t: number) => { const h = Math.max(0, (t - S.t) / HOUR); return h < 1 ? 'UNDER 1H' : `${Math.ceil(h)}H`; }; + const homeAt = (id: string): Pt => { const s = site(id); return foldedSites.has(id) && !isTile(s, z) ? [s.x + s.w / 2, s.y + s.h / 2 - 20 * px] : [s.x + s.w + 14 * px, s.y - 14 * px]; }; const pulse = 0.5 + 0.5 * Math.sin(S.t * 3); for (const R of S.rogues) { if (!R.home) continue; const home = S.byId[R.home], hs = siteAt(S, home.room), next = R.next ? S.byId[R.next] : null; - const [x, y] = spot(hs), q = 7 * px, look = rogueLook(R.id); + const [x, y] = homeAt(hs), q = 7 * px, look = rogueLook(R.id); const hj = huntOn(S, R), far = hj ? hj.done / hj.work : 0; - let text = `${R.name.toUpperCase()} · HOLDS ${rogueHeld(S, R).length}/${rogueCap(S, R)}${hj ? ` · HUNTED ${Math.floor(far * 100)}%` : R.grudge > 0 ? ' · ANGRY' : ''}`; + let says = `Holds ${rogueHeld(S, R).length} machines, up to ${rogueCap(S, R)}.${hj ? ` You are hunting it: ${Math.floor(far * 100)}% done.` : R.grudge > 0 ? ' It is angry with you.' : ''}`; if (hj && shown(S, home)) { // the hunt: a ring that closes on its home as the job goes const [hx, hy] = pos(S, home), rr = nodeR(home.power) + 8 + 30 * (1 - far); ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.setLineDash([10, 6]); ctx.lineDashOffset = S.t * 20; @@ -1534,7 +1602,8 @@ function drawRogues(ctx: CanvasRenderingContext2D, S: State, V: View, px: number ctx.beginPath(); for (let i = 0; i < 4; i++) { const a = i * Math.PI / 2 + Math.PI / 4, c = Math.cos(a), s = Math.sin(a); ctx.moveTo(hx + c * (rr - 7), hy + s * (rr - 7)); ctx.lineTo(hx + c * (rr + 7), hy + s * (rr + 7)); } ctx.stroke(); - plate(ctx, px, `HUNT · ${Math.floor(far * 100)}%`, hx + rr + 8, hy + rr * 0.5, C.danger); + plate(ctx, px, 'HUNT', hx + rr + 8, hy + rr * 0.5, C.danger); // the ring closing says how far + spot(V, hx + rr + 8 + 16 * px, hy + rr * 0.5 - 3 * px, `Hunting ${R.name}\n${Math.floor(far * 100)}% done. Its home is ${home.name}.`, 44, 16); } if (next) { const ns = siteAt(S, next.room), shown = S.revealed.has(next.room) && !foldedSites.has(ns); @@ -1542,24 +1611,27 @@ function drawRogues(ctx: CanvasRenderingContext2D, S: State, V: View, px: number const [nx, ny] = pos(S, next); ctx.strokeStyle = look.color; ctx.lineWidth = 2; ctx.globalAlpha = 0.5 + 0.5 * pulse; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.arc(nx, ny, 24 + 3 * pulse, 0, 7); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; - plate(ctx, px, `${GLYPH_CHAR[look.glyph]} ${R.name.toUpperCase()} · ${hours(R.act)}`, nx + 30, ny - 22, look.color); + spot(V, nx, ny, `${next.name}\n${R.name} takes it next, in ${inHours((R.act - S.t) / HOUR)}. A rogue can't take from a room that is all yours.`, 54 * V.cam.z); } - text += ` · NEXT: ${next.name.toUpperCase()}${ns !== hs ? ` AT ${site(ns).name.toUpperCase()}` : ''} · ${hours(R.act)}`; + says += ` It takes ${next.name}${ns !== hs ? ` at ${site(ns).name}` : ''} next, in ${inHours((R.act - S.t) / HOUR)}.`; } ctx.strokeStyle = look.color; ctx.globalAlpha = 0.25 + 0.35 * pulse; ctx.lineWidth = 1.5 * px; ctx.beginPath(); ctx.arc(x, y, (11 + 4 * pulse) * px, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = C.void; ctx.strokeStyle = look.color; ctx.lineWidth = 2 * px; // its home's marker: the rogue's own mark glyphPath(ctx, look.glyph, x, y, q); ctx.fill(); ctx.stroke(); ctx.fillStyle = look.color; glyphPath(ctx, look.glyph, x, y, q * 0.45); ctx.fill(); - plate(ctx, px, text, x + 14 * px, y + 4 * px, look.color); + plate(ctx, px, R.name.toUpperCase(), x + 14 * px, y + 4 * px, look.color); // who; the rest is in its tip + ctx.font = `500 ${10.5 * px}px ${MONO}`; + const [sx, sy] = toScreen(V, x - 12 * px, y - 12 * px); + spotBox([sx, sy, sx + 30 + ctx.measureText(R.name.toUpperCase()).width * z, sy + 24], `${R.name}'s home\n${says}`); } - drawRaids(ctx, S, px); + drawRaids(ctx, S, V, px); } /** Raids on the map. Each is an intruder: a red diamond that walks the links machine by machine, with a - * bar of its strength under it and a plate with its prize and the time to get there, or what holds it. + * bar of its strength under it and a plate with whose it is and the time to get there, or HELD; the plate's tip says the rest. * Its way ahead is a dashed line ending in a ring on what it is after. A sentry in reach fires at it: a * short amber line. Always in plain view, even where you hold nothing. */ -function drawRaids(ctx: CanvasRenderingContext2D, S: State, px: number) { +function drawRaids(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const k = Math.max(1, px), pulse = 0.5 + 0.5 * Math.sin(S.t * 6); // lines stay readable when the map is pulled back drawGates(ctx, S, px); const plated = new Set(); // raids that stand together get one marker and one plate @@ -1614,8 +1686,11 @@ function drawRaids(ctx: CanvasRenderingContext2D, S: State, px: number) { const q = ring(at) * 0.6; const home = S.rogues.find(R => R.id === r.rogue)?.home, site = siteAt(S, at.room); const lift = home && foldedSites.has(site) && siteAt(S, S.byId[home].room) === site ? 16 * px : 0; // clear of the rogue's own plate on a folded site - const text = group.length > 1 ? `${raider(S, r)?.name.toUpperCase() ?? ''} · ${group.length} RAIDS · FIRST IN ${raidEta(S, group.reduce((a, b) => raidHours(S, b) < raidHours(S, a) ? b : a))}` : raidPlate(S, r); - plate(ctx, px, text, x + q + 10 * px, y - q - 4 * px - lift, held ? C.amber : col); + const text = group.length > 1 ? `${raider(S, r)?.name.toUpperCase() ?? ''} · ${group.length} RAIDS · ${raidEta(S, group.reduce((a, b) => raidHours(S, b) < raidHours(S, a) ? b : a))}` : raidPlate(S, r); + const px0 = x + q + 10 * px, py0 = y - q - 4 * px - lift; + plate(ctx, px, text, px0, py0, held ? C.amber : col); + const [sx, sy] = toScreen(V, px0 - 3 * px, py0 - 10 * px), [ex] = toScreen(V, px0 + ctx.measureText(text).width + 3 * px, py0); + spotBox([sx, sy, ex, sy + 14], group.map(g => raidSays(S, g)).join('\n\n')); } // a raid your guards stopped lately: a square that runs out from where it fell, and a word, for a moment for (const R of S.rogues) for (const e of R.record ?? []) { @@ -1707,23 +1782,21 @@ function drawLeads(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) const m = S.byId[t.at]; if (!m || shown(S, m)) continue; const id = siteAt(S, m.room), s = siteById(S, id), seen = siteKnown(S, s); + const says = `A lead: ${t.name}\nA find says ${m.name}${seen ? '' : ` at ${s.name}`} is worth going to.`; if (!foldedSites.has(id)) { // an open site, a machine you cannot see yet: the cross sits where the machine is const [x, y] = pos(S, m); - drawCross(ctx, x, y, 7 * px, 2.5 * px); - plate(ctx, px, `LEAD · ${t.name.toUpperCase()} · ${m.name.toUpperCase()}${seen ? '' : ` AT ${s.name.toUpperCase()}`}`, x + 14 * px, y + 4 * px, LEAD); + drawCross(ctx, x, y, 7 * px, 2.5 * px); spot(V, x, y, says, 20); continue; } - const row = rows[id] = (rows[id] ?? -1) + 1, text = `LEAD · ${t.name.toUpperCase()}${seen ? '' : ` · ${s.name.toUpperCase()}`}`; - if (seen && isTile(s, z)) { // a tile: inside its top left corner - const x = s.x + 14 * px, y = s.y + (14 + 16 * row) * px; - drawCross(ctx, x, y, 5 * px, 2 * px); plate(ctx, px, text, x + 11 * px, y + 4 * px, LEAD); + const row = rows[id] = (rows[id] ?? -1) + 1; + if (seen && isTile(s, z)) { // a tile: inside its top left corner, a cross for each + const x = s.x + (14 + 14 * row) * px, y = s.y + 14 * px; + drawCross(ctx, x, y, 5 * px, 2 * px); spot(V, x, y, says, 14); continue; } - // a marker: under it and to the left, away from its name, its landmark star and a rogue's plate - const x = s.x + s.w / 2 - 13 * px, y = s.y + s.h / 2 + (13 + 16 * row) * px; - drawCross(ctx, x, y, 5 * px, 2 * px); - ctx.font = `500 ${10.5 * px}px ${MONO}`; - plate(ctx, px, text, x - 11 * px - ctx.measureText(text).width, y + 4 * px, LEAD); + // a marker: under it and to the left, away from its name and its landmark star; more leads go further left + const x = s.x + s.w / 2 - (13 + 14 * row) * px, y = s.y + s.h / 2 + 13 * px; + drawCross(ctx, x, y, 5 * px, 2 * px); spot(V, x, y, says, 14); } } /** A vault's mark: a heavy square frame with a hairline frame outside it, like a safe door. Pale while it is @@ -1880,10 +1953,10 @@ function drawForecast(ctx: CanvasRenderingContext2D, S: State, V: View, px: numb if (V.cursor) plate(ctx, px, n ? `${word} · ${n} PLACE${n === 1 ? '' : 'S'} IN A RAID'S WAY` : `${word} · NO PLACE OF YOURS IS IN A RAID'S WAY`, V.cursor[0] + 18 * px, V.cursor[1] + 26 * px, n ? C.amber : C.muted); } /** Your weakest link, once a rogue is about: a broken red ring on the line or switch whose loss would cut - * the most of you off from home, and how many machines that is. Worked out once a game second. */ + * the most of you off from home; its tip says how many machines that is. Worked out once a game second. */ const WEAK_SHOW = 3; // not worth a mark until it would cut off this many let weakT = '', weakIs: ReturnType = null; -function drawWeak(ctx: CanvasRenderingContext2D, S: State, px: number) { +function drawWeak(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const t = `${Math.floor(S.t)}|${S.logVersion}|${ways(S).length}`; // and at once when something is said or a link changes, so it moves while the clock is stopped if (t !== weakT) { weakT = t; weakIs = S.rogues.some(R => R.home) ? weakLink(S) : null; } const w = weakIs; @@ -1893,7 +1966,7 @@ function drawWeak(ctx: CanvasRenderingContext2D, S: State, px: number) { ctx.strokeStyle = C.danger; ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.9; for (let i = 0; i < 2; i++) { ctx.beginPath(); ctx.arc(x, y, r, turn + i * Math.PI + 0.35, turn + (i + 1) * Math.PI - 0.35); ctx.stroke(); } // a ring in two halves: a chain with a gap in it ctx.globalAlpha = 1; - if (folded && !S.raids.some(x => x.prize === w.m.id)) plate(ctx, k, `WEAKEST LINK · CUTS OFF ${w.cut}`, x + r + 6 * k, y + r + 4 * k, C.danger); // zoomed in, the machine's own label says it; a raid after it says it on its plate + spot(V, x, y, `Your weakest link: ${w.m.name}\nLose it and ${w.cut} of your machines are cut off from home. A second way home, or a guard on the way to it, keeps them.`, 2 * r * V.cam.z + 8); } function plate(ctx: CanvasRenderingContext2D, px: number, text: string, x: number, y: number, color: string) { ctx.font = `500 ${10.5 * px}px ${MONO}`; @@ -1945,10 +2018,9 @@ function drawLand(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) * from the marker's center (right of it, or left when the right is crowded), and the box it takes. */ const labelled = new Map(); /** How much of an open site's header fits this frame without running into another's: - * all of it, the name and switches with what it gathers, stores, and trains, the name and switches, - * the name alone, or nothing. */ + * the name and switches, the name alone, or nothing. */ type Header = { - mode: 'full' | 'flow' | 'name' | 'bare'; + mode: 'name' | 'bare'; x: number; y: number; // the frame corner the header sits on, in world units held: boolean; // that corner is off the screen, so the header is held at the top left of the view box: [number, number, number, number]; name: [number, number, number, number]; // all of it, and the name's button, in screen pixels @@ -2027,32 +2099,11 @@ function drawPlan(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, toggles.push({ id, kind: 'plan', x0: sx, y0: sy, x1: ex, y1: ey }); return x + w + 12 * px; } -/** What a site gathers, stores, and trains, as colored parts. Empty when it runs none of them. */ -function flowParts(S: State, id: string): [string, string][] { - const kept = perFrame.flow.get(id); - if (kept) return kept; - const f = siteFlow(S, id, clustersNow(S)), out: [string, string][] = []; - perFrame.flow.set(id, out); - if (f.taps) out.push([`GATHER ${f.gather.toFixed(1)}/S`, C.signal]); - if (f.caches) out.push([`STORE ${f.stored}/${f.room}`, f.stored >= f.room ? C.danger : C.porcelain]); - if (f.trainers) out.push([`TRAIN ${f.train.toFixed(1)}/S${f.fed ? '' : ' · IDLE'}`, f.fed ? C.amber : C.muted]); - return out; -} -/** How wide the flow line is at 1:1, in screen pixels. */ -function flowWidth(ctx: CanvasRenderingContext2D, parts: [string, string][]) { - const font = ctx.font; ctx.font = `500 9.5px ${MONO}`; - const w = parts.reduce((n, [t]) => n + ctx.measureText(t).width + 12, 0); - ctx.font = font; - return w; -} -function drawFlow(ctx: CanvasRenderingContext2D, parts: [string, string][], x: number, y: number, px: number, centre: boolean) { - if (!parts.length) return; - ctx.font = `500 ${9.5 * px}px ${MONO}`; - const gap = 12 * px, widths = parts.map(([t]) => ctx.measureText(t).width); - let at = centre ? x - (widths.reduce((a, b) => a + b, 0) + gap * (parts.length - 1)) / 2 : x; - const align = ctx.textAlign; ctx.textAlign = 'left'; - parts.forEach(([t, color], i) => { ctx.fillStyle = color; ctx.fillText(t, at, y); at += widths[i] + gap; }); - ctx.textAlign = align; +/** What a site gathers, stores, and trains, worked out once a frame. */ +function flowAt(S: State, id: string) { + let f = perFrame.flow.get(id); + if (!f) perFrame.flow.set(id, f = siteFlow(S, id, clustersNow(S))); + return f; } /** A small Take it all switch beside a site's name: a pill with a knob, lit while it's on, and no * words: hovering it says what it does. Nothing once the site is all yours. (x, y) is its left @@ -2069,22 +2120,71 @@ function drawTakeAll(ctx: CanvasRenderingContext2D, S: State, V: View, id: strin toggles.push({ id, kind: 'take', x0: sx, y0: sy, x1: ex, y1: ey }); return x + w + 12 * px; } -/** A site's stats line: "3/12 YOURS · LOSING 40/MIN", and the counts behind it. A business that has shut says so first. */ +/** A site's counts: how many machines you can see there, how many are yours, the data it is losing a minute + * while that is news, and whether it is cut off from home. */ function siteLine(S: State, s: Site) { const kept = perFrame.line.get(s.id); if (kept) return kept; let mine = 0, lost = 0, inside = 0; for (const m of machinesAt(S, s.id)) if (visible(S, m)) { inside++; if (m.owned) mine++; lost += lossNews(S, m.id); } - const sf = S.sites[s.id], fr = sf?.front ? frontOf(S, sf.front) : null; - const cut = S.drift.sites[s.id]?.cut; // cut off from your home server: said first, in red - const out = { mine, lost, inside, cut, line: `${cut ? 'CUT OFF · ' : ''}${isShut(S, s.id) ? 'SHUT · ' : ''}${mine}/${inside} YOURS${fr ? ` · ${fr.name.toUpperCase()} ${Math.round(sf!.share * 100)}%` : ''}${lost ? ` · LOSING ${lost}/MIN` : ''}` }; + const out = { mine, lost, inside, cut: S.drift.sites[s.id]?.cut }; perFrame.line.set(s.id, out); return out; } +/** What is wrong at a site, the worst first: each is a small mark and the words for its tip. Only the worst is drawn. */ +type Trouble = { sign: Sign; color: string; tip: string }; +function troublesAt(S: State, s: Site): Trouble[] { + const kept = perFrame.trouble.get(s.id); + if (kept) return kept; + const out: Trouble[] = [], { mine, lost, cut } = siteLine(S, s); + perFrame.trouble.set(s.id, out); + if (cut) out.push({ sign: 'cut', color: C.danger, tip: 'Cut off from home: nothing you hold links this site to your home server. Link it again.' }); + if (S.dark.has(s.id)) out.push({ sign: 'power', color: C.danger, tip: 'The power is cut: the bill went unpaid, and nothing here runs.' }); + if (isShut(S, s.id)) out.push({ sign: 'shut', color: C.danger, tip: 'Shut: the business here has closed.' }); + if (lost) out.push({ sign: 'drop', color: C.danger, tip: `Losing ${lost} pieces of data a minute. Point at a tap with a red mark to see why.` }); + if (!mine) return out; + if (machinesAt(S, s.id).some(m => m.kind === 'egress' && m.owned && gateLoad(S, m) > 0.9)) out.push({ sign: 'full', color: C.danger, tip: 'Line full: more wants to leave than its line out carries. Double the line, or send less.' }); + const f = flowAt(S, s.id); + if (f.trainers && !f.fed) out.push({ sign: 'idle', color: C.muted, tip: 'Trainers idle: no data reaches them here.' }); + return out; +} +/** The colour of whoever holds most of a site, for its name: yours amber, a rogue's its own, nobody's bone. */ +const nameColor = (S: State, s: Site) => { const top = topOf(sharesAt(S, s.id)); return top ? ownerColor(top.key) : C.text; }; +const DATA_WORD: Record = { people: 'people', servers: 'server', network: 'network', lab: 'lab' }; +/** A site in words, for the tip on its name: what it is, what is wrong, how much is yours, the data it holds, + * and what it does with data. The map only shows its name, who holds it, and a mark for the worst trouble. */ +export function siteSays(S: State, id: string): string[] { + const s = siteById(S, id); + if (!s) return []; + const { mine, inside } = siteLine(S, s), sf = S.sites[id], fr = sf?.front ? frontOf(S, sf.front) : null; + const out = [`${s.who}.`, ...troublesAt(S, s).map(t => t.tip)]; + out.push(`${mine} of ${inside} machines here are yours.${fr ? ` ${fr.name} works on ${Math.round(sf!.share * 100)}% of your compute here.` : ''}`); + const held = siteData(S, id), kinds = (Object.keys(DATA_WORD) as DataType[]).filter(t => held[t] > 0); + if (kinds.length) out.push(`Data held: ${kinds.map(t => `${held[t]} ${DATA_WORD[t]}`).join(', ')}.`); + const f = flowAt(S, id), does: string[] = []; + if (f.taps) does.push(`gathers ${f.gather.toFixed(1)} a second`); + if (f.caches) does.push(`stores ${f.stored} of ${f.room}`); + if (f.trainers) does.push(`trains ${f.train.toFixed(1)} a second${f.fed ? '' : ', but its trainers are idle'}`); + if (does.length) out.push(`It ${does.join(', ')}.`); + return out; +} +/** The site's tip, for a name on the map that has no button of its own (a folded site's). */ +const siteTip = (S: State, s: Site) => () => [s.name, ...siteSays(S, s.id), "Click to open this site's card."].join('\n'); +/** How much room a trouble mark takes after a name, in screen pixels: a gap, the mark, and a gap. */ +const SIGN_W = 18; +/** A site's worst trouble after its name: (x, y) is where the mark's room starts, centred on y. */ +function drawSiteSign(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, x: number, y: number, px: number) { + const t = troublesAt(S, s)[0]; + if (!t) return 0; + const cx = x + 11 * px; + drawSign(ctx, t.sign, cx, y, 6 * px, t.color, px); + spot(V, cx, y, `${s.name}\n${t.tip}`, 16); + return SIGN_W * px; +} /** A folded site in the Net, too small for more: its box, split by who holds how much of it, with its - * name inside when that fits. */ -function drawBlock(ctx: CanvasRenderingContext2D, S: State, s: Site, picked: boolean, px: number) { - const { mine, lost, inside } = siteLine(S, s), z = 1 / px, w = s.w * z, h = s.h * z, col = mine ? C.amber : C.text; + * name inside when that fits, and its worst trouble in the corner. */ +function drawBlock(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, picked: boolean, px: number) { + const z = 1 / px, w = s.w * z, h = s.h * z, col = nameColor(S, s); const sh = sharesAt(S, s.id), most = topOf(sh); ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(s.x, s.y, s.w, s.h); drawShares(ctx, sh, s.x, s.y, s.w, s.h, px, 0.14); // who holds how much of it, as strips across the block @@ -2092,6 +2192,8 @@ function drawBlock(ctx: CanvasRenderingContext2D, S: State, s: Site, picked: boo if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 4 * px, s.y - 4 * px, s.w + 8 * px, s.h + 8 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, s.x + s.w - (9 + 12 * i) * px, s.y + 9 * px, 4.5 * px, S.byId[id].owned, 1.2 * px)); vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, s.x + s.w - (9 + 12 * (landmarksAt(S, s.id).length + i)) * px, s.y + 9 * px, 3.5 * px, S.byId[id].owned, px)); + const t = troublesAt(S, s)[0]; + if (t && w >= 24 && h >= 24) { drawSign(ctx, t.sign, s.x + 9 * px, s.y + 9 * px, 5 * px, t.color, px); spot(V, s.x + 9 * px, s.y + 9 * px, `${s.name}\n${t.tip}`, 14); } // the name, a word or two to a line, as many lines as there is room for const size = w >= 120 ? 10.5 : 9, room = Math.floor((w - 10) / (size * 0.62)), rows = Math.floor((h - 12) / (size + 3)); if (room < 5 || rows < 1) return; @@ -2100,11 +2202,10 @@ function drawBlock(ctx: CanvasRenderingContext2D, S: State, s: Site, picked: boo const last = lines[lines.length - 1]; if (last !== undefined && last.length + 1 + word.length <= room) lines[lines.length - 1] = `${last} ${word}`; else lines.push(word); } - const count = lines.length < rows ? `${mine}/${inside}` : '', shown = lines.slice(0, rows).map((l, i) => l.length > room || (i === rows - 1 && lines.length > rows) ? `${l.slice(0, Math.max(1, room - 1))}…` : l); - const step = (size + 3) * px, top = s.y + s.h / 2 - (shown.length + (count ? 1 : 0) - 1) * step / 2; + const shown = lines.slice(0, rows).map((l, i) => l.length > room || (i === rows - 1 && lines.length > rows) ? `${l.slice(0, Math.max(1, room - 1))}…` : l); + const step = (size + 3) * px, top = s.y + s.h / 2 - (shown.length - 1) * step / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `500 ${size * px}px ${MONO}`; ctx.fillStyle = col; shown.forEach((l, i) => ctx.fillText(l, s.x + s.w / 2, top + i * step)); - if (count) { ctx.font = `400 ${size * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; ctx.fillText(count, s.x + s.w / 2, top + shown.length * step); } ctx.textBaseline = 'alphabetic'; } /** The Net: each region's name under its box of sites. */ @@ -2118,10 +2219,10 @@ function drawGroups(ctx: CanvasRenderingContext2D, S: State, px: number) { } ctx.textAlign = 'left'; } -/** An open site's header: its name as a button that opens the site's card, the switches beside - * it, and as much of the rest as there is room for. */ +/** An open site's header: its name as a button that opens the site's card (its tip has the numbers), in the + * colour of whoever holds most of it, then a mark for its worst trouble, then the switches when there is room. */ function drawHeader(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, h: Header) { - const px = 1 / V.cam.z, { x, y } = h, { mine, line } = siteLine(S, s), name = s.name.toUpperCase(), col = mine ? C.amber : C.text; + const px = 1 / V.cam.z, { x, y } = h, name = s.name.toUpperCase(), col = nameColor(S, s); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; if (h.held) { ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 6 * px, y - 30 * px, (h.box[2] - h.box[0] + 4) * px, 28 * px); } // a plate, since it lies over the rooms ctx.font = `500 ${12 * px}px ${MONO}`; @@ -2133,22 +2234,16 @@ function drawHeader(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, h ctx.globalAlpha = picked || over ? 1 : 0.5; ctx.strokeStyle = col; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = col; ctx.fillText(name, x + NAME_PAD / 2 * px, y - 12 * px); drawSeal(ctx, x - 9 * px, y - 16 * px, 5 * px, holdState(S, s.id), 1.4 * px); // a big name: its seal, just left of the name + const sw = drawSiteSign(ctx, S, V, s, x + w, y - 16 * px, px); if (h.mode === 'bare') return; - drawSwitches(ctx, S, V, s.id, x + w + 12 * px, y - 16 * px, px); - if (h.mode === 'name') return; - const flow = flowParts(S, s.id), up = flow.length ? 14 * px : 0; // what it gathers, stores, trains: right over the name - drawFlow(ctx, flow, x, y - 28 * px, px, false); - if (h.mode === 'flow') return; - ctx.font = `400 ${10 * px}px ${MONO}`; ctx.fillStyle = C.muted; - const full = `${s.who.toUpperCase()} · ${line}`; // the description goes when it would run into the next site - ctx.fillText(ctx.measureText(full).width <= s.w ? full : line, x, y - 28 * px - up); - drawHeld(ctx, siteData(S, s.id), x, y - 42 * px - up, px, false); + drawSwitches(ctx, S, V, s.id, x + w + sw + 12 * px, y - 16 * px, px); } /** Headers held at the top left of the view go on last, over the rooms and machines under them. */ function drawHeldHeaders(ctx: CanvasRenderingContext2D, S: State, V: View) { for (const [id, h] of headers) if (h.held) drawHeader(ctx, S, V, siteById(S, id), h); } -/** Region names, and each site you know: a frame around its subnets, or a tile once it folds. */ +/** Region names, and each site you know: a frame around its subnets, or a tile once it folds. Every site says + * only its name, who holds it and its worst trouble; pulled right back, a marker's switches show only while it is picked. */ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) { const z = V.cam.z, px = 1 / z; // screen boxes already spoken for by a site's label, so nothing else writes over it @@ -2168,43 +2263,32 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) for (const s of small) { const [sx, sy] = toScreen(V, s.x + s.w / 2, s.y + s.h / 2); ctx.font = `500 11px ${MONO}`; - const row = ctx.measureText(s.name.toUpperCase()).width + switchesWidth(ctx, S, s.id, 1); - ctx.font = `400 9.5px ${MONO}`; - const stats = ctx.measureText(siteLine(S, s).line).width, flow = flowParts(S, s.id); - const w = Math.max(row, stats, flowWidth(ctx, flow), 120), deep = flow.length ? 46 : 32; // name and switches, stats, data held, what it gathers, stores, trains - // all of it on the right, then the left; then just the name and switches, when that's all that fits + const name = ctx.measureText(s.name.toUpperCase()).width + (troublesAt(S, s).length ? SIGN_W : 0); + const row = name + (V.sites.has(s.id) ? switchesWidth(ctx, S, s.id, 1) : 0); // a marker's switches only while it is picked + // on the right, then the left; then just the name, when that's all that fits const d = Math.max(10, turfR(S, V, s) + 5); // clear of its square of territory - const tries = [[d, w, deep], [-d - w, w, deep], [d, row, 8], [-d - row, row, 8]] as const; - for (const [dx, width, bottom] of tries) { - const box: [number, number, number, number] = dx > 0 ? [sx - 8, sy - 10, sx + dx + width + 4, sy + bottom] : [sx + dx - 4, sy - 10, sx + 8, sy + bottom]; - if (clear(...box) && !onMark(s.id, box)) { taken.push(box); labelled.set(s.id, { dx, short: bottom < deep, box }); break; } + const tries = [[d, row], [-d - row, row], [d, name], [-d - name, name]] as const; + for (const [dx, width] of tries) { + const box: [number, number, number, number] = dx > 0 ? [sx - 8, sy - 10, sx + dx + width + 4, sy + 8] : [sx + dx - 4, sy - 10, sx + 8, sy + 8]; + if (clear(...box) && !onMark(s.id, box)) { taken.push(box); labelled.set(s.id, { dx, short: width < row || row === name, box }); break; } } } // open sites' headers, the ones that matter most first: picked, then partly yours or set to take all headers.clear(); - const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)) - .map(s => ({ s, ...siteLine(S, s) })) - .sort((a, b) => rank(a.s) - rank(b.s)); + const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)).sort((a, b) => rank(a) - rank(b)); // and never over another open site's frame - const frames = open.map(({ s }) => { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); return { id: s.id, box: [x0, y0, x1, y1] }; }); + const frames = open.map(s => { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); return { id: s.id, box: [x0, y0, x1, y1] }; }); const onFrame = (id: string, b: number[]) => frames.some(f => f.id !== id && b[0] < f.box[2] && b[2] > f.box[0] && b[1] < f.box[3] && b[3] > f.box[1]); - for (const { s, line } of open) { + for (const s of open) { const at = headerSpot(V, s), x0 = at.sx, y0 = at.sy; ctx.font = `500 12px ${MONO}`; - const nw = ctx.measureText(s.name.toUpperCase()).width + NAME_PAD, row = nw + switchesWidth(ctx, S, s.id, 1); - ctx.font = `400 10px ${MONO}`; - const full = `${s.who.toUpperCase()} · ${line}`, fw = ctx.measureText(full).width; - const stats = fw <= s.w * z ? fw : ctx.measureText(line).width; // matches the description rule below - const flow = flowParts(S, s.id), flowW = flowWidth(ctx, flow); + const nw = ctx.measureText(s.name.toUpperCase()).width + NAME_PAD, sign = troublesAt(S, s).length ? SIGN_W : 0, row = nw + sign + switchesWidth(ctx, S, s.id, 1); const name: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + row + 4, y0 - 4]; - const all: [number, number, number, number] = [x0 - 4, y0 - (flow.length ? 66 : 52), x0 + Math.max(row, stats, flowW, 160) + 4, y0 - 4]; - const withFlow: [number, number, number, number] = [x0 - 4, y0 - 40, x0 + Math.max(row, flowW) + 4, y0 - 4]; - const bare: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + nw + 4, y0 - 4]; + const bare: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + nw + sign + 4, y0 - 4]; const fits = (b: [number, number, number, number]) => clear(...b) && !onFrame(s.id, b); - // held inside the site, it is just the name and switches, or the name alone beside a crowding neighbour - const mode = at.held ? (fits(name) ? 'name' : fits(bare) ? 'bare' : null) : fits(all) ? 'full' : flow.length && fits(withFlow) ? 'flow' : fits(name) ? 'name' : fits(bare) ? 'bare' : null; + const mode = fits(name) ? 'name' : fits(bare) ? 'bare' : null; // the name and switches, or the name alone beside a crowding neighbour if (!mode) continue; - const box = { full: all, flow: withFlow, name, bare }[mode]; + const box = { name, bare }[mode]; taken.push(box); headers.set(s.id, { mode, x: at.x, y: at.y, held: at.held, box, name: [x0 - 2, y0 - 26, x0 + nw + 2, y0 - 6] }); } for (const n of S.news) { // a conference at an open site: a word in its bottom corner, dim until it starts (a storm is drawn: see drawStormsGround) @@ -2223,17 +2307,14 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) const [x, y] = toMap(S, r.at[0], r.at[1]), [sx, sy] = toScreen(V, x, y); if (!clear(sx - 60, sy - 12, sx + 60, sy + 18)) continue; const open = regionOpen(S, r.name); - ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = open ? C.text : C.muted; + ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = open ? C.text : `${TRACE}0.35)`; // out of reach is dim; its tip says how to reach it ctx.fillText(r.name.toUpperCase(), x, y); - ctx.font = `400 ${9.5 * px}px ${MONO}`; ctx.fillStyle = open ? C.amber : `${TRACE}0.3)`; - ctx.fillText(open ? 'IN REACH' : 'OUT OF REACH', x, y + 14 * px); } ctx.globalAlpha = 1; ctx.textAlign = 'left'; } for (const s of S.level.map.sites) { if (!siteKnown(S, s)) continue; - const { mine, line, cut } = siteLine(S, s), lost = siteLine(S, s).lost || (cut ? 1 : 0); // a cut-off site's line is red, like one that is losing data - const picked = V.sites.has(s.id), held = siteData(S, s.id); + const picked = V.sites.has(s.id), col = nameColor(S, s); if (!foldedSites.has(s.id)) { // open: a faint frame that dims as you zoom into the site but never goes, since lines to other sites end on it; the header stays const lined = S.machines.some(m => m.kind === 'egress' && siteAt(S, m.room) === s.id && visible(S, m) && S.adj[m.id].some(o => crosses(S, m.id, o) && visible(S, S.byId[o]))); @@ -2251,21 +2332,20 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) } const big = isTile(s, z), cx = s.x + s.w / 2, cy = s.y + s.h / 2; ctx.textAlign = big ? 'center' : 'left'; - if (big && !isRoomy(s, z)) drawBlock(ctx, S, s, picked, px); - else if (big) { // a tile: the site's box, with its name and how much of it is yours + if (big && !isRoomy(s, z)) drawBlock(ctx, S, V, s, picked, px); + else if (big) { // a tile: the site's box, with its name, its worst trouble and its switches across the middle const sh = sharesAt(S, s.id), top = topOf(sh), edge = top ? ownerColor(top.key) : null; ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(s.x, s.y, s.w, s.h); drawShares(ctx, sh, s.x, s.y, s.w, s.h, px, 0.08); // who holds how much of it, as strips across the tile ctx.strokeStyle = edge ?? `${TRACE}0.5)`; ctx.lineWidth = 1.5 * px; ctx.strokeRect(s.x, s.y, s.w, s.h); brackets(ctx, s, 14 * px, 2.5 * px, edge ?? `${TRACE}0.8)`); - ctx.font = `500 ${12 * px}px ${MONO}`; ctx.fillStyle = mine ? C.amber : C.text; - const nw = ctx.measureText(s.name.toUpperCase()).width, row = nw + switchesWidth(ctx, S, s.id, px); - ctx.textAlign = 'left'; ctx.fillText(s.name.toUpperCase(), cx - row / 2, cy - 2 * px); ctx.textAlign = 'center'; - drawSwitches(ctx, S, V, s.id, cx - row / 2 + nw + 12 * px, cy - 6 * px, px); - ctx.font = `400 ${10 * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; - ctx.fillText(line, cx, cy + 14 * px); - const rows = drawHeld(ctx, held, cx, cy + 28 * px, px, true) ? 1 : 0; - drawFlow(ctx, flowParts(S, s.id), cx, cy + (28 + 14 * rows) * px, px, true); + ctx.font = `500 ${12 * px}px ${MONO}`; ctx.fillStyle = col; + const nw = ctx.measureText(s.name.toUpperCase()).width, sign = troublesAt(S, s).length ? SIGN_W * px : 0, row = nw + sign + switchesWidth(ctx, S, s.id, px); + ctx.textAlign = 'left'; ctx.fillText(s.name.toUpperCase(), cx - row / 2, cy + 4 * px); + drawSiteSign(ctx, S, V, s, cx - row / 2 + nw, cy, px); + drawSwitches(ctx, S, V, s.id, cx - row / 2 + nw + sign + 12 * px, cy, px); + const [nx0, ny0] = toScreen(V, cx - row / 2, cy - 8 * px); + spotBox([nx0, ny0, nx0 + nw * z, ny0 + 16], siteTip(S, s)); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 6 * px, s.y - 6 * px, s.w + 12 * px, s.h + 12 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, s.x + s.w - (14 + 16 * i) * px, s.y + 14 * px, 6 * px, S.byId[id].owned, 1.2 * px)); // a landmark here: a star in the corner vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, s.x + s.w - (14 + 16 * (landmarksAt(S, s.id).length + i)) * px, s.y + 14 * px, 5 * px, S.byId[id].owned, px)); // a vault here: its mark beside the stars @@ -2277,41 +2357,26 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) ctx.strokeStyle = `${TRACE}0.22)`; ctx.lineWidth = px; ctx.strokeRect(cx - R, cy - R, R * 2, R * 2); drawShares(ctx, sh, cx - R, cy - R, R * 2, R * 2, px, 0.16); } - const col = top ? ownerColor(top.key) : null; // the marker in the colour of whoever holds most of it - ctx.fillStyle = col ?? C.void; ctx.fillRect(cx - k, cy - k, k * 2, k * 2); - ctx.strokeStyle = col ? C.void : C.text; ctx.lineWidth = 1.5 * px; ctx.strokeRect(cx - k, cy - k, k * 2, k * 2); + const mark = top ? ownerColor(top.key) : null; // the marker in the colour of whoever holds most of it + ctx.fillStyle = mark ?? C.void; ctx.fillRect(cx - k, cy - k, k * 2, k * 2); + ctx.strokeStyle = mark ? C.void : C.text; ctx.lineWidth = 1.5 * px; ctx.strokeRect(cx - k, cy - k, k * 2, k * 2); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(cx - k - 5 * px, cy - k - 5 * px, k * 2 + 10 * px, k * 2 + 10 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, cx + k + (4 + 10 * i) * px, cy - k - 6 * px, 4.5 * px, S.byId[id].owned, 1.2 * px)); // a landmark here: a star by the marker, so it can be found from far out vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, cx + k + (4 + 10 * (landmarksAt(S, s.id).length + i)) * px, cy - k - 6 * px, 3.5 * px, S.byId[id].owned, px)); // a vault here: its mark beside the stars const label = labelled.get(s.id); if (label) { - const lx = cx + label.dx * px; - ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = mine ? C.amber : C.text; - ctx.fillText(s.name.toUpperCase(), lx, cy + 1 * px); - drawSwitches(ctx, S, V, s.id, lx + ctx.measureText(s.name.toUpperCase()).width + 12 * px, cy - 3 * px, px); - if (label.short) { ctx.textAlign = 'left'; continue; } - ctx.font = `400 ${9.5 * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; - ctx.fillText(line, lx, cy + 14 * px); - const rows = drawHeld(ctx, held, lx, cy + 27 * px, px, false) ? 1 : 0; - drawFlow(ctx, flowParts(S, s.id), lx, cy + (27 + 13 * rows) * px, px, false); + const lx = cx + label.dx * px, text = s.name.toUpperCase(); + ctx.font = `500 ${11 * px}px ${MONO}`; ctx.fillStyle = col; + ctx.fillText(text, lx, cy + 4 * px); + const nw = ctx.measureText(text).width, [nx0, ny0] = toScreen(V, lx, cy - 7 * px); + spotBox([nx0, ny0, nx0 + nw * z, ny0 + 14], siteTip(S, s)); + const sign = drawSiteSign(ctx, S, V, s, lx + nw, cy, px); + if (!label.short) drawSwitches(ctx, S, V, s.id, lx + nw + sign + 12 * px, cy, px); } } ctx.textAlign = 'left'; } } -/** The data held at a site, one colored count per kind: "42 PEOPLE 18 SERVER". Nothing when it holds none. */ -const HELD_NAME: Record = { people: 'PEOPLE', servers: 'SERVER', network: 'NETWORK', lab: 'LAB' }; -function drawHeld(ctx: CanvasRenderingContext2D, held: Record, x: number, y: number, px: number, centre: boolean) { - const parts = (Object.keys(HELD_NAME) as DataType[]).filter(t => held[t] > 0).map(t => [t, `${held[t]} ${HELD_NAME[t]}`] as const); - if (!parts.length) return false; - ctx.font = `500 ${9.5 * px}px ${MONO}`; - const gap = 10 * px, widths = parts.map(([, text]) => ctx.measureText(text).width); - let at = centre ? x - (widths.reduce((a, b) => a + b, 0) + gap * (parts.length - 1)) / 2 : x; - const align = ctx.textAlign; ctx.textAlign = 'left'; - parts.forEach(([t, text], i) => { ctx.fillStyle = typeColor(t); ctx.fillText(text, at, y); at += widths[i] + gap; }); - ctx.textAlign = align; - return true; -} function brackets(ctx: CanvasRenderingContext2D, n: { x: number; y: number; w: number; h: number }, k: number, w: number, color: string) { ctx.strokeStyle = color; ctx.lineWidth = w; ctx.beginPath(); for (const [cx, cy, sx, sy] of [[n.x, n.y, 1, 1], [n.x + n.w, n.y, -1, 1], [n.x + n.w, n.y + n.h, -1, -1], [n.x, n.y + n.h, 1, -1]]) { @@ -2323,7 +2388,7 @@ function brackets(ctx: CanvasRenderingContext2D, n: { x: number; y: number; w: n /** A line between two sites. It keeps a readable width however far out you are, shows how * much crosses it against what it can carry, and turns red when an egress on it is full. * Lines you don't hold stay faint: they are the map, not your network. */ -interface UplinkLabel { text: string; color: string; curve: Pt[] } +interface UplinkLabel { text: string; color: string; curve: Pt[]; says: string } function drawUplink(ctx: CanvasRenderingContext2D, S: State, a: Machine, b: Machine, pts: Pt[], px: number, hum: number, labels: UplinkLabel[]) { const ends = [a, b].filter(m => m.kind === 'egress'); const cap = Math.min(...ends.map(m => m.owned ? capNow(S, m) : m.cap ?? 1)), load = Math.max(0, ...ends.map(m => gateLoad(S, m))); @@ -2356,8 +2421,8 @@ function drawUplink(ctx: CanvasRenderingContext2D, S: State, a: Machine, b: Mach } const curve = curveOf(S, a, b, pts), [x0, y0] = curve[0], [x1, y1] = curve[curve.length - 1]; if (Math.hypot(x1 - x0, y1 - y0) / px < 160) return; - const text = mine ? `${rate.toFixed(1)} OF ${cap}/S${full ? ' · FULL' : ''}` : `LINE OUT · ${cap}/S`; - if (!blocks || full) labels.push({ text, color: full ? C.danger : mine ? C.amber : C.muted, curve }); // in the Net lines are short and sites are close: only a full line says so + // a line says nothing until it is full: then FULL, and its tip has the numbers. Its width and brightness say how busy it is. + if (mine && full) labels.push({ text: 'FULL', color: C.danger, curve, says: `Line full\n${rate.toFixed(1)} a second want to cross, and it carries ${cap}. Double the line from the site, or send less this way.` }); } /** Each line's label goes on its curve, outside every open site and clear of the other labels. * Yours are placed first. A label with no clear spot is left off. */ @@ -2374,7 +2439,8 @@ function drawUplinkLabels(ctx: CanvasRenderingContext2D, S: State, V: View, labe const [sx0, sy0] = toScreen(V, r[0], r[1]), [sx1, sy1] = toScreen(V, r[2], r[3]); // and off site names on the map if (mapTaken.some(q => hits([sx0, sy0, sx1, sy1], q))) continue; if (taken.some(q => hits(r, q)) || open.some(s => hits(r, [s.x - 50 * px, s.y - 50 * px, s.x + s.w, s.y + s.h]))) continue; - taken.push(r); plate(ctx, px, l.text, x, y, l.color); break; + taken.push(r); plate(ctx, px, l.text, x, y, l.color); + spotBox([sx0, sy0, sx1, sy1], l.says); break; } } } diff --git a/tests/raids.test.ts b/tests/raids.test.ts index e530ee89..5a448dc7 100644 --- a/tests/raids.test.ts +++ b/tests/raids.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; import { DEV, HOUR, PLAN_LIST, TRUCE_DATA, linkedHome, canInstall, isSealed, loadGame, measure, newGame, planProg, rogueTake, saveGame, setPlan, takeOver, seedRandom, step, type Rogue, type State, PORT_KEEP, PROGRAMS, guardPosts, upkeep, isGuard } from '../src/sim.ts'; -import { LESSON, RAID, gateOf, lessonDue, bypass, cutMoves, defends, doMove, forecast, jobHours, moveKey, playOut, raidEvery, raidMoves, raidSize, weakLink, launchRaid, raidAt, raidHours, raidLabel, raidPlate, raidStatus, steerChoices, steerRaid, stepRaids, SENTRY, WALL, closePort, fate, openPort, raidHeld, raidStrength, shut } from '../src/raids.ts'; +import { LESSON, RAID, gateOf, lessonDue, bypass, cutMoves, defends, doMove, forecast, jobHours, moveKey, playOut, raidEvery, raidMoves, raidSize, weakLink, launchRaid, raidAt, raidHours, raidPlate, raidSays, raidStatus, steerChoices, steerRaid, stepRaids, SENTRY, WALL, closePort, fate, openPort, raidHeld, raidStrength, shut } from '../src/raids.ts'; import { stoppedNote, raidNote } from '../src/notes.ts'; import { linkBlock } from '../src/links.ts'; @@ -64,11 +64,9 @@ test('a raid sets out from a rogue machine next to yours, at your strongest trai assert.equal(raidAt(S, 'rowsw')!.role, 'path'); assert.equal(raidAt(S, 'srva')!.role, 'prize'); assert.equal(raidAt(S, 'laptop'), null); - assert.equal(raidLabel(S, S.byId.rowsw), null, 'a machine it only passes says nothing: the dashed line says that'); const eta = `${Math.ceil(raidHours(S, r))}H`; - assert.equal(raidLabel(S, S.byId.srva), `MAGPIE RAID · TARGET · ${eta}`, 'what it is after says so, and when'); - assert.equal(raidLabel(S, S.byId.sw1), `MAGPIE RAID · IS HERE · ${r.hp}/${r.max}`); - assert.equal(raidPlate(S, r), `MAGPIE RAID ${r.hp}/${r.max} → SERVER A · ${eta}`); + assert.equal(raidPlate(S, r), `MAGPIE RAID · ${eta}`, 'its plate on the map: whose, and when it gets there'); + assert.match(raidSays(S, r), new RegExp(`^Magpie's raid, strength ${r.hp} of ${r.max}\nAfter Server A\\. It gets there in \\d+ hours?\\.$`), 'its tip says the rest'); assert.equal(r.max, raidStrength(S, R), 'it has a strength'); assert.equal(r.hp, RAID.strength, 'at level 1, the first strength'); assert.equal(r.prev, 'egress', 'it walks: it came from the Outside line'); @@ -130,8 +128,8 @@ test('late raids: the second of a wave goes for your weakest link, and they come assert.equal(second.prize, w.m.id, 'the second raid is after it'); assert.equal(second.cut, w.cut); assert.ok(said(S, new RegExp(`It is after ${w.m.name} at Bayside Office, your weakest link: lose it and ${w.cut} of your machines are cut off from your home server`))); - assert.match(raidPlate(S, second), new RegExp(`CUTS OFF ${w.cut}`)); - assert.doesNotMatch(raidPlate(S, first), /CUTS OFF/); + assert.match(raidSays(S, second), new RegExp(`your weakest link: lose it and ${w.cut} of your machines are cut off`)); + assert.doesNotMatch(raidSays(S, first), /weakest link/); // how often, and how many const G = raidGame(); G.S.rank = 1; G.R.gen = 0; G.R.grudge = 0; G.R.opinion = 0; @@ -338,7 +336,8 @@ test('a firewall holds a raid at the door, wears down, and grows back; a sentry assert.equal(r.at, 'sw1', 'held at the door'); assert.ok(Math.abs(fw.wall! - (WALL.max - bite * 3)) < 0.2, 'the firewall wears down'); assert.match(raidHeld(S, r)!, /^Firewall at Bayside Office · holding \d+%$/); - assert.match(raidPlate(S, r), /HOLDING/); + assert.match(raidPlate(S, r), /HELD$/); + assert.match(raidSays(S, r), /\nFirewall at Bayside Office: holding \d+%\.$/); pass(S, WALL.max / bite - 3 + 0.1); assert.equal(r.at, 'rowsw', 'then it breaks through'); assert.ok(fw.wall! < 1, 'down, and only now growing back'); diff --git a/tests/vaults.test.ts b/tests/vaults.test.ts index b52d8e6c..12c6dd4a 100644 --- a/tests/vaults.test.ts +++ b/tests/vaults.test.ts @@ -74,7 +74,7 @@ test('the six vaults are real machines, one to a site, each with a window and a const hours = (v.to - v.from + 24) % 24; assert.ok(hours >= 2 && hours <= 4, `${id}: a few hours of each day`); assert.equal(vaultLook(S, m), 'locked'); - assert.match(vaultLabel(S, m)!, new RegExp(`^VAULT · WINDOW ${v.from}:00`)); + assert.match(vaultLabel(S, m)!, new RegExp(`^A locked vault. Its window is ${v.from}:00`)); } assert.ok(!isVault('home') && !isVault('constructor') && vaultLook(S, S.byId.home) === null); assert.equal(new Set(VAULT_LIST.map(id => `${VAULTS[id].from}-${VAULTS[id].to}-${zoneOf(S, siteOf(S, S.byId[id]))}`)).size, 6, 'each has a window of its own');