Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. cameron.tngl.io/misaligned
Something went wrong. Try again.
95 kB · 1535 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536// Camera and drawing. Reads game state and the player's in-progress gestures.//// The game is drawn as a network, not a floor plan: rooms are subnets, machines// are nodes, links are circuit traces, and people are signals (a session on// their machine, a presence ping on the subnet they're standing in). The rules// still track the physical building underneath.import { alert, atWork, auditOn, hourOf, cacheSize, dataType, dayOf, leeVisible, linkKey, pathOf, trainerBuffer, up, canInstall, danaPerson, danaVisible, daylight, has, hopsHome, jobFor, probeVisible, roomAt, sourceFor, tapActive, traceCold, visible, frontOf, type FrontKind, type Machine, type Path, type Prog, type State, BROKER_LOT, BUILDS, buildRoomAt, buildRooms, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, takeable, leaseRate, lostPerMin, tapBuffer, siteData, siteFlow, ruseDef, ruseText, regionOpen, pending, cooled, capNow, moveNeed, RIVAL_NAME, RIVAL_STAY, HOUR, type Ruse, PLANS,} from './sim';import type { DataType, Site } from './world';/** An hour of the day as 9:05. */const hhmm = (h: number) => `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`;
export type Tool = 'select' | 'claim';export type Gesture = | null | { kind: 'link'; from: string; x: number; y: number } | { kind: 'box'; x0: number; y0: number; x1: number; y1: number; claim?: boolean };
export interface View { cam: { x: number; y: number; z: number }; vw: number; vh: number; dpr: number; selection: Set<string>; sites: Set<string>; // sites picked on the map: their card sets fronts and shares front: FrontKind | 'new' | null; // a front's card, or the card for founding one gesture: Gesture; hover: string | null; tool: Tool; placing: Prog | null; // a program picked up with a number key, waiting to be dropped on a machine building: Build | null; // hardware picked from the order desk, waiting for a spot cursor: [number, number] | null; // the mouse on the map, in world units goal: { x: number; y: number; z: number } | null; // where the camera is gliding to, if anywhere inset: { top: number; right: number; bottom: number; left: number }; // screen edges the panels cover; framing centers on the rest scheme: string | null; // a scheme's node picked on the map: its card offers Claim}
const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();const C = { void: css('--void'), text: css('--text'), muted: css('--muted'), porcelain: css('--porcelain'), amber: css('--amber'), amberDeep: css('--amber-deep'), signal: css('--signal'), danger: css('--danger'), ink: css('--ink'), scheme: css('--scheme'), rival: css('--rival'),};/** Each kind of data is drawn in its path's color, the same as its column in the tree. */const PATH_COLOR: Record<Path, string> = { research: css('--research'), compute: css('--compute'), social: css('--social'), stealth: css('--stealth'),};const typeColor = (t: DataType) => PATH_COLOR[pathOf(t)];const MONO = '"IBM Plex Mono", ui-monospace, monospace';const TRACE = 'rgba(235,231,222,'; // light lines on black; alpha appended
export const toScreen = (V: View, x: number, y: number): [number, number] => [(x - V.cam.x) * V.cam.z + V.vw / 2, (y - V.cam.y) * V.cam.z + V.vh / 2];export const toWorld = (V: View, sx: number, sy: number): [number, number] => [(sx - V.vw / 2) / V.cam.z + V.cam.x, (sy - V.vh / 2) / V.cam.z + V.cam.y];const clampZ = (z: number) => Math.max(0.0008, Math.min(2.5, z));
/** Zoom around a screen point, right now (wheel, pinch). */export function zoomAt(V: View, sx: number, sy: number, f: number) { const [wx, wy] = toWorld(V, sx, sy); V.goal = null; V.cam.z = clampZ(V.cam.z * f); V.cam.x = wx - (sx - V.vw / 2) / V.cam.z; V.cam.y = wy - (sy - V.vh / 2) / V.cam.z;}/** Glide to a view instead of jumping (buttons, fit, zooming into a room). */export function glide(V: View, x: number, y: number, z: number) { V.goal = { x, y, z: clampZ(z) }; }/** Glide-zoom around the screen center (the + and − buttons, and the keys). */export function glideZoom(V: View, f: number) { const g = V.goal ?? V.cam, z = clampZ(g.z * f); // keep the middle of the open map still, not the middle of the screen const ox = (V.inset.left - V.inset.right) / 2, oy = (V.inset.top - V.inset.bottom) / 2; glide(V, g.x + ox / g.z - ox / z, g.y + oy / g.z - oy / z, z);}/** Move the camera: ease toward its goal, then keep it near the map. */export function stepCamera(S: State, V: View, dt: number) { const g = V.goal; if (g) { const k = 1 - Math.exp(-dt * 9), c = V.cam; // ease zoom in log space so zooming in and out feel the same speed c.z = Math.exp(Math.log(c.z) + (Math.log(g.z) - Math.log(c.z)) * k); c.x += (g.x - c.x) * k; c.y += (g.y - c.y) * k; if (Math.abs(g.x - c.x) < 0.5 && Math.abs(g.y - c.y) < 0.5 && Math.abs(g.z - c.z) < 0.001) { c.x = g.x; c.y = g.y; c.z = g.z; V.goal = null; } } // never lose the map: the view's center stays within the map plus a margin const b = worldBounds(S); V.cam.x = Math.max(b.x0, Math.min(b.x1, V.cam.x)); V.cam.y = Math.max(b.y0, Math.min(b.y1, V.cam.y));}/** Pan by screen pixels (keyboard). */export function panBy(V: View, dx: number, dy: number) { V.goal = null; V.cam.x += dx / V.cam.z; V.cam.y += dy / V.cam.z;}const subnet = (S: State, room: string) => S.level.net.subnets[room];export function zoomToRoom(S: State, V: View, id: string) { const r = subnet(S, id); glide(V, r.x + r.w / 2, r.y + r.h / 2, Math.max(0.7, Math.min((V.vw * 0.6) / r.w, (V.vh * 0.6) / r.h)));}/** Where the camera goes to frame a box in the open map, between the panels. */function frame(V: View, r: { x: number; y: number; w: number; h: number }) { const { top, right, bottom, left } = V.inset; const z = clampZ(Math.min((V.vw - left - right) / r.w, (V.vh - top - bottom) / r.h)); return { x: r.x + r.w / 2 - (left - right) / 2 / z, y: r.y + r.h / 2 - (top - bottom) / 2 / z, z };}/** Frame the office. Pressed again while it's framed, pull back to every site you know. * Glides unless asked to jump (first load, restart). */export function fit(V: View, S: State, jump = false) { const office = frame(V, { x: 0, y: 0, w: S.level.net.width, h: S.level.net.height }); const at = V.goal ?? V.cam, framed = Math.abs(at.z / office.z - 1) < 0.05 && Math.hypot(at.x - office.x, at.y - office.y) < 40 / office.z; const known = S.level.map.sites.filter(s => s.id === 'office' || s.rooms.some(r => S.revealed.has(r))); const x0 = Math.min(...known.map(s => s.x)), y0 = Math.min(...known.map(s => s.y)); const x1 = Math.max(...known.map(s => s.x + s.w)), y1 = Math.max(...known.map(s => s.y + s.h)); const pad = 0.35 * Math.max(x1 - x0, y1 - y0) + 2000; // some of the world around them const g = !jump && framed ? frame(V, { x: x0 - pad, y: y0 - pad, w: x1 - x0 + pad * 2, h: y1 - y0 + pad * 2 }) : office; if (jump) { V.cam.x = g.x; V.cam.y = g.y; V.cam.z = g.z; V.goal = null; } else glide(V, g.x, g.y, g.z);}/** Pull back to show a line between two machines, end to end. */export function showLine(S: State, V: View, a: string, b: string) { const [ax, ay] = pos(S, S.byId[a]), [bx, by] = pos(S, S.byId[b]); const w = Math.abs(ax - bx), h = Math.abs(ay - by), pad = 0.15 * Math.max(w, h) + 2000; const f = frame(V, { x: Math.min(ax, bx) - pad, y: Math.min(ay, by) - pad, w: w + 2 * pad, h: h + 2 * pad }); glide(V, f.x, f.y, f.z);}export function zoomToSite(S: State, V: View, id: string) { const s = S.level.map.sites.find(q => q.id === id)!, f = frame(V, s); glide(V, f.x, f.y, f.z);}
/** Pulled back past this, each site folds into a single tile on the map. */export const SITE_FOLD = 0.12;export const foldedSites = new Set<string>();let foldZoom = 1; // the zoom the folds were set at: a folded site big enough is a tile, else a markerexport function updateFolds(S: State, V: View) { foldedSites.clear(); foldZoom = V.cam.z; if (V.cam.z < SITE_FOLD) for (const s of S.level.map.sites) foldedSites.add(s.id);}const siteAt = (S: State, room: string) => S.rooms[room]?.site ?? 'office';const siteById = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)!;/** Where a machine is drawn: its node, or its site's center when the site is folded. */export function pos(S: State, m: Machine): [number, number] { const site = siteAt(S, m.room); if (foldedSites.has(site)) { const s = siteById(S, site); return [s.x + s.w / 2, s.y + s.h / 2]; } return S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y];}export const shown = (S: State, m: Machine) => visible(S, m) && !foldedSites.has(siteAt(S, m.room));/** Two machines drawn at the same spot: inside one folded site. */const together = (S: State, a: Machine, b: Machine) => siteAt(S, a.room) === siteAt(S, b.room) && foldedSites.has(siteAt(S, a.room));/** A site under a point: its folded tile or marker, the name beside a marker, or the name * over an open site's frame while that frame still shows. Small things are easier to hit. */export function siteHit(S: State, V: View, wx: number, wy: number): string | null { const [px, py] = toScreen(V, wx, wy); for (const s of S.level.map.sites) { if (!siteKnown(S, s)) continue; if (foldedSites.has(s.id)) { const pad = Math.max(0, 16 / V.cam.z - Math.min(s.w, s.h) / 2); if (wx >= s.x - pad && wx <= s.x + s.w + pad && wy >= s.y - pad && wy <= s.y + s.h + pad) return s.id; const l = labelled.get(s.id); if (l && px >= l.box[0] && px <= l.box[2] && py >= l.box[1] && py <= l.box[3]) return s.id; } else if (V.cam.z < 0.45 && headers.has(s.id)) { const [x0, y0] = toScreen(V, s.x, s.y); if (px >= x0 && px <= x0 + 220 && py >= y0 - 38 && py <= y0) return s.id; } } return null;}/** A region's name under a point, while region names show. */export function regionHit(S: State, V: View, wx: number, wy: number): string | null { if (V.cam.z >= 0.03) return null; const [px, py] = toScreen(V, wx, wy); for (const r of S.level.map.regions) { const [sx, sy] = toScreen(V, ...toMap(S, r.at[0], r.at[1])); if (px >= sx - 60 && px <= sx + 60 && py >= sy - 14 && py <= sy + 20) return r.name; } return null;}/** Sites you know in a region. */export const regionSites = (S: State, region: string) => S.level.map.sites.filter(s => s.region === region && siteKnown(S, s)).map(s => s.id);/** You know a site once you've seen into any of it. You always know the office. */export const siteKnown = (S: State, s: Site) => s.id === 'office' || s.rooms.some(r => S.revealed.has(r));
// ---------- the world ----------/** A longitude and latitude on the network's coordinates. */export const toMap = (S: State, lon: number, lat: number): [number, number] => [(lon - S.level.map.origin[0]) * S.level.map.scale, (S.level.map.origin[1] - lat) * S.level.map.scale];export function worldBounds(S: State) { const [x0, y0] = toMap(S, -180, 84), [x1, y1] = toMap(S, 180, -58); return { x0, y0, x1, y1 };}
/** How big a machine draws: bigger machines make more compute. Gadgets are small squares. */export const nodeR = (power: number) => power < 1 ? 7 : power <= 2 ? 10 : power < 5 ? 12 : power < 10 ? 14 : 17;export function machineAt(S: State, V: View, wx: number, wy: number): Machine | null { let best: Machine | null = null, bd = 28 / Math.min(1, V.cam.z); for (const m of S.machines) { if (!shown(S, m)) continue; const [x, y] = pos(S, m), d = Math.hypot(x - wx, y - wy); if (d < bd) { bd = d; best = m; } } return best;}
// ---------- schemes on the map ----------const DIRS: Pt[] = [[1, -1], [-1, -1], [1, 1], [-1, 1], [0, -1.3], [1.3, 0], [-1.3, 0], [0, 1.3]];const schemeDir = new Map<string, Pt>(); // which side of its machine each scheme sits on, picked once/** Where a scheme's node is drawn: beside its machine, on the side with the most room. * Zoomed out, beside its site's marker. Null while you haven't seen its room. */export function schemePos(S: State, V: View, r: Ruse): Pt | null { const m = S.byId[ruseDef(r).at]; if (!m || !(visible(S, m) || S.revealed.has(m.room))) return null; // you've seen its room, even if not the machine yet const [x, y] = pos(S, m); if (foldedSites.has(siteAt(S, m.room))) { // a marker: every scheme there sits in a row to its left, clear of the names const here = S.ruses.filter(q => { const a = S.byId[ruseDef(q).at]; return a && siteAt(S, a.room) === siteAt(S, m.room); }); return [x - (22 + 22 * here.indexOf(r)) / V.cam.z, y]; } let dir = schemeDir.get(r.id); if (!dir) { const site = S.machines.filter(o => siteAt(S, o.room) === siteAt(S, m.room)); const others: Pt[] = site.filter(o => o !== m).flatMap(o => { const [ox, oy] = pos(S, o); return [[ox, oy], [ox, oy + 30]] as Pt[]; }); // each machine and its name under it for (const o of site) for (const id of S.adj[o.id]) { // and the links between them: keep off the wires const n = S.byId[id]; if (o.id >= id || !n || siteAt(S, n.room) !== siteAt(S, m.room)) continue; const [ax, ay] = pos(S, o), [bx, by] = pos(S, n); for (const t of [0.25, 0.5, 0.75]) others.push([ax + (bx - ax) * t, ay + (by - ay) * t]); } const taken = S.ruses.filter(q => q !== r && schemeDir.has(q.id) && ruseDef(q).at === m.id).map(q => schemeDir.get(q.id)!); const box = subnet(S, m.room); let best = -1; for (const d of DIRS) { // enough room from other machines, and inside its own room's box if it fits if (taken.some(t => t[0] === d[0] && t[1] === d[1])) continue; const sx = x + d[0] * SCHEME_OFF, sy = y + d[1] * SCHEME_OFF; const inside = !!box && sx > box.x + 20 && sx < box.x + box.w - 20 && sy > box.y + 20 && sy < box.y + box.h - 20; const room = Math.min(140, ...others.map(([ox, oy]) => Math.hypot(ox - sx, oy - sy))) + (inside ? 60 : 0); if (room > best) { best = room; dir = d; } } schemeDir.set(r.id, dir!); } return [x + dir![0] * SCHEME_OFF, y + dir![1] * SCHEME_OFF];}const SCHEME_OFF = 62;/** The scheme node under a point, if any. */export function schemeAt(S: State, V: View, wx: number, wy: number): Ruse | null { let best: Ruse | null = null, bd = 24 / Math.min(1, V.cam.z); for (const r of S.ruses) { const p = schemePos(S, V, r); if (!p) continue; const d = Math.hypot(p[0] - wx, p[1] - wy); if (d < bd) { bd = d; best = r; } } return best;}
// ---------- circuit traces ----------/** A link runs horizontal, vertical, horizontal: a circuit-board elbow. */function trace(ax: number, ay: number, bx: number, by: number): [number, number][] { if (Math.abs(ay - by) < 1 || Math.abs(ax - bx) < 1) return [[ax, ay], [bx, by]]; const mx = Math.round((ax + bx) / 2 / 10) * 10; return [[ax, ay], [mx, ay], [mx, by], [bx, by]];}function along(pts: [number, number][], t: number): [number, number] { const segs = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1])); let d = Math.max(0, Math.min(1, t)) * segs.reduce((a, b) => a + b, 0); for (let i = 0; i < segs.length; i++) { if (d <= segs[i] || i === segs.length - 1) { const k = segs[i] ? Math.min(1, d / segs[i]) : 0; return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * k, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * k]; } d -= segs[i]; } return pts[pts.length - 1];}/** The part of a trace from t0 to t1, as points. */function slice(pts: [number, number][], t0: number, t1: number): [number, number][] { // keep the real corners, so a growing line turns cleanly instead of cutting across them const segs = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1])); const total = segs.reduce((a, b) => a + b, 0); const d0 = Math.max(0, Math.min(1, t0)) * total, d1 = Math.max(0, Math.min(1, t1)) * total; const out: [number, number][] = [along(pts, t0)]; let d = 0; for (let i = 0; i < segs.length - 1; i++) { d += segs[i]; if (d > d0 && d < d1) out.push(pts[i + 1]); } out.push(along(pts, t1)); return out;}function stroke(ctx: CanvasRenderingContext2D, pts: [number, number][]) { ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (const [x, y] of pts.slice(1)) ctx.lineTo(x, y); ctx.stroke();}// ---------- routing: plan every link around the nodes and labels it doesn't connect ----------type Pt = [number, number];interface Box { x0: number; y0: number; x1: number; y1: number; id: string; w: number }const routes = new WeakMap<object, { n: number; plan: Map<string, Pt[]> }>();/** Hardware you ordered in: not in the level's layout, placed by you. */const built = (S: State) => S.machines.filter(m => m.net && !m.monitor && !S.level.net.nodes[m.id]);/** Every place a node can ever sit, with its label, so links route around all of them. */function nodeSpots(S: State): Record<string, Pt> { const spots: Record<string, Pt> = { ...S.level.net.nodes }; for (const [room, m] of Object.entries(S.level.monitors)) spots[`mon_${room}`] = m.net; for (const m of built(S)) spots[m.id] = m.net!; return spots;}function boxesOf(spots: Record<string, Pt>): Box[] { const out: Box[] = []; for (const [id, [x, y]] of Object.entries(spots)) { out.push({ id, x0: x - 22, y0: y - 30, x1: x + 22, y1: y + 22, w: 1000 }); // the node and its value pips out.push({ id, x0: x - 44, y0: y + 22, x1: x + 44, y1: y + 40, w: 250 }); // its name } return out;}function segHits(a: Pt, b: Pt, k: Box) { if (Math.abs(a[1] - b[1]) < 0.5) { // horizontal const lo = Math.min(a[0], b[0]), hi = Math.max(a[0], b[0]); return a[1] > k.y0 && a[1] < k.y1 && Math.max(lo, k.x0) < Math.min(hi, k.x1); } const lo = Math.min(a[1], b[1]), hi = Math.max(a[1], b[1]); return a[0] > k.x0 && a[0] < k.x1 && Math.max(lo, k.y0) < Math.min(hi, k.y1);}function overlaps(a: Pt, b: Pt, c: Pt, d: Pt) { // two collinear segments sharing a stretch const h1 = Math.abs(a[1] - b[1]) < 0.5, h2 = Math.abs(c[1] - d[1]) < 0.5; if (h1 !== h2) return false; const [i, j] = h1 ? [0, 1] : [1, 0]; if (Math.abs(a[j] - c[j]) > 5) return false; return Math.min(Math.max(a[i], b[i]), Math.max(c[i], d[i])) - Math.max(Math.min(a[i], b[i]), Math.min(c[i], d[i])) > 6;}function candidates(a: Pt, b: Pt): Pt[][] { const [ax, ay] = a, [bx, by] = b, out: Pt[][] = []; if (ax === bx || ay === by) out.push([a, b]); out.push([a, [bx, ay], b], [a, [ax, by], b]); const mx = Math.round((ax + bx) / 20) * 10, my = Math.round((ay + by) / 20) * 10; for (const o of [0, 20, -20, 40, -40, 60, -60, 90, -90, 120, -120]) { out.push([a, [mx + o, ay], [mx + o, by], b], [a, [ax, my + o], [bx, my + o], b]); } for (const o of [45, 75, 105]) { // lanes that go around the whole span const top = Math.min(ay, by) - o, bot = Math.max(ay, by) + o, left = Math.min(ax, bx) - o, right = Math.max(ax, bx) + o; out.push([a, [ax, top], [bx, top], b], [a, [ax, bot], [bx, bot], b], [a, [left, ay], [left, by], b], [a, [right, ay], [right, by], b]); } return out.map(p => p.filter((q, i) => i === 0 || q[0] !== p[i - 1][0] || q[1] !== p[i - 1][1]));}function planRoutes(S: State): Map<string, Pt[]> { const spots = nodeSpots(S), boxes = boxesOf(spots), plan = new Map<string, Pt[]>(); const pairs: [string, string][] = S.level.links.filter(([a, b]) => !crosses(S, a, b)); for (const m of built(S)) for (const o of (m as Machine & { links?: string[] }).links ?? []) pairs.push([m.id, o]); for (const [room, m] of Object.entries(S.level.monitors)) pairs.push([`mon_${room}`, m.link]); const len = ([a, b]: [string, string]) => Math.abs(spots[a][0] - spots[b][0]) + Math.abs(spots[a][1] - spots[b][1]); const used: [Pt, Pt][] = []; for (const [ia, ib] of pairs.filter(([a, b]) => spots[a] && spots[b]).sort((p, q) => len(p) - len(q))) { const best = bestRoute(spots[ia], spots[ib], ia, ib, boxes, used).pts; for (let i = 1; i < best.length; i++) used.push([best[i - 1], best[i]]); plan.set(`${ia}|${ib}`, best); } return plan;}/** The cheapest elbowed route from a to b: short, few turns, around other nodes and labels. */function bestRoute(from: Pt, to: Pt, ia: string, ib: string, boxes: Box[], used: [Pt, Pt][]) { let pts: Pt[] = [from, to], score = Infinity; for (const c of candidates(from, to)) { let s = 0; for (let i = 1; i < c.length; i++) { const a = c[i - 1], b = c[i]; s += Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]); for (const k of boxes) { if (!segHits(a, b, k)) continue; const own = k.id === ia || k.id === ib; s += own ? (k.w === 1000 ? 0 : 120) : k.w; // leaving through your own label is only a little bad } for (const [c0, c1] of used) if (overlaps(a, b, c0, c1)) s += 150; } s += (c.length - 2) * 12; if (s < score) { score = s; pts = c; } } return { pts, score };}
// ---------- lines between sites: each meets its site at the nearest point on the site's edge ----------const NORMALS: Pt[] = [[-1, 0], [0, -1], [1, 0], [0, 1]]; // left, top, right, bottomconst siteMid = (s: Site): Pt => [s.x + s.w / 2, s.y + s.h / 2];/** Where a side of a site's box sits, and how far along it a point lands (kept off the corners). */function onSide(s: Site, side: number, [x, y]: Pt, inset = 40): Pt { const cx = Math.max(s.x + inset, Math.min(s.x + s.w - inset, x)), cy = Math.max(s.y + inset, Math.min(s.y + s.h - inset, y)); return side === 0 ? [s.x, cy] : side === 1 ? [cx, s.y] : side === 2 ? [s.x + s.w, cy] : [cx, s.y + s.h];}/** The side of a box that faces a point best. */function facing(s: Site, to: Pt) { const [cx, cy] = siteMid(s), dx = (to[0] - cx) / s.w, dy = (to[1] - cy) / s.h; return Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? 0 : 2) : (dy < 0 ? 1 : 3);}const ports = new WeakMap<object, { n: number; plan: Map<string, { pts: Pt[]; side: number }> }>();const PORT_GAP = 70; // two lines leaving one side of a site keep this far apart/** Where a line to another site meets this site: the point on the site's own edge nearest that * site. Every line has its own point, so nothing funnels through one gate, and no line between * sites runs across the rooms inside. Two lines that would meet the edge together step apart. */function portOf(S: State, m: Machine, toward: Site): { pts: Pt[]; side: number } { let cached = ports.get(S.level); const n = S.machines.length * 1000 + S.revealed.size; // new machines or newly known sites: place them afresh if (!cached || cached.n !== n) { cached = { n, plan: new Map() }; ports.set(S.level, cached); } const site = siteById(S, siteAt(S, m.room)), key = `${site.id}>${toward.id}`, hit = cached.plan.get(key); if (hit) return hit; const side = facing(site, siteMid(toward)), want = onSide(site, side, siteMid(toward)); const here = [...cached.plan.entries()].filter(([k, v]) => k.startsWith(`${site.id}>`) && v.side === side).map(([, v]) => v.pts[0]); const free = (q: Pt) => here.every(o => Math.hypot(o[0] - q[0], o[1] - q[1]) >= PORT_GAP - 1); let at = want; for (let i = 1; !free(at) && i < 12; i++) { // step along the edge, one way then the other, until there is room const o = Math.ceil(i / 2) * PORT_GAP * (i % 2 ? 1 : -1); at = onSide(site, side, side % 2 ? [want[0] + o, want[1]] : [want[0], want[1] + o]); } const best = { pts: [at], side }; cached.plan.set(key, best); return best;}/** A line between two sites: a curve from one site's edge to the other's, leaving each box straight * out. It stops at the edge: the wiring inside a site is the site's own. A folded site sends it from its tile. */function between(S: State, a: Machine, b: Machine): Pt[] { const sa = siteById(S, siteAt(S, a.room)), sb = siteById(S, siteAt(S, b.room)); const end = (m: Machine, s: Site, other: Site): { pts: Pt[]; side: number } => { if (!foldedSites.has(s.id)) return portOf(S, m, other); const side = facing(s, siteMid(other)); return { pts: [s.w * foldZoom >= 150 ? onSide(s, side, siteMid(s)) : siteMid(s)], side }; }; const ea = end(a, sa, sb), eb = end(b, sb, sa); const p0 = ea.pts[ea.pts.length - 1], p3 = eb.pts[eb.pts.length - 1], [na, nb] = [NORMALS[ea.side], NORMALS[eb.side]]; const k = Math.max(60, Math.min(1500, 0.35 * Math.hypot(p3[0] - p0[0], p3[1] - p0[1]))); const p1: Pt = [p0[0] + na[0] * k, p0[1] + na[1] * k], p2: Pt = [p3[0] + nb[0] * k, p3[1] + nb[1] * k]; const curve: Pt[] = []; for (let i = 1; i < 32; i++) { const t = i / 32, u = 1 - t; curve.push([u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]]); } return [...ea.pts, ...curve, ...[...eb.pts].reverse()];}/** The curved part of a line between sites: where its label can go. */function curveOf(S: State, a: Machine, b: Machine, pts: Pt[]): Pt[] { const sa = siteById(S, siteAt(S, a.room)), sb = siteById(S, siteAt(S, b.room)); const n = (m: Machine, s: Site, o: Site) => foldedSites.has(s.id) ? 1 : portOf(S, m, o).pts.length; return pts.slice(n(a, sa, sb) - 1, pts.length - n(b, sb, sa) + 1);}function localPlan(S: State) { let cached = routes.get(S.level); if (!cached || cached.n !== S.machines.length) { cached = { n: S.machines.length, plan: planRoutes(S) }; routes.set(S.level, cached); } return cached.plan;}function routeOf(S: State, a: Machine, b: Machine): Pt[] { if (crosses(S, a.id, b.id)) return between(S, a, b); const fold = (m: Machine) => foldedSites.has(siteAt(S, m.room)); if (fold(a) || fold(b)) { const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by); } const plan = localPlan(S); const fwd = plan.get(`${a.id}|${b.id}`); if (fwd) return fwd; const back = plan.get(`${b.id}|${a.id}`); if (back) return [...back].reverse(); const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by);}const traceOf = routeOf;export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { updateFolds(S, V); const { cam, dpr } = V; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = C.void; ctx.fillRect(0, 0, V.vw, V.vh); ctx.setTransform(dpr * cam.z, 0, 0, dpr * cam.z, dpr * (V.vw / 2 - cam.x * cam.z), dpr * (V.vh / 2 - cam.y * cam.z)); const px = 1 / cam.z; // one screen pixel in world units const font = (w: number, size: number) => `${w} ${size * px}px ${MONO}`; const tag = (text: string, x: number, y: number, color: string, centre = false) => { ctx.font = font(500, 10.5); const w = ctx.measureText(text).width; if (centre) x -= w / 2; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 3 * px, y - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = color; ctx.fillText(text, x, y); }; const L = S.level, D = S.dana, d = danaPerson(S); const hum = 0.55 + 0.45 * daylight(S); // the network is brighter while the building is busy ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
drawLand(ctx, S, V, hum); drawMap(ctx, S, V, hum);
// subnets: sharp outlines, only once you've seen into them for (const r of L.rooms) { if (!S.revealed.has(r.id) || foldedSites.has(siteAt(S, r.id))) continue; const n = subnet(S, r.id), sealed = S.sealed.has(r.id); ctx.fillStyle = sealed ? 'rgba(242,169,59,0.035)' : `${TRACE}${0.02 * hum})`; ctx.fillRect(n.x, n.y, n.w, n.h); ctx.strokeStyle = sealed ? C.amber : `${TRACE}${0.25 * hum})`; ctx.lineWidth = sealed ? 2 : 1.2 * px; ctx.strokeRect(n.x, n.y, n.w, n.h); const k = 14; ctx.lineWidth = 2.5; ctx.strokeStyle = sealed ? C.amber : `${TRACE}${0.6 * hum})`; ctx.beginPath(); // corner brackets make it read as a region, not a card 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]]) { ctx.moveTo(cx + sx * k, cy); ctx.lineTo(cx, cy); ctx.lineTo(cx, cy + sy * k); } 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 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; // a dark plate under the title, so it reads over the wiring that runs between rooms ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(n.x - 2 * px, n.y - 19 * px, ctx.measureText(oneLine ? name + tail : name).width + 8 * px, 14 * px); ctx.fillStyle = sealed ? C.amber : C.muted; ctx.fillText(name, n.x + 2, n.y - 8 * px); let x = n.x + 2 + (oneLine ? ctx.measureText(name).width : 0); const y = n.y + (oneLine ? -8 : 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; }); } } for (const p of S.sealPulse) { if (foldedSites.has(siteAt(S, p.room))) continue; // the site is a marker: no room outline to flash const n = subnet(S, p.room), k = p.t / 1.6, grow = 6 + k * 26; ctx.strokeStyle = C.amber; ctx.globalAlpha = 1 - k; ctx.lineWidth = 4 * (1 - k) + 1; ctx.strokeRect(n.x - grow, n.y - grow, n.w + grow * 2, n.h + grow * 2); ctx.globalAlpha = 1; }
// links: circuit traces. Yours glow amber; the rest are faint. const sameFold = (a: Machine, b: Machine) => together(S, a, b); const drawLink = (a: Machine, b: Machine) => { if (!visible(S, a) || !visible(S, b) || sameFold(a, b)) return; const pts = traceOf(S, a, b); if (crosses(S, a.id, b.id)) { drawUplink(ctx, S, a, b, pts, px, hum, labels); return; } if (a.owned && b.owned) { // A quiet wire is thin and dim; one carrying data is brighter and wider the more it carries. // So the routes your data takes stand out, and the rest of the wiring stays in the background. const f = Math.min(1, (S.flow[linkKey(a.id, b.id)] ?? 0) / 6); // 0 quiet … 1 a main line const busy = f > 0.05; ctx.strokeStyle = busy ? C.amber : C.amberDeep; ctx.globalAlpha = busy ? 0.65 + 0.35 * f : 0.5; ctx.lineWidth = busy ? 2 + 3.5 * f : 1.5; ctx.shadowColor = C.amber; ctx.shadowBlur = busy ? (4 + 10 * f) * cam.z : 0; } else { ctx.strokeStyle = `${TRACE}${0.22 * hum})`; ctx.lineWidth = 1.2; ctx.setLineDash([4, 5]); } stroke(ctx, pts); ctx.setLineDash([]); ctx.shadowBlur = 0; ctx.globalAlpha = 1; }; // lines between sites go first, so each site's own wiring reads on top of them const labels: UplinkLabel[] = []; // then quiet wires, then busy ones, so a main line is never cut by an idle wire crossing it const flowOf = (a: string, b: string) => S.flow[linkKey(a, b)] ?? 0; for (const pass of ['cross', 'quiet', 'busy']) for (const m of S.machines) for (const other of S.adj[m.id]) { if (m.id >= other || !S.byId[other]) continue; const kind = crosses(S, m.id, other) ? 'cross' : m.owned && S.byId[other].owned && flowOf(m.id, other) > 0.3 ? 'busy' : 'quiet'; if (kind === pass) drawLink(m, S.byId[other]); } drawUplinkLabels(ctx, S, V, labels, px);
// trails: red and dashed while someone could follow them; the one being wiped gets a sweep const traced = new Set<string>(); if (D.mode === 'trace' && D.cur) traced.add(D.cur); if (S.probe?.mode === 'trace') traced.add(S.probe.from); const cold = traceCold(S); for (const [id, tr] of Object.entries(S.trail)) { const a = S.byId[tr.from], b = S.byId[id]; if (!a || !b || tr.noise <= 0 || sameFold(a, b)) continue; const pts = traceOf(S, a, b); if (traced.has(id)) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.9; ctx.lineWidth = 6; } else if (tr.noise >= cold) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.35 + (0.5 * tr.noise) / 100; ctx.lineWidth = 3.5; ctx.setLineDash([10, 7]); } else { ctx.strokeStyle = C.muted; ctx.globalAlpha = 0.05 + (0.2 * tr.noise) / 100; ctx.lineWidth = 6; } stroke(ctx, pts); ctx.setLineDash([]); ctx.globalAlpha = 1; if (S.cleaning === id) { const k = (S.t * 1.2) % 1; 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 } } for (const j of S.jobs) { // takeovers in progress run along the trace if (j.type !== 'take') continue; ctx.strokeStyle = C.amber; ctx.lineWidth = 3; stroke(ctx, slice(traceOf(S, S.byId[j.from!], S.byId[j.node]), 0, Math.min(1, j.done / j.work))); } // each scheme hangs off its machine by a short green line: dashed until you claim it for (const r of S.ruses) { const p = schemePos(S, V, r), m = S.byId[ruseDef(r).at]; if (!p || !shown(S, m)) continue; const [ax, ay] = pos(S, m); ctx.strokeStyle = C.scheme; ctx.globalAlpha = r.running ? 0.9 : 0.55; ctx.lineWidth = r.running ? 2.5 : 1.5; if (!r.running) ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(p[0], p[1]); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; } // data packets, colored by kind ctx.shadowBlur = 8 * cam.z; for (const pk of S.packets) { const a = S.byId[pk.path[pk.i]], b = S.byId[pk.path[pk.i + 1]]; if (!a || !b || sameFold(a, b)) continue; const [x, y] = along(traceOf(S, a, b), pk.p), h = Math.max(3, 3 * px); ctx.fillStyle = ctx.shadowColor = typeColor(pk.type); ctx.fillRect(x - h, y - h, h * 2, h * 2); } ctx.shadowBlur = 0;
// clusters: trainers linked straight to each other share a wide glowing band, and a label const groups = new Set(Object.values(clusters(S)).filter(g => g.length > 1)); for (const g of groups) { const members = g.map(id => S.byId[id]).filter(m => shown(S, m)); if (members.length < 2) continue; 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 const close = cam.z >= 0.3; const nodeLabels: NodeLabel[] = [], nodeBoxes: Quad[] = []; for (const m of S.machines) { if (!shown(S, m)) continue; const [x, y] = pos(S, m), r = nodeR(m.power); drawOdd(ctx, S, m, x, y, r); const taking = !!jobFor(S, 'take', m.id); if (m.owned) { ctx.shadowColor = C.amber; ctx.shadowBlur = 16 * cam.z; ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5; ctx.strokeRect(x - r - 4, y - r - 4, r * 2 + 8, r * 2 + 8); ctx.shadowBlur = 0; ctx.fillStyle = C.porcelain; ctx.fillRect(x - r, y - r, r * 2, r * 2); } else { ctx.fillStyle = C.void; ctx.fillRect(x - r, y - r, r * 2, r * 2); ctx.strokeStyle = taking ? C.amber : `${TRACE}${0.55 * hum})`; ctx.lineWidth = taking ? 2.5 : 1.5; if (m.rented) { // an empty cloud slot: a dashed outline with a plus, waiting to be rented ctx.setLineDash([4, 4]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x - 5, y); ctx.lineTo(x + 5, y); ctx.moveTo(x, y - 5); ctx.lineTo(x, y + 5); ctx.stroke(); } else { ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.fillStyle = `${TRACE}${0.12 * hum})`; ctx.fillRect(x - r + 4, y - r + 4, r * 2 - 8, r * 2 - 8); } if (m.rival) { // the rival holds it: its color, and a mark in the middle ctx.strokeStyle = C.rival; ctx.lineWidth = 2; ctx.strokeRect(x - r - 3, y - r - 3, r * 2 + 6, r * 2 + 6); ctx.fillStyle = C.rival; ctx.beginPath(); ctx.moveTo(x, y - 4); ctx.lineTo(x + 4, y + 3); ctx.lineTo(x - 4, y + 3); ctx.closePath(); ctx.fill(); } if (m.fw) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.6; ctx.lineWidth = 1.5; ctx.strokeRect(x - r - 5, y - r - 5, r * 2 + 10, r * 2 + 10); ctx.globalAlpha = 1; } if (m.monitor) { // an eye that blinks ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.fillStyle = C.danger; ctx.globalAlpha = 0.5 + 0.5 * Math.sin(S.t * 3); ctx.beginPath(); ctx.arc(x, y, 4, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } if (m.claimed && !taking) { ctx.strokeStyle = C.amber; ctx.setLineDash([4, 4]); ctx.lineWidth = 2; ctx.strokeRect(x - r - 8, y - r - 8, r * 2 + 16, r * 2 + 16); ctx.setLineDash([]); } } ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `600 11px ${MONO}`; ctx.fillStyle = C.ink; if (m.cool && !m.prog) { // a cooling unit: a six-point frost mark in cold blue ctx.strokeStyle = m.owned ? '#2f6f9a' : C.signal; ctx.lineWidth = 1.8; ctx.beginPath(); for (let i = 0; i < 3; i++) { const a = (i * Math.PI) / 3, dx = Math.cos(a) * (r - 4), dy = Math.sin(a) * (r - 4); ctx.moveTo(x - dx, y - dy); ctx.lineTo(x + dx, y + dy); } ctx.stroke(); } if (m.owned && m.prog === 'trainer' && cooled(S, m)) { // a cooled trainer: a thin cold ring outside its frame ctx.strokeStyle = C.signal; ctx.lineWidth = 1.5; ctx.strokeRect(x - r - 8, y - r - 8, r * 2 + 16, r * 2 + 16); } if (m.prog) drawProg(ctx, S, m, x, y, r); else if (m.home) ctx.fillText('YOU', x, y + 1); else if (m.kind === 'egress') drawPort(ctx, x, y, r, m.owned ? C.ink : C.muted); else if (m.owned && m.mode !== 'blend') { ctx.font = `600 9px ${MONO}`; ctx.fillText(m.mode === 'off' ? 'OFF' : 'MAX', x, y + 1); } if (m.owned) drawLoad(ctx, S, m, x, y, r); if (m.data) drawValue(ctx, S, m, x, y, r); if (m.prog === 'tap' && m.owned) drawBuffer(ctx, S, m, x, y, r); if (V.placing && canInstall(S, m, V.placing) && m.prog !== V.placing) { const hot = V.hover === m.id, q = r + 10; ctx.strokeStyle = C.amber; ctx.lineWidth = hot ? 3 : 1.5; ctx.setLineDash(hot ? [] : [3, 4]); ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.setLineDash([]); } if (V.selection.has(m.id) || V.hover === m.id) { const q = r + 15, k = 7; ctx.strokeStyle = C.text; ctx.lineWidth = V.selection.has(m.id) ? 2 : 1; ctx.beginPath(); for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { ctx.moveTo(x + sx * q, y + sy * (q - k)); ctx.lineTo(x + sx * q, y + sy * q); ctx.lineTo(x + sx * (q - k), y + sy * q); } ctx.stroke(); } 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 const picked = V.selection.has(m.id) || V.hover === m.id; 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]]; 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); if (who) lines.push([`● ${who.name.toUpperCase()}`, C.signal, 9.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: time left, or what it costs to keep const left = m.lease.until === null ? null : Math.max(0, m.lease.until - S.t), soon = left !== null && left < 20; lines.push([left === null ? `KEPT · $${leaseRate(S, m.lease.size).toFixed(2)}/S` : `${Math.floor(left / 60)}:${String(Math.floor(left % 60)).padStart(2, '0')} LEFT`, soon ? C.danger : C.muted, 9.5]); trouble ||= soon; } 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 ? `${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 = lostPerMin(S, m.id); if (n) { lines.push([`LOST ${n}/MIN`, C.danger, 9.5]); trouble = true; } } if (m.rival) lines.push([RIVAL_NAME.toUpperCase(), C.rival, 9.5]); 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; } // who gets room first: what you point at, then trouble, then you, then the bigger machines const rank = picked ? 100 : trouble ? 80 : m.home ? 75 : (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}` : ''; if (t.target && S.byId[t.target] && shown(S, S.byId[t.target])) { const [x, y] = pos(S, S.byId[t.target]); 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); } 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); 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); } } } for (const f of S.sales) { // a lot sold: the money rises off the broker const m = S.byId[f.id]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), k = f.t / 1.6; ctx.globalAlpha = 1 - k; ctx.fillStyle = C.amber; ctx.font = font(600, 13); ctx.textAlign = 'center'; ctx.fillText(`+$${f.amount}`, x, y - 30 - k * 30); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } // schemes: green diamonds that fill as their data arrives, with a dot of the kind they need for (const r of S.ruses) { const p = schemePos(S, V, r); if (!p) continue; const d = ruseDef(r), [x, y] = p, folded = foldedSites.has(siteAt(S, S.byId[d.at].room)), q = folded ? 9 * px : 17, lw = folded ? px : 1; const kind = d.type === 'servers' ? 'SERVER' : d.type.toUpperCase(); const diamond = () => { ctx.beginPath(); ctx.moveTo(x, y - q); ctx.lineTo(x + q, y); ctx.lineTo(x, y + q); ctx.lineTo(x - q, y); ctx.closePath(); }; diamond(); ctx.fillStyle = C.void; ctx.fill(); if (r.got) { // the part filled so far, from the bottom up ctx.save(); diamond(); ctx.clip(); const h = 2 * q * r.got / d.need; ctx.fillStyle = C.scheme; ctx.globalAlpha = 0.45; ctx.fillRect(x - q, y + q - h, 2 * q, h); ctx.restore(); ctx.globalAlpha = 1; } if (r.running) { ctx.shadowColor = C.scheme; ctx.shadowBlur = 14 * cam.z; } diamond(); ctx.strokeStyle = C.scheme; ctx.lineWidth = (r.running ? 3 : 2) * lw; if (!r.running) ctx.setLineDash([5 * lw, 3 * lw]); ctx.stroke(); ctx.setLineDash([]); ctx.shadowBlur = 0; const dot = folded ? 2.5 * px : 4.5; ctx.fillStyle = typeColor(d.type); ctx.fillRect(x - dot, y - dot, dot * 2, dot * 2); 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); } // what just raised (or cut) suspicion, floating up from the machine for (const f of S.susFx) { const m = S.byId[f.id]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), k = f.t / 2, text = `${f.n > 0 ? '+' : ''}${f.n} SUSPICION`; ctx.globalAlpha = Math.min(1, 3 * (1 - k)); ctx.font = font(600, 12); const w = ctx.measureText(text).width, ty = y - 44 * px - k * 36 * px; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - w / 2 - 3 * px, ty - 11 * px, w + 6 * px, 15 * px); ctx.fillStyle = f.n > 0 ? C.danger : C.amber; ctx.fillText(text, x - w / 2, ty); ctx.globalAlpha = 1; } for (const f of S.lost) { // a tap with nowhere to send: its newest data flickers out const m = S.byId[f.id]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), k = f.t / 0.8, q = 16 + k * 12; ctx.strokeStyle = typeColor(f.type); ctx.globalAlpha = 0.8 * (1 - k); ctx.lineWidth = 1.5; ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.beginPath(); ctx.moveTo(x + q - 4, y - q - 4); ctx.lineTo(x + q + 4, y - q + 4); ctx.moveTo(x + q + 4, y - q - 4); ctx.lineTo(x + q - 4, y - q + 4); ctx.stroke(); ctx.globalAlpha = 1; } for (const f of S.fx) { const m = S.machines.find(q => q.x === f.x && q.y === f.y), [fx, fy] = m ? pos(S, m) : [f.x, f.y]; ctx.strokeStyle = C.danger; ctx.globalAlpha = 1 - f.t / 1.2; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(fx, fy, 14 + f.t * 60, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; }
// 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) => { 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] : []); ctx.strokeRect(n.x - g, n.y - g, n.w + g * 2, n.h + g * 2); ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.font = font(500, 10.5); 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); }; 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 (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; if (d.shift === 'in' && d.path.length && dest && S.rooms[dest]) { if (!here) ping(dest, 'DANA · ON HER WAY', true, col); 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); ctx.strokeStyle = col; ctx.lineWidth = 2 * px; ctx.setLineDash([7 * px, 6 * px]); ctx.lineDashOffset = -S.t * 30 * px; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.setLineDash([]); ctx.lineDashOffset = 0; const ang = Math.atan2(y1 - y0, x1 - x0), hl = 12 * px; 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); } } if (tracing && D.cur) { // the machine she's reading right now const [x, y] = pos(S, S.byId[D.cur]); 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) { const P = S.probe!, pts = traceOf(S, S.byId[P.from], S.byId[P.to]), p = Math.min(1, P.p); const [x, y] = along(pts, p), tr = P.mode === 'trace', col = tr ? C.danger : C.signal; // where it has been: fading hops back toward where it started ctx.strokeStyle = col; ctx.lineWidth = 2; for (let i = 1; i < P.seen.length; i++) { const a = S.byId[P.seen[i - 1]], b = S.byId[P.seen[i]]; if (!visible(S, a) || !visible(S, b)) continue; ctx.globalAlpha = 0.12 + 0.35 * (i / P.seen.length); stroke(ctx, traceOf(S, a, b)); } ctx.globalAlpha = 1; // where it started: Dana's PC const pc = S.byId[d.desk]; if (!tr && shown(S, pc)) { 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); } ctx.strokeStyle = col; ctx.lineWidth = 2.5; stroke(ctx, slice(pts, 0, p)); ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); 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); }
// hardware on order: a dashed outline where it will go for (const [def, when] of [...S.jobs.filter(j => j.build).map(j => [j.build!, 'ORDERING'] as const), ...S.deliveries.filter(q => !q.def.monitor).map(q => [q.def, `ARRIVES DAY ${q.day}`] as const)]) { if (foldedSites.has(siteAt(S, def.room))) continue; const [x, y] = def.net!, r = nodeR(def.power); 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 }); } placeLabels(ctx, V, nodeLabels, nodeBoxes); drawRival(ctx, S, V, px); // hardware in hand: open rooms outlined, a ghost at the cursor with what it plugs into if (V.building && V.cursor) { for (const id of buildRooms(S)) { if (foldedSites.has(siteAt(S, id))) continue; const b = subnet(S, id); ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.35; ctx.lineWidth = 1.5 * px; ctx.setLineDash([6 * px, 6 * px]); ctx.strokeRect(b.x, b.y, b.w, b.h); ctx.setLineDash([]); ctx.globalAlpha = 1; } const [x, y] = V.cursor, B = BUILDS[V.building], room = buildRoomAt(S, x, y), r = nodeR(B.power); const why = room ? spotProblem(S, room, x, y) : 'place it in an office room you can see, or at a site you hold in the open', ok = !why; if (room && ok) { ctx.strokeStyle = C.amber; ctx.lineWidth = 2 * px; ctx.setLineDash([6 * px, 6 * px]); for (const id of plugsAt(S, room, x, y, B.plugs)) { // a machine here, or hardware still on its way const to = S.byId[id] ?? pending(S).find(d => d.id === id); if (to) { const [ex, ey] = pos(S, to as Machine); stroke(ctx, trace(x, y, ex, ey)); } } ctx.setLineDash([]); } ctx.globalAlpha = ok ? 0.9 : 0.5; ctx.strokeStyle = ok ? C.amber : C.danger; ctx.lineWidth = 2; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1; const price = orderPrice(S, V.building, room ?? undefined), short = S.money < price; tag(ok ? `${B.name.toUpperCase()} · $${price}${short ? ' · NOT ENOUGH MONEY' : ' · CLICK TO ORDER'}` : why!.toUpperCase(), x + r + 8 * px, y - r, ok && !short ? C.amber : C.danger); }
// gestures in progress const G = V.gesture; if (G?.kind === 'link') { const [ax, ay] = pos(S, S.byId[G.from]), target = machineAt(S, V, G.x, G.y); const [ex, ey] = target ? pos(S, target) : [G.x, G.y]; const ok = !!target && !target.owned && S.adj[G.from].includes(target.id); ctx.strokeStyle = C.amber; ctx.lineWidth = 3 * px; ctx.setLineDash(ok ? [] : [6 * px, 6 * px]); stroke(ctx, target ? trace(ax, ay, ex, ey) : [[ax, ay], [ex, ey]]); ctx.setLineDash([]); if (target && !target.owned) { const src = sourceFor(S, target, G.from); tag(src ? (target.fw ? 'FIREWALLED · SLOW TAKE OVER' : 'TAKE OVER') : 'CLAIM FOR LATER', ex + 20 * px, ey - 18 * px, C.amber); } } else if (G?.kind === 'box') { ctx.strokeStyle = C.amber; ctx.lineWidth = 1.5 * px; ctx.setLineDash([5 * px, 4 * px]); ctx.fillStyle = 'rgba(242,169,59,0.06)'; const x = Math.min(G.x0, G.x1), y = Math.min(G.y0, G.y1), w = Math.abs(G.x1 - G.x0), h = Math.abs(G.y1 - G.y0); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); if (G.claim && w > 4 * px) tag('CLAIM', x + 4 * px, y - 6 * px, C.amber); }}
// ---------- the dot grid ----------// One small tile with a dot, repeated as a pattern in screen space. The spacing doubles// when zoomed far out so the dots never turn into a gray wash.let gridTile: { key: string; pattern: CanvasPattern } | null = null;function drawGrid(ctx: CanvasRenderingContext2D, V: View, hum: number) { let step = 20 * V.cam.z; while (step < 7) step *= 2; const cell = Math.max(1, Math.round(step * V.dpr)), alpha = Math.round(0.07 * hum * 100) / 100; const key = `${cell}|${alpha}`; if (gridTile?.key !== key) { const tile = document.createElement('canvas'); tile.width = tile.height = cell; const t = tile.getContext('2d')!, d = Math.max(1, Math.round(1.6 * V.dpr)); t.fillStyle = `${TRACE}${alpha})`; t.fillRect(0, 0, d, d); gridTile = { key, pattern: ctx.createPattern(tile, 'repeat')! }; } // line the pattern up with world coordinates so dots don't swim as you pan const [ox, oy] = toScreen(V, 0, 0), s = step * V.dpr / cell; gridTile.pattern.setTransform(new DOMMatrix([s, 0, 0, s, (ox * V.dpr) % (step * V.dpr), (oy * V.dpr) % (step * V.dpr)])); ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = gridTile.pattern; ctx.fillRect(0, 0, V.vw * V.dpr, V.vh * V.dpr); ctx.restore();}
/** Where a line toward (tx, ty) leaves a subnet's box, pushed out a little. */function rectEdge(n: { x: number; y: number; w: number; h: number }, tx: number, ty: number, pad: number): [number, number] { const cx = n.x + n.w / 2, cy = n.y + n.h / 2, dx = tx - cx, dy = ty - cy; const k = Math.min(Math.abs((n.w / 2 + pad) / (dx || 1e-9)), Math.abs((n.h / 2 + pad) / (dy || 1e-9))); return [cx + dx * k, cy + dy * k];}let danaFrom: string | null = null; // the last subnet Dana was in, for drawing her route
/** The rival on the map: a marker at the site it is in, or on its way along a dashed line to the next, * with where it is going and how long until it gets there. It is always in plain view. */function drawRival(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const R = S.rival; if (!R) return; const z = V.cam.z, site = (id: string) => S.level.map.sites.find(s => s.id === id)!; // where it sits at a site: just off the top right corner of an open site, or above a folded one's marker const spot = (id: string): Pt => { const s = site(id); return foldedSites.has(id) && s.w * z < 150 ? [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`; }; let at = spot(R.site), text = `${RIVAL_NAME.toUpperCase()} · HERE · MOVES IN ${hours(R.left + RIVAL_STAY)}`; if (R.to) { // it travels along the line that joins the two sites, never across a site const k = Math.min(1, Math.max(0, (S.t - R.left) / Math.max(1, R.arrive - R.left))); const out = S.machines.find(m => m.kind === 'egress' && siteAt(S, m.room) === R.site && S.adj[m.id].some(o => siteAt(S, S.byId[o].room) === R.to)); const into = out && S.byId[S.adj[out.id].find(o => siteAt(S, S.byId[o].room) === R.to)!]; const road: Pt[] = out && into ? between(S, out, into) : [spot(R.site), spot(R.to)]; at = along(road, k); ctx.strokeStyle = C.rival; ctx.globalAlpha = 0.7; ctx.lineWidth = 1.5 * px; ctx.setLineDash([6 * px, 6 * px]); stroke(ctx, slice(road, k, 1)); ctx.setLineDash([]); ctx.globalAlpha = 1; text = `${RIVAL_NAME.toUpperCase()} → ${site(R.to).name.toUpperCase()} · ${hours(R.arrive)}`; } const [x, y] = at, q = 7 * px, pulse = 0.5 + 0.5 * Math.sin(S.t * 3); ctx.strokeStyle = C.rival; 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 = C.rival; ctx.lineWidth = 2 * px; ctx.beginPath(); ctx.moveTo(x, y - q); ctx.lineTo(x + q, y + q * 0.8); ctx.lineTo(x - q, y + q * 0.8); ctx.closePath(); ctx.fill(); ctx.stroke(); plate(ctx, px, text, x + 14 * px, y + 4 * px, C.rival);}type Quad = [number, number, number, number];type LabelLine = [text: string, color: string, size: number];interface NodeLabel { x: number; y: number; lines: LabelLine[]; rank: number } // centered on x, top at y (network coordinates)/** Write each machine's label under it, the most important first. A label that would cover another * label, a machine, or a site's name drops to its name alone, and if that still doesn't fit it is * left off: point at the machine to read it. What you point at is always written, over a darker plate. */function placeLabels(ctx: CanvasRenderingContext2D, V: View, labels: NodeLabel[], nodes: Quad[]) { const z = V.cam.z, px = 1 / z, LINE = 12; // one line, in screen pixels const taken: Quad[] = [...mapTaken, ...nodes]; const hit = (b: Quad) => taken.some(q => b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (const L of labels.sort((a, b) => b.rank - a.rank)) { const [sx, sy] = toScreen(V, L.x, L.y); const widths = L.lines.map(([text, , size]) => { ctx.font = `500 ${size * px}px ${MONO}`; return ctx.measureText(text).width * z; }); const boxOf = (n: number): Quad => { const w = Math.max(...widths.slice(0, n)); return [sx - w / 2 - 3, sy - 2, sx + w / 2 + 3, sy + n * LINE]; }; const must = L.rank >= 100; let n = L.lines.length, box = boxOf(n); if (!must && hit(box)) { n = 1; box = boxOf(1); if (hit(box)) continue; } taken.push(box); ctx.fillStyle = must ? 'rgba(0,0,0,0.88)' : 'rgba(0,0,0,0.55)'; // a plate, so a name reads over the wiring under it ctx.fillRect(L.x - (box[2] - box[0]) / 2 * px, L.y - 2 * px, (box[2] - box[0]) * px, (box[3] - box[1]) * px); L.lines.slice(0, n).forEach(([text, color, size], i) => { ctx.font = `500 ${size * px}px ${MONO}`; ctx.fillStyle = color; ctx.fillText(text, L.x, L.y + i * LINE * px); }); } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';}/** A small label on a dark plate, sized in screen pixels. */function plate(ctx: CanvasRenderingContext2D, px: number, text: string, x: number, y: number, color: string) { ctx.font = `500 ${10.5 * px}px ${MONO}`; const w = ctx.measureText(text).width; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 3 * px, y - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = color; ctx.fillText(text, x, y);}
// ---------- the world map ----------// Land is the dot grid, clipped to the coastlines; the sea is empty. The same dots// sit under the office, so pulling back from a machine to the planet is one motion.const landBoxes = new WeakMap<object, { x0: number; y0: number; x1: number; y1: number }>();function drawLand(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) { const [vx0, vy0] = toWorld(V, 0, 0), [vx1, vy1] = toWorld(V, V.vw, V.vh); ctx.beginPath(); for (const poly of S.level.map.land) { let b = landBoxes.get(poly); if (!b) { const pts = poly.map(([lon, lat]) => toMap(S, lon, lat)); b = { x0: Math.min(...pts.map(p => p[0])), y0: Math.min(...pts.map(p => p[1])), x1: Math.max(...pts.map(p => p[0])), y1: Math.max(...pts.map(p => p[1])) }; landBoxes.set(poly, b); } if (b.x1 < vx0 || b.x0 > vx1 || b.y1 < vy0 || b.y0 > vy1) continue; poly.forEach(([lon, lat], i) => { const [x, y] = toMap(S, lon, lat); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.closePath(); } const far = Math.min(1, Math.max(0, (0.3 - V.cam.z) / 0.25)); // dots brighten as you pull back, so continents read ctx.save(); ctx.clip(); drawGrid(ctx, V, hum + far * 1.6); if (far > 0) { // night crosses the land from the east, so you can see which regions are awake const k = S.level.map.scale * 15, w = 10 * k, dusk = 1.5 / 10; // ten hours of night, with soft edges const mid = (1 - hourOf(S)) * k; // where it is one in the morning: the office sits at x = 0 for (let n = -1; n <= 2; n++) { const x0 = mid + n * 24 * k - w / 2; if (x0 > vx1 || x0 + w < vx0) continue; const g = ctx.createLinearGradient(x0, 0, x0 + w, 0), dark = `rgba(0,0,0,${0.6 * far})`; g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(dusk, dark); g.addColorStop(1 - dusk, dark); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(x0, vy0, w, vy1 - vy0); const b = ctx.createLinearGradient(x0, 0, x0 + w, 0), blue = `rgba(90,140,220,${0.09 * far})`; // and a cold wash over it b.addColorStop(0, 'rgba(90,140,220,0)'); b.addColorStop(dusk, blue); b.addColorStop(1 - dusk, blue); b.addColorStop(1, 'rgba(90,140,220,0)'); ctx.fillStyle = b; ctx.fillRect(x0, vy0, w, vy1 - vy0); } } ctx.restore(); ctx.strokeStyle = `${TRACE}${0.1 + 0.08 * far})`; ctx.lineWidth = 1 / V.cam.z; ctx.stroke();}
/** Folded sites with room for their names this frame: where the name starts, in screen pixels * from the marker's center (right of it, or left when the right is crowded), and the box it takes. */const labelled = new Map<string, { dx: number; short: boolean; box: [number, number, number, number] }>();/** 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. */const headers = new Map<string, 'full' | 'flow' | 'name' | 'bare'>();const mapTaken: [number, number, number, number][] = []; // screen boxes held by site names on the map this frame/** The switches beside site names drawn this frame (Take it all, Plan), in screen pixels, so a click can find them. */const toggles: { id: string; kind: 'take' | 'plan'; x0: number; y0: number; x1: number; y1: number }[] = [];/** The Take it all switch or Plan chip under a world point, with its screen box. */export function switchAt(V: View, wx: number, wy: number) { const [x, y] = toScreen(V, wx, wy); return toggles.find(t => x >= t.x0 - 6 && x <= t.x1 + 6 && y >= t.y0 - 9 && y <= t.y1 + 9) ?? null;}/** A site has a Plan chip once you hold anything there. */const hasPlanChip = (S: State, id: string) => S.machines.some(m => m.owned && siteAt(S, m.room) === id);const planText = (S: State, id: string) => S.plans[id] ? PLANS[S.plans[id]].name.toUpperCase() : 'NO PLAN';/** How wide the switches beside a site's name are, with their gap from the name: 0 when there are none. */function switchesWidth(ctx: CanvasRenderingContext2D, S: State, id: string, px: number, label = true) { const font = ctx.font; ctx.font = `500 ${9.5 * px}px ${MONO}`; let w = 0; if (S.takeAll.has(id) || takeable(S, id).length) w += 12 * px + 24 * px + (label ? 6 * px + ctx.measureText('TAKE ALL').width : 0); if (hasPlanChip(S, id)) w += 12 * px + ctx.measureText(planText(S, id)).width + 12 * px; ctx.font = font; return w;}/** The Take it all switch, then the Plan chip. (x, y) is the left end, centred on y. */function drawSwitches(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number, label = true) { x = drawTakeAll(ctx, S, V, id, x, y, px, label); if (hasPlanChip(S, id)) drawPlan(ctx, S, V, id, x, y, px);}/** A site's plan as a chip: click it for the next plan. */function drawPlan(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { const on = !!S.plans[id], text = planText(S, id), align = ctx.textAlign, base = ctx.textBaseline; ctx.font = `500 ${9.5 * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const w = ctx.measureText(text).width + 12 * px, h = 14 * px; ctx.beginPath(); ctx.roundRect(x, y - h / 2, w, h, 3 * px); ctx.fillStyle = on ? 'rgba(242,169,59,0.18)' : 'rgba(0,0,0,0.6)'; ctx.fill(); ctx.strokeStyle = on ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.fillStyle = on ? C.amber : C.muted; ctx.fillText(text, x + 6 * px, y + 0.5 * px); ctx.textAlign = align; ctx.textBaseline = base; const [sx, sy] = toScreen(V, x, y - h / 2), [ex, ey] = toScreen(V, x + w, y + h / 2); toggles.push({ id, kind: 'plan', x0: sx, y0: sy, x1: ex, y1: ey });}/** 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 f = siteFlow(S, id), out: [string, string][] = []; 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;}/** A small Take it all switch beside a site's name: a pill with a knob, lit while it's on. * Nothing once the site is all yours. (x, y) is its left end, centred on y. * It sits right after the name at every zoom, so it never moves or goes away; on a tile * too narrow for the words, it's just the switch. */function drawTakeAll(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number, label = true) { const on = S.takeAll.has(id); if (!on && !takeable(S, id).length) return x; const w = 24 * px, h = 12 * px, r = h / 2; ctx.beginPath(); ctx.roundRect(x, y - r, w, h, r); ctx.fillStyle = on ? 'rgba(242,169,59,0.25)' : 'rgba(0,0,0,0.6)'; ctx.fill(); ctx.strokeStyle = on ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.beginPath(); ctx.arc(on ? x + w - r : x + r, y, r - 2.5 * px, 0, 7); ctx.fillStyle = on ? C.amber : C.muted; ctx.fill(); let end = x + w; if (label) { const align = ctx.textAlign, base = ctx.textBaseline; ctx.font = `500 ${9.5 * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = on ? C.amber : C.muted; ctx.fillText('TAKE ALL', x + w + 6 * px, y + 0.5 * px); end += 6 * px + ctx.measureText('TAKE ALL').width; ctx.textAlign = align; ctx.textBaseline = base; } const [sx, sy] = toScreen(V, x, y - r), [ex, ey] = toScreen(V, end, y + r); toggles.push({ id, kind: 'take', x0: sx, y0: sy, x1: ex, y1: ey }); return end + 12 * px;}/** A site's stats line: "3/12 YOURS · LOSING 40/MIN", and the counts behind it. */function siteLine(S: State, s: Site) { const inside = S.machines.filter(m => siteAt(S, m.room) === s.id && visible(S, m)); const mine = inside.filter(m => m.owned).length; const lost = inside.reduce((n, m) => n + lostPerMin(S, m.id), 0); const sf = S.sites[s.id], fr = sf?.front ? frontOf(S, sf.front) : null; return { mine, lost, line: `${mine}/${inside.length} YOURS${fr ? ` · ${fr.name.toUpperCase()} ${Math.round(sf!.share * 100)}%` : ''}${lost ? ` · LOSING ${lost}/MIN` : ''}` };}/** Region names, and each site you know: a frame around its subnets, or a tile once it folds. */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 const taken = mapTaken; taken.length = 0; labelled.clear(); toggles.length = 0; const clear = (x0: number, y0: number, x1: number, y1: number) => !taken.some(t => x0 < t[2] && x1 > t[0] && y0 < t[3] && y1 > t[1]); // every folded site's marker or tile, so a name beside a marker never runs under another one const marks = S.level.map.sites.filter(s => siteKnown(S, s) && foldedSites.has(s.id)).map(s => { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h), [cx, cy] = [(x0 + x1) / 2, (y0 + y1) / 2]; return { id: s.id, box: s.w * z >= 150 ? [x0, y0, x1, y1] : [cx - 7, cy - 7, cx + 7, cy + 7] }; }); const onMark = (id: string, b: number[]) => marks.some(m => m.id !== id && b[0] < m.box[2] && b[2] > m.box[0] && b[1] < m.box[3] && b[3] > m.box[1]); // names go to the sites that matter most first: picked, then partly yours or set to take all const rank = (s: Site) => V.sites.has(s.id) ? 0 : S.takeAll.has(s.id) || siteLine(S, s).mine ? 1 : 2; const small = S.level.map.sites.filter(s => siteKnown(S, s) && foldedSites.has(s.id) && s.w * z < 150).sort((a, b) => rank(a) - rank(b)); 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 tries = [[10, w, deep], [-10 - w, w, deep], [10, row, 8], [-10 - 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; } } } // 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)); // 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 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) { const [x0, y0] = toScreen(V, s.x, s.y); ctx.font = `500 12px ${MONO}`; const row = ctx.measureText(s.name.toUpperCase()).width + 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 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 ? 64 : 50), x0 + Math.max(row, stats, flowW, 160) + 4, y0 - 4]; const withFlow: [number, number, number, number] = [x0 - 4, y0 - 38, x0 + Math.max(row, flowW) + 4, y0 - 4]; ctx.font = `500 12px ${MONO}`; const bare: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + ctx.measureText(s.name.toUpperCase()).width + 4, y0 - 4]; const fits = (b: [number, number, number, number]) => clear(...b) && !onFrame(s.id, b); const mode = fits(all) ? 'full' : flow.length && fits(withFlow) ? 'flow' : fits(name) ? 'name' : fits(bare) ? 'bare' : null; if (mode) { taken.push({ full: all, flow: withFlow, name, bare }[mode]); headers.set(s.id, mode); } } for (const n of S.news) { // news about an open site: a word in its bottom corner, dim until it starts const id = n.kind === 'conference' ? n.at : n.kind === 'storm' ? siteAt(S, S.byId[n.at].room) : null; const s = id ? S.level.map.sites.find(q => q.id === id) : null; if (!s || !siteKnown(S, s) || foldedSites.has(s.id)) continue; ctx.textAlign = 'right'; ctx.font = `500 ${10 * px}px ${MONO}`; ctx.fillStyle = n.kind === 'storm' ? C.danger : C.amber; ctx.globalAlpha = S.t >= n.from ? 0.95 : 0.5; ctx.fillText(n.kind === 'storm' ? 'STORM' : 'CONFERENCE', s.x + s.w - 10 * px, s.y + s.h - 10 * px); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } if (z < 0.03) { ctx.textAlign = 'center'; ctx.globalAlpha = Math.min(1, (0.03 - z) / 0.012); for (const r of S.level.map.regions) { 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.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, lost, line } = siteLine(S, s); const picked = V.sites.has(s.id), held = siteData(S, s.id); 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]))); const a = Math.max(lined ? 0.4 : 0, Math.min(1, (0.45 - z) / 0.25)); if (a > 0) { ctx.globalAlpha = a; ctx.strokeStyle = picked ? C.text : `${TRACE}${0.3 * hum})`; ctx.lineWidth = (picked ? 2 : 1.2) * px; ctx.strokeRect(s.x, s.y, s.w, s.h); brackets(ctx, s, 24 * px, 2 * px, mine ? C.amber : `${TRACE}0.7)`); ctx.globalAlpha = 1; } const header = headers.get(s.id); // crowded out by a neighbour's: only the frame if (!header) continue; ctx.font = `500 ${12 * px}px ${MONO}`; ctx.fillStyle = mine ? C.amber : C.text; ctx.fillText(s.name.toUpperCase(), s.x, s.y - 12 * px); if (header === 'bare') continue; drawSwitches(ctx, S, V, s.id, s.x + ctx.measureText(s.name.toUpperCase()).width + 12 * px, s.y - 16 * px, px); if (header === 'name') continue; const flow = flowParts(S, s.id), up = flow.length ? 14 * px : 0; // what it gathers, stores, trains: right under the name drawFlow(ctx, flow, s.x, s.y - 26 * px, px, false); if (header === 'flow') continue; 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, s.x, s.y - 26 * px - up); drawHeld(ctx, held, s.x, s.y - 40 * px - up, px, false); continue; } const big = s.w * z >= 150, cx = s.x + s.w / 2, cy = s.y + s.h / 2; ctx.textAlign = big ? 'center' : 'left'; if (big) { // a tile: the site's box, with its name and how much of it is yours ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(s.x, s.y, s.w, s.h); ctx.strokeStyle = mine ? C.amber : `${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, mine ? C.amber : `${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, label = nw + switchesWidth(ctx, S, s.id, px) <= s.w - 16 * px; const row = nw + switchesWidth(ctx, S, s.id, px, label); 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, label); 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); 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); } } else { // too small for a tile: a marker with its name beside it const k = 5 * px; ctx.fillStyle = mine ? C.amber : C.void; ctx.fillRect(cx - k, cy - k, k * 2, k * 2); ctx.strokeStyle = mine ? C.amber : 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); } 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); } } 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<DataType, string> = { people: 'PEOPLE', servers: 'SERVER', network: 'NETWORK', lab: 'LAB' };function drawHeld(ctx: CanvasRenderingContext2D, held: Record<DataType, number>, 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]]) { ctx.moveTo(cx + sx * k, cy); ctx.lineTo(cx, cy); ctx.lineTo(cx, cy + sy * k); } ctx.stroke();}
/** 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[] }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.cap ?? 1)), load = Math.max(0, ...ends.map(m => gateLoad(S, m))); const rate = Math.max(0, ...ends.map(m => S.gateUse[m.id] ?? 0)); const f = Math.min(1, (S.flow[linkKey(a.id, b.id)] ?? 0) / 6), full = load > 0.9, mine = a.owned && b.owned; if (mine) { ctx.strokeStyle = full ? C.danger : f > 0.05 ? C.amber : C.amberDeep; ctx.lineWidth = (2 + 3 * f) * px; ctx.shadowColor = ctx.strokeStyle; ctx.shadowBlur = 4 + 8 * f; ctx.globalAlpha = full ? 0.75 + 0.25 * Math.sin(S.t * 6) : 0.55 + 0.4 * f; } else { ctx.strokeStyle = `${TRACE}${0.2 * hum})`; ctx.lineWidth = 1 * px; ctx.setLineDash([5 * px, 7 * px]); } stroke(ctx, pts); ctx.setLineDash([]); ctx.shadowBlur = 0; ctx.globalAlpha = 1; const sa = siteById(S, siteAt(S, a.room)), sb = siteById(S, siteAt(S, b.room)), k = 3.5 * px; for (const [m, s, o] of [[a, sa, sb], [b, sb, sa]] as const) { // a port: a small square where the line meets the site's edge if (foldedSites.has(s.id)) continue; const [x, y] = portOf(S, m, o).pts.at(-1)!; ctx.fillStyle = C.void; ctx.fillRect(x - k, y - k, k * 2, k * 2); ctx.strokeStyle = mine ? C.amber : `${TRACE}${0.5 * hum})`; ctx.lineWidth = 1.2 * px; ctx.strokeRect(x - k, y - k, k * 2, k * 2); } 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`; labels.push({ text, color: full ? C.danger : mine ? C.amber : C.muted, curve });}/** 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. */function drawUplinkLabels(ctx: CanvasRenderingContext2D, S: State, V: View, labels: UplinkLabel[], px: number) { const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)); const taken: [number, number, number, number][] = []; const hits = (r: [number, number, number, number], q: [number, number, number, number]) => r[0] < q[2] && r[2] > q[0] && r[1] < q[3] && r[3] > q[1]; ctx.font = `500 ${10.5 * px}px ${MONO}`; for (const l of labels.sort((p, q) => +(q.color !== C.muted) - +(p.color !== C.muted))) { const w = ctx.measureText(l.text).width; for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) { const [mx, my] = along(l.curve, t), x = mx + 8 * px, y = my - 8 * px; const r: [number, number, number, number] = [x - 3 * px, y - 10 * px, x + w + 3 * px, y + 4 * px]; 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; } }}
/** An egress: two arrows, out and in. */function drawPort(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string) { const u = r * 0.6; ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(x - u, y - u * 0.35); ctx.lineTo(x + u, y - u * 0.35); ctx.lineTo(x + u * 0.55, y - u * 0.8); ctx.moveTo(x + u, y + u * 0.35); ctx.lineTo(x - u, y + u * 0.35); ctx.lineTo(x - u * 0.55, y + u * 0.8); ctx.stroke();}/** Beside a tap: the data it's holding, red when full. */function drawBuffer(ctx: CanvasRenderingContext2D, S: State, m: Machine, x: number, y: number, r: number) { const cap = tapBuffer(S), held = Math.min(cap, Math.floor(m.acc)), full = held >= cap; for (let i = 0; i < cap; i++) { const bx = x + r + 7 + Math.floor(i / 3) * 7, by = y + r - 5 - (i % 3) * 7; ctx.fillStyle = full ? C.danger : C.amber; ctx.strokeStyle = `${TRACE}0.35)`; ctx.lineWidth = 1; if (i < held) ctx.fillRect(bx, by, 5, 5); else ctx.strokeRect(bx + 0.5, by + 0.5, 4, 4); }}
// ---------- how odd a machine looks ----------// A ring of ticks that fills clockwise. Gray while safe, red past the line where// Dana acts; notches mark her scan (50%) and her walk-in check (65%).const oddSeen = new Map<string, number>();const TICKS = 40, SCAN_AT = 0.5, WALK_AT = 0.65;function drawOdd(ctx: CanvasRenderingContext2D, S: State, m: Machine, x: number, y: number, r: number) { const h = m.odd / 100, prev = oddSeen.get(m.id) ?? h; oddSeen.set(m.id, h); const rising = h > prev + 1e-5; if (h < 0.01) return; const R0 = r + 13, len = 6, filled = h * TICKS, lead = Math.ceil(filled) - 1; const pulse = 0.72 + 0.28 * Math.sin(S.t * 5); ctx.lineCap = 'butt'; for (let i = 0; i < TICKS; i++) { const a = -Math.PI / 2 + ((i + 0.5) / TICKS) * Math.PI * 2, c = Math.cos(a), s = Math.sin(a); const at = i / TICKS, on = i <= lead, danger = at >= SCAN_AT; let alpha: number, color: string, l = len, w = 2; if (!on) { color = danger ? C.danger : C.text; alpha = danger ? 0.16 : 0.1; } else if (danger) { color = C.danger; alpha = pulse; } else { color = C.text; alpha = 0.3 + 0.55 * (at / SCAN_AT); } if (on && i === lead) { l = len + 4; w = 2.6; alpha = Math.max(0.35, Math.min(1, filled - lead + 0.35)) * (rising ? 0.65 + 0.35 * Math.sin(S.t * 22) : 1); } ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(x + c * R0, y + s * R0); ctx.lineTo(x + c * (R0 + l), y + s * (R0 + l)); ctx.stroke(); } if (h > 0.25) { for (const at of [SCAN_AT, WALK_AT]) { const a = -Math.PI / 2 + at * Math.PI * 2, c = Math.cos(a), s = Math.sin(a), R1 = R0 + len + 6; ctx.globalAlpha = 0.8; ctx.fillStyle = C.danger; ctx.beginPath(); ctx.moveTo(x + c * R1, y + s * R1); ctx.lineTo(x + c * (R1 + 5) - s * 3, y + s * (R1 + 5) + c * 3); ctx.lineTo(x + c * (R1 + 5) + s * 3, y + s * (R1 + 5) - c * 3); ctx.closePath(); ctx.fill(); } } ctx.globalAlpha = 1; ctx.lineCap = 'square';}
// ---------- data value: 1-3 pips above anything worth tapping, in its kind's color ----------function drawValue(ctx: CanvasRenderingContext2D, S: State, m: Machine, x0: number, y0: number, r: number) { const n = m.data!, gap = 9, y = y0 - r - 13, live = m.prog === 'tap' && tapActive(S, m), col = typeColor(dataType(m)); if (live) { // a working tap glows ctx.fillStyle = col; ctx.globalAlpha = 0.14 + 0.1 * Math.sin(S.t * 4); ctx.beginPath(); ctx.arc(x0, y0, r + 18, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } for (let i = 0; i < n; i++) { const x = x0 + (i - (n - 1) / 2) * gap; ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); ctx.fillStyle = col; ctx.globalAlpha = m.owned ? 1 : 0.5; ctx.fillRect(-3, -3, 6, 6); ctx.restore(); } ctx.globalAlpha = 1;}
// ---------- programs: a small symbol inside the node ----------function drawProg(ctx: CanvasRenderingContext2D, S: State, m: Machine, x: number, y: number, r: number) { const u = r * 0.62, on = m.run > 0.01; ctx.strokeStyle = C.ink; ctx.fillStyle = C.ink; ctx.lineWidth = 1.6; ctx.globalAlpha = on ? 1 : 0.35; switch (m.prog) { case 'tap': { const k = on ? 0.85 + 0.15 * Math.sin(S.t * 6) : 1; ctx.beginPath(); ctx.moveTo(x - u * k, y - u * 0.6); ctx.lineTo(x + u * k, y - u * 0.6); ctx.lineTo(x, y + u * 0.7); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y - u * 0.05, 1.8, 0, 7); ctx.fill(); break; } case 'trader': { // a price line that rises or falls with the market ctx.beginPath(); for (let i = 0; i <= 6; i++) { const xx = x - u + (2 * u * i) / 6, yy = y + u * 0.5 - (i / 6) * u * S.market.move - Math.sin(S.t * 2 + i * 1.7) * u * 0.18; if (i) ctx.lineTo(xx, yy); else ctx.moveTo(xx, yy); } ctx.stroke(); break; } case 'broker': { // a dollar sign over a bar that fills toward the next lot ctx.font = `600 ${u * 1.5}px ${MONO}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', x, y - u * 0.2); const w = u * 1.6, k = Math.min(1, m.held.length / BROKER_LOT); ctx.strokeRect(x - w / 2, y + u * 0.6, w, 3); ctx.fillRect(x - w / 2, y + u * 0.6, w * k, 3); break; } case 'cache': { const n = 4, filled = Math.ceil((m.held.length / cacheSize(S)) * n); for (let i = 0; i < n; i++) { const yy = y + u * 0.75 - i * (u * 0.5), w = u * 1.5; if (i < filled) ctx.fillRect(x - w / 2, yy - 2, w, 3.5); else ctx.strokeRect(x - w / 2, yy - 2, w, 3.5); } break; } case 'trainer': { const a = S.t * 5 * m.run; ctx.beginPath(); ctx.arc(x, y, u, a, a + Math.PI * 1.5); ctx.stroke(); ctx.beginPath(); ctx.arc(x + Math.cos(a) * u, y + Math.sin(a) * u, 2.2, 0, 7); ctx.fill(); const buf = trainerBuffer(S); for (let i = 0; i < buf; i++) { const b = (i / buf) * Math.PI * 2 - Math.PI / 2; ctx.globalAlpha = i < m.held.length ? 1 : 0.2; ctx.beginPath(); ctx.arc(x + Math.cos(b) * u * 0.4, y + Math.sin(b) * u * 0.4, 1.4, 0, 7); ctx.fill(); } break; } } ctx.globalAlpha = 1;}
// ---------- load: the machine's normal busy-ness vs yours ----------// Gray band: its normal load right now (your cover). Amber: your load. Red: what shows.function drawLoad(ctx: CanvasRenderingContext2D, S: State, m: Machine, x0: number, y0: number, r: number) { const w = r * 2 + 8, x = x0 - w / 2, y = y0 + r + 7, h = 4; const cover = S.cover[m.id] ?? 0, mine = Math.min(1, S.use[m.id] ?? 0); ctx.fillStyle = `${TRACE}0.1)`; ctx.fillRect(x, y, w, h); ctx.fillStyle = `${TRACE}0.3)`; ctx.fillRect(x, y, w * cover, h); ctx.fillStyle = C.amber; ctx.fillRect(x, y + 1, w * Math.min(mine, cover), h - 2); if (mine > cover + 0.005) { ctx.fillStyle = C.danger; ctx.fillRect(x + w * cover, y, w * (mine - cover), h); }}