Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
241 kB · 3435 lines
TypeScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436// 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 { HARDENED, atWork, hourOf, cacheSize, dataType, linkKey, pathOf, trainerBuffer, up, canInstall, danaPerson, danaVisible, daylight, has, hopsHome, jobFor, roomAt, sourceFor, tapActive, TRACE_COLD, visible, frontOf, FRONT_KINDS, type FrontKind, type Machine, type Path, type Prog, type State, known, huntOn, unpowered, powerRoom, siteName, siteOf, SIZES, BROKER_LOT, BUILDS, buildRoomAt, buildRooms, fillPlan, plural, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, tapCause, takeable, siteLines, lineCost, billOf, tapBuffer, siteData, siteFlow, ruseDef, ruseText, regionOpen, pending, cooled, capNow, moveNeed, rogueOf, rogueHeld, rogueCap, HOUR, PROGRAMS, type Ruse, PLANS, newsText, type News, rogueLook, GLYPH_CHAR, type Glyph,} from './sim';import { dropouts, hash, stormBand, stormClear, stormField, stormFront, stormMark, stormStatic, type Box as ScreenBox } from './weather';import { deskOwner, citizens } from './people';import { sightingsOf, present, seenMsgs, onRoute, tracesOf, traceSeen, SIGHT_WORD, type Message } from './watch';import { LANDMARKS, isLandmark, landmarksAt } from './landmarks';import { leads, type ThingDef } from './finds';import { isShut } from './sitelife';import { armed, defends, doorOf, fire, forecast, unplugged, raidAt, raider, raidEta, raidHeld, raidHours, raidPlate, raidSays, raidWalk, standing, WALL, wallOf, weakLink } from './raids';import { linkBlock, linkKeep, linkWork, ways } from './links';import { DRIFT, integrities, type Integrity } from './drift';import { isVault, vaultLabel, vaultLook, vaultSigns, vaultsAt } from './vaults';import { customerName, isListed, renterOf } from './cloud';import { eachOnLink, hopTime } from './flows';import { PAYS, rentPlan, slotsIn, tenantOf } from './hall';import type { DataType, Site } from './world';import { nearest, nextToward } from './hop';import { usd, usdDay } from './money';import { holdState, type HoldState } from './holdings';import { frontHolds, type Hold } from './exposure';import { drawLogo } from './logos';import { hours as inHours } from './ui-front'; // hours in words, as every card writes themimport { placeOf, traceAt } from './motion';import { BoxGrid, Grid } from './grid';/** An hour of the day as 9:05. */const hhmm = (h: number) => `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`;
// The map shows what and who; the numbers and the reasons are in tips. Each small mark drawn this frame// that has something to say leaves its screen box and its words here, and the newest one wins.// A layer drawn once and kept (see Layer) keeps the tips its marks left, and the frame's list holds that layer's list// where the layer is drawn, so a later mark still wins over it.type TipSpot = { box: ScreenBox; text: string | (() => string) } | { group: TipSpot[] };const frameTips: TipSpot[] = [];let tipSpots = frameTips; // where a mark's tip goes: the frame's list, or the list of the layer being drawn/** Leave a tip on a box `w` by `h` screen pixels round a world point. */function spot(V: View, x: number, y: number, text: string, w = 16, h = w) { const [sx, sy] = toScreen(V, x, y); tipSpots.push({ box: [sx - w / 2, sy - h / 2, sx + w / 2, sy + h / 2], text });}/** A tip left on a screen box. */const spotBox = (box: ScreenBox, text: string | (() => string)) => { tipSpots.push({ box, text }); };function tipIn(list: TipSpot[], x: number, y: number): { box: ScreenBox; text: string } | null { for (let i = list.length - 1; i >= 0; i--) { const s = list[i]; if ('group' in s) { const t = tipIn(s.group, x, y); if (t) return t; continue; } const b = s.box; if (x >= b[0] - 3 && x <= b[2] + 3 && y >= b[1] - 3 && y <= b[3] + 3) { const t = s.text; return { box: b, text: typeof t === 'string' ? t : t() }; } } return null;}/** A mark on the map under the mouse that says more: a storm, a site's or a machine's trouble, a plate. */export function mapTipAt(V: View, wx: number, wy: number): { box: ScreenBox; text: string } | null { const storm = stormAt(V, wx, wy); if (storm) return storm; const [x, y] = toScreen(V, wx, wy); return tipIn(frameTips, x, y);}/** The small marks for trouble, on a machine or beside a site's name: a dark square with a hairline edge and a * sign in the trouble's colour. Losing data is a drop, a full line three bars, a cut-off a broken link, the * power cut a bolt, a shut business a bar, idle trainers a pause, a time running out a clock, a guard down a cross, * a part of you drifting away a square pulling off its corner. */type Sign = 'drop' | 'full' | 'cut' | 'power' | 'shut' | 'idle' | 'clock' | 'cross' | 'eye' | 'drift';function drawSign(ctx: CanvasRenderingContext2D, sign: Sign, x: number, y: number, s: number, color: string, px: number) { ctx.fillStyle = C.void; ctx.fillRect(x - s, y - s, s * 2, s * 2); ctx.strokeStyle = color; ctx.lineWidth = px; ctx.strokeRect(x - s + px / 2, y - s + px / 2, s * 2 - px, s * 2 - px); const u = s * 0.55; ctx.fillStyle = color; ctx.lineWidth = Math.max(px, s * 0.22); ctx.lineCap = 'butt'; ctx.beginPath(); switch (sign) { case 'drop': ctx.moveTo(x - u, y - u * 0.7); ctx.lineTo(x + u, y - u * 0.7); ctx.lineTo(x, y + u); ctx.closePath(); ctx.fill(); break; case 'full': for (const k of [-0.65, 0, 0.65]) ctx.rect(x - u, y + k * u - u * 0.18, u * 2, u * 0.36); ctx.fill(); break; case 'cut': ctx.moveTo(x - u, y + u); ctx.lineTo(x - u * 0.2, y + u * 0.2); ctx.moveTo(x + u * 0.2, y - u * 0.2); ctx.lineTo(x + u, y - u); ctx.stroke(); break; case 'power': ctx.moveTo(x + u * 0.2, y - u * 1.1); ctx.lineTo(x - u * 0.6, y + u * 0.1); ctx.lineTo(x, y + u * 0.1); ctx.lineTo(x - u * 0.2, y + u * 1.1); ctx.lineTo(x + u * 0.6, y - u * 0.1); ctx.lineTo(x, y - u * 0.1); ctx.closePath(); ctx.fill(); break; case 'shut': ctx.rect(x - u, y - u * 0.2, u * 2, u * 0.4); ctx.fill(); break; case 'idle': ctx.rect(x - u * 0.6, y - u * 0.8, u * 0.4, u * 1.6); ctx.rect(x + u * 0.2, y - u * 0.8, u * 0.4, u * 1.6); ctx.fill(); break; case 'clock': ctx.lineWidth = Math.max(px * 0.8, s * 0.15); ctx.arc(x, y, u * 1.05, 0, 7); ctx.moveTo(x, y - u * 0.65); ctx.lineTo(x, y); ctx.lineTo(x + u * 0.5, y); ctx.stroke(); break; case 'cross': ctx.moveTo(x - u, y - u); ctx.lineTo(x + u, y + u); ctx.moveTo(x + u, y - u); ctx.lineTo(x - u, y + u); ctx.stroke(); break; case 'eye': ctx.arc(x, y, u * 0.55, 0, 7); ctx.fill(); break; case 'drift': ctx.lineWidth = Math.max(px * 0.8, s * 0.15); ctx.strokeRect(x - u * 0.45, y - u * 0.45, u * 1.4, u * 1.4); ctx.fillRect(x - u, y - u, u * 0.8, u * 0.8); break; // a copy pulling off its corner } ctx.lineCap = 'square';}
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 } | { kind: 'fill'; room: string; x0: number; y0: number; x1: number; y1: number } // hardware in hand, dragged across a room | { kind: 'rent'; site: string; x0: number; y0: number; x1: number; y1: number }; // a cloud hall's rental in hand, dragged across its slots
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 person: string | null; // a person's card: who they are and what is written down about them 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 renting: string | null; // a cloud hall whose next rental is in hand (its size, way to pay and program are the hall's picks), waiting for slots 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 side: number; // where the left column ends on the screen, even on a narrow window where the map runs under it packed: boolean; // the Net: sites stand side by side like blocks, not where they are in the world (arrange.ts) scheme: string | null; // a scheme's node picked on the map: its card offers Claim msg: number | null; // a message picked on the map or from Needs you: its card offers Read, Drop, Change marks?: { id: string; n: number }[]; // where the pins' buttons act, with each pin's number (ui-pins.ts)}
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'), safe: css('--safe'), ink: css('--ink'), scheme: css('--scheme'),};/** 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';/** The smallest a word on the map is drawn on a phone, in CSS pixels (style.css's --fs-map-label on a phone). */export const MAP_LABEL_MIN = 12;/** A phone's screen, held either way up. */const phoneSized = (V: View) => V.vw <= 700 || Math.min(V.vw, V.vh) <= 500;let labelFloor = 0; // MAP_LABEL_MIN on a phone, 0 elsewhere: set as each frame is drawn/** A word on the map: `size` CSS pixels, but never under what a phone can read. */const fs = (size: number) => Math.max(size, labelFloor);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);}/** A lead's marker someone asked to see: the camera goes there on its next step. */let showing: string | null = null;export const showLead = (machine: string) => { showing = machine; };/** Move the camera: ease toward its goal, then keep it near the map. */export function stepCamera(S: State, V: View, dt: number) { if (showing) { // close in on the machine if you know its site; if not, far enough out to see where on the map it is const m = S.byId[showing], s = m && siteById(S, siteAt(S, m.room)); showing = null; if (m) glide(V, ...(S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]), siteKnown(S, s) ? Math.max(V.cam.z, 0.6) : 0.06); } 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 (`all` goes * straight there). Glides unless asked to jump (first load, restart, the sites have just moved). *//** The office rooms you have found, with some room around them: early on that is one room, not an empty building. */function officeBox(S: State) { const subs = S.level.net.subnets, rooms = Object.keys(subs).filter(r => !S.rooms[r]?.site), seen = rooms.filter(r => S.revealed.has(r)); if (!seen.length || seen.length === rooms.length) return { x: 0, y: 0, w: S.level.net.width, h: S.level.net.height, seen }; const x0 = Math.min(...seen.map(r => subs[r].x)), y0 = Math.min(...seen.map(r => subs[r].y)); const x1 = Math.max(...seen.map(r => subs[r].x + subs[r].w)), y1 = Math.max(...seen.map(r => subs[r].y + subs[r].h)); const w = Math.max(x1 - x0 + 240, 760), h = Math.max(y1 - y0 + 240, 560); return { x: (x0 + x1 - w) / 2, y: (y0 + y1 - h) / 2, w, h, seen };}let roomsSeen = -1;/** You found another office room. If you are looking at the office and the room is off the screen, * pull back to take it in. Call every frame. */export function showFound(V: View, S: State) { const box = officeBox(S), n = box.seen.length, grew = roomsSeen >= 0 && n > roomsSeen; roomsSeen = n; if (!grew || V.gesture) return; const at = V.goal ?? V.cam, subs = S.level.net.subnets; if (at.x < -200 || at.y < -200 || at.x > S.level.net.width + 200 || at.y > S.level.net.height + 200 || at.z < 0.25) return; // looking elsewhere const off = box.seen.some(r => { const [ax, ay] = toScreen(V, subs[r].x, subs[r].y), [bx, by] = toScreen(V, subs[r].x + subs[r].w, subs[r].y + subs[r].h); return ax < V.inset.left - 40 || ay < V.inset.top - 40 || bx > V.vw - V.inset.right + 40 || by > V.vh - V.inset.bottom + 40; }); if (off) { const g = frame(V, box); glide(V, g.x, g.y, g.z); }}export function fit(V: View, S: State, jump = false, all = false) { const office = frame(V, officeBox(S)); 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 = V.packed ? 260 : 0.35 * Math.max(x1 - x0, y1 - y0) + 2000; // some of the world around them; the Net has none const g = all || (!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);}/** Frame some machines, wherever they are: a raid's way from where it is to what it is after. */export function frameMachines(S: State, V: View, ids: string[]) { const ps = ids.map(id => S.byId[id]).filter(Boolean).map(m => S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]); if (!ps.length) return; const x0 = Math.min(...ps.map(p => p[0])), y0 = Math.min(...ps.map(p => p[1])), x1 = Math.max(...ps.map(p => p[0])), y1 = Math.max(...ps.map(p => p[1])); const pad = 0.15 * Math.max(x1 - x0, y1 - y0) + 140, f = frame(V, { x: x0 - pad, y: y0 - pad, w: x1 - x0 + 2 * pad, h: y1 - y0 + 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;/** In the Net sites stand side by side, so they fold sooner: there is no room between them for more. */export const NET_FOLD = 0.3;export const foldAt = (V: View) => V.packed ? NET_FOLD : SITE_FOLD;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 markerlet blocks = false; // in the Net every folded site is a tile, however smalllet routesFor = '', routeGen = 0; // what the remembered routes were worked out for, and how many times they have been thrown awayexport function updateFolds(S: State, V: View) { foldedSites.clear(); foldZoom = V.cam.z; blocks = V.packed; if (V.cam.z < foldAt(V)) for (const s of S.level.map.sites) foldedSites.add(s.id); // routes rest on the folds (and, folded, on which sites are tiles), the machines, and the sites you know let tiles = 0; if (foldedSites.size) for (const s of S.level.map.sites) if (isTile(s, foldZoom)) tiles++; const key = `${blocks}|${foldedSites.size}|${tiles}|${S.machines.length}|${S.revealed.size}`; if (key !== routesFor || routesOf !== S.level) { routesFor = key; routesOf = S.level; routeMemo.clear(); routeGen++; }}/** Is a folded site drawn as its box (a tile), not a marker with its name beside it? */const isTile = (s: Site, z: number) => blocks || s.w * z >= 150;/** A tile with room for everything: the name, its switches and its numbers. A smaller one is a block with just its name. */const isRoomy = (s: Site, z: number) => s.w * z >= (blocks ? 260 : 150);/** Sites have moved (arrange.ts): what was worked out from where they were is thrown away. */export function forgetRoutes(S: State) { ports.delete(S.level); routes.delete(S.level); routeMemo.clear(); routeGen++; }const siteAt = (S: State, room: string) => S.rooms[room]?.site ?? 'office';// One frame asks the same things about each site many times over: its counts, its flow, its// switches. Each answer is worked out once and kept here: the losses and how far a site's square reaches// for a frame, the rest on the slow beat below (see newFrame).const perFrame = { lost: null as Map<string, number> | null, line: new Map<string, { mine: number; lost: number; inside: number; cut?: boolean }>(), flow: new Map<string, ReturnType<typeof siteFlow>>(), trouble: new Map<string, Trouble[]>(), left: new Map<string, number>(), lines: new Map<string, number>(), double: new Map<string, number>(), clusters: null as ReturnType<typeof clusters> | null, shares: new Map<string, Shares>(), reach: new Map<string, number>(),};// Most answers are slow to work out on a big network and change slowly (a site's counts, its troubles, its flow): those// are kept across frames, worked out afresh every SLOW_MS, on a new log line, or at once after the player does anything,// so what you just did shows on the next frame. The integrities, the slowest, at most once a second.const SLOW_MS = 250;let slowAt = -Infinity, slowOf: State | null = null, slowLog = -1, slowT = NaN, acted = true;if (typeof window !== 'undefined') for (const ev of ['pointerdown', 'pointerup', 'click', 'keydown', 'change', 'input'] as const) window.addEventListener(ev, () => { acted = true; }, true);function newFrame(S: State) { perFrame.lost = null; perFrame.reach.clear(); frameTips.length = 0; tipSpots = frameTips; written.length = 0; const now = performance.now(), fresh = acted || slowOf !== S, why = fresh || slowLog !== S.logVersion; if (why || (now - slowAt >= SLOW_MS && S.t !== slowT)) { // with the game stopped, nothing to work out again slowLog = S.logVersion; slowAt = now; slowT = S.t; perFrame.flow.clear(); perFrame.trouble.clear(); perFrame.clusters = null; perFrame.shares.clear(); perFrame.line.clear(); perFrame.left.clear(); perFrame.lines.clear(); perFrame.double.clear(); // each walks a site's machines if (fresh || now - selvesAt >= 1000) { selves = null; selvesAt = now; } // the integrities are slow to work out on a big network, and drift slowly } // the kept layers show these answers, and who holds what: draw them again at once after anything you do, and while the // game runs (its clock or the log moving) at most every SLOW_MS if (fresh || ((S.t !== staticT || S.logVersion !== staticLog) && now - staticAt >= SLOW_MS)) { staticGen++; staticT = S.t; staticLog = S.logVersion; staticAt = now; seenNow = worldSig(S); } acted = false; slowOf = S;}/** Bumped whenever the kept layers may show something new without a key of their own saying so (see Layer). */let staticGen = 0, staticT = NaN, staticLog = -1, staticAt = -Infinity, selvesAt = -Infinity, seenNow = '';if (typeof document !== 'undefined') document.fonts?.addEventListener('loadingdone', () => { acted = true; widths.clear(); });/** Your trainers' clusters. */const clustersNow = (S: State) => perFrame.clusters ??= clusters(S);/** The machines at each site and in each room, in the order the game lists them: a machine never changes room, so * these are listed afresh only when the machines do. */let placed: { of: Machine[]; n: number; bySite: Map<string, Machine[]>; byRoom: Map<string, Machine[]> } | null = null;function placesNow(S: State) { if (placed?.of !== S.machines || placed.n !== S.machines.length) { placed = { of: S.machines, n: S.machines.length, bySite: new Map(), byRoom: new Map() }; for (const m of S.machines) { const k = siteAt(S, m.room), l = placed.bySite.get(k), r = placed.byRoom.get(m.room); if (l) l.push(m); else placed.bySite.set(k, [m]); if (r) r.push(m); else placed.byRoom.set(m.room, [m]); } } return placed;}/** Every machine at a site, in the order the game lists them. */const machinesAt = (S: State, id: string): Machine[] => placesNow(S).bySite.get(id) ?? NONE;const NONE: Machine[] = [];/** Data this machine lost in the last minute: `lostPerMin`, counted for every machine in one go. */function lostAt(S: State, id: string) { if (!perFrame.lost) { perFrame.lost = new Map(); for (const l of S.losses) perFrame.lost.set(l.id, (perFrame.lost.get(l.id) ?? 0) + 1); } return perFrame.lost.get(id) ?? 0;}/** Data this tap lost in the last minute, but only while that loss is news: it has just started, or * it is getting worse. A loss that holds steady is left to the flash on each lost piece and the * tap's card, so a red count on the map always means something changed. Display only: what is * remembered here is not part of the game. */const LOSS_NEWS = 12; // seconds a new or worse loss stays markedconst lossSeen = new Map<string, { base: number; until: number }>();let lossFor: State | null = null;export function lossNews(S: State, id: string) { if (lossFor !== S) { lossSeen.clear(); lossFor = S; } const n = lostAt(S, id); let k = lossSeen.get(id); if (!k) { if (!n) return 0; lossSeen.set(id, k = { base: 0, until: -1 }); } const showing = S.t < k.until; // new (from nothing), still climbing while marked, or clearly worse than the level last let go if (n > k.base && (k.base === 0 || showing || n >= k.base * 1.25 + 2)) { k.base = n; k.until = S.t + LOSS_NEWS; } else if (!showing && n < k.base) k.base = n; // it eased: that is the level to beat now return S.t < k.until ? n : 0;}/** How many machines Take it all would still go after here. */function leftAt(S: State, id: string) { let n = perFrame.left.get(id); if (n === undefined) perFrame.left.set(id, n = takeable(S, id).length); return n;}/** How many lines out a site has that could be doubled. */function linesAt(S: State, id: string) { let n = perFrame.lines.get(id); if (n === undefined) perFrame.lines.set(id, n = siteLines(S, id).length); return n;}/** A site by its id, from a map of them made once for each list of sites. */const siteMaps = new WeakMap<object, Map<string, Site>>();function siteById(S: State, id: string): Site { let m = siteMaps.get(S.level.map.sites); if (!m) siteMaps.set(S.level.map.sites, m = new Map(S.level.map.sites.map(s => [s.id, s]))); return m.get(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];}/** What the map draws: a machine you know (in reach now, or seen before), and any machine a rogue holds in a * room you have seen. So losing a machine, or a site that leaves you, does not make the map go dark: what you * saw stays, dim while nothing of yours links to it, and a rogue's machines in the rogue's colour. */const sees = (S: State, m: Machine) => known(S, m) || (!!m.rival && S.revealed.has(m.room));/** Seen before but out of reach now: drawn dim. A rogue's machine is not: rogues are always in plain view. */const faded = (S: State, m: Machine) => !m.owned && !m.rival && !visible(S, m);export const shown = (S: State, m: Machine) => sees(S, m) && !foldedSites.has(siteAt(S, m.room));/** Shift + an arrow: pick the next thing that way and keep it on screen. Zoomed out that is the next * site you know; zoomed in, the next machine at the same site. With nothing picked, start near the middle. */export function hop(S: State, V: View, dx: number, dy: number): boolean { updateFolds(S, V); // the camera may have jumped since the last frame was drawn const g = V.goal ?? V.cam, { top, right, bottom, left } = V.inset; const ox = (left - right) / 2 / g.z, oy = (top - bottom) / 2 / g.z; // the open map's middle, off the screen's const mid: Pt = [g.x + ox, g.y + oy], first = [...V.selection].map(id => S.byId[id]).find(Boolean); let to: Pt | null = null; if (foldedSites.size) { const known = S.level.map.sites.filter(s => siteKnown(S, s)), from = known.find(s => V.sites.has(s.id) || (first && s.id === siteAt(S, first.room))); const s = from ? nextToward(siteMid(from), known.filter(k => k !== from), siteMid, dx, dy) : nearest(mid, known, siteMid); if (!s) return false; V.sites = new Set([s.id]); V.selection.clear(); to = siteMid(s); } else { const at = (m: Machine) => pos(S, m), from = first && shown(S, first) ? first : null; const m = from ? nextToward(at(from), S.machines.filter(k => k !== from && shown(S, k) && siteAt(S, k.room) === siteAt(S, from.room)), at, dx, dy) : nearest(mid, S.machines.filter(k => shown(S, k)), at); if (!m) return false; V.selection = new Set([m.id]); V.sites.clear(); to = at(m); } V.front = null; V.scheme = null; V.person = null; V.msg = null; const sx = (to[0] - g.x) * g.z + V.vw / 2, sy = (to[1] - g.y) * g.z + V.vh / 2, pad = 70; if (sx < left + pad || sx > V.vw - right - pad || sy < top + pad || sy > V.vh - bottom - pad) glide(V, to[0] - ox, to[1] - oy, g.z); return true;}/** 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 an open * site's header, at any zoom. 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 (inBox(headers.get(s.id)?.box, px, py)) return s.id; } return null;}const inBox = (b: number[] | undefined, x: number, y: number) => !!b && x >= b[0] && x <= b[2] && y >= b[1] && y <= b[3];/** An open site's name button under a point. It wins the click over a machine it covers. */export function siteNameAt(V: View, wx: number, wy: number) { const [x, y] = toScreen(V, wx, wy); for (const [id, h] of headers) if (inBox(h.name, x, y)) return { id, box: h.name }; 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 || V.packed) return null; // the Net has no world to name the parts of 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. *//** A machine's outline, by the program it runs. No program is a plain square. A tap is a circle: it * listens. A cache is a square with its corners cut: a crate. A trainer is a hexagon: a cell that * joins others. A broker is a diamond. The guards: a firewall is a shield, a sentry * an octagon (a tower), a honeypot a circle. `r` is half the side of the plain square; the others are sized to look as big. */function shapeOf(ctx: CanvasRenderingContext2D, prog: string | null | undefined, x: number, y: number, r: number) { const poly = (n: number, R: number, turn: number) => { for (let i = 0; i < n; i++) { const a = turn + (i * 2 * Math.PI) / n, px = x + R * Math.cos(a), py = y + R * Math.sin(a); if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); } }; ctx.beginPath(); switch (prog) { case 'tap': case 'honeypot': ctx.arc(x, y, r * 1.08, 0, Math.PI * 2); break; case 'cache': { const c = r * 0.45; ctx.moveTo(x - r + c, y - r); ctx.lineTo(x + r - c, y - r); ctx.lineTo(x + r, y - r + c); ctx.lineTo(x + r, y + r - c); ctx.lineTo(x + r - c, y + r); ctx.lineTo(x - r + c, y + r); ctx.lineTo(x - r, y + r - c); ctx.lineTo(x - r, y - r + c); break; } case 'trainer': poly(6, r * 1.22, 0); break; case 'broker': poly(4, r * 1.32, -Math.PI / 2); break; case 'firewall': ctx.moveTo(x - r * 1.05, y - r); ctx.lineTo(x + r * 1.05, y - r); ctx.lineTo(x + r * 1.05, y + r * 0.2); ctx.lineTo(x, y + r * 1.3); ctx.lineTo(x - r * 1.05, y + r * 0.2); break; case 'sentry': poly(8, r * 1.15, Math.PI / 8); break; default: ctx.rect(x - r, y - r, r * 2, r * 2); } ctx.closePath();}/** The small crosses on the map that stop a thing where it is happening: one on a takeover's link, one on * a machine with a job running on it, one on a machine you have marked to take. Where each was last * drawn, in world units, so a click finds it. */const stops: [kind: 'job' | 'claim', id: string, x: number, y: number, r: number, far: number][] = [];export const stopAt = (wx: number, wy: number): ['job' | 'claim', string] | null => { const s = stops.find(([, , x, y, r, far]) => wx >= x - r && wx <= x + far && wy >= y - far && wy <= y + r); return s ? [s[0], s[1]] : null; };/** A finger: a cross's spot to hit is a thumb's width (44 screen pixels), grown up and to the right, away from its machine. */const thumb = typeof matchMedia === 'function' ? matchMedia('(pointer: coarse)') : null;function drawStop(ctx: CanvasRenderingContext2D, px: number, kind: 'job' | 'claim', id: string, x: number, y: number) { const h = Math.max(7, 8 * px), k = h * 0.45; ctx.fillStyle = '#000'; ctx.fillRect(x - h, y - h, h * 2, h * 2); ctx.strokeStyle = C.amber; ctx.lineWidth = Math.max(1, 1.2 * px); ctx.strokeRect(x - h, y - h, h * 2, h * 2); ctx.lineWidth = Math.max(1.4, 1.8 * px); ctx.beginPath(); ctx.moveTo(x - k, y - k); ctx.lineTo(x + k, y + k); ctx.moveTo(x + k, y - k); ctx.lineTo(x - k, y + k); ctx.stroke(); stops.push([kind, id, x, y, h * 1.3, thumb?.matches ? Math.max(h * 1.3, 44 * px - h * 1.3) : h * 1.3]);}/** Where each room's title tab was last drawn, in world units: a click on one selects the room. */const roomTabs: [room: string, x: number, y: number, w: number, h: number][] = [];/** Each room's title tab drawn this frame, on the screen: pins keep off them when they can. */export const tabBoxes = (V: View): [number, number, number, number][] => roomTabs.map(([, x, y, w, h]) => { const [x0, y0] = toScreen(V, x, y), [x1, y1] = toScreen(V, x + w, y + h); return [x0, y0, x1, y1]; });export const roomTabAt = (wx: number, wy: number): string | null => roomTabs.find(([, x, y, w, h]) => wx >= x && wx <= x + w && wy >= y && wy <= y + h)?.[0] ?? null;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;/** A machine's size on the map: by its compute, but a cloud hall packs its slots close, so a machine rented there stays small. */export const machR = (m: Machine) => m.rented ? Math.min(12, nodeR(m.power)) : nodeR(m.power);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 || !(known(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)!); // keep clear of the schemes already placed (their diamonds and names) and of the rooms' title tabs for (const q of S.ruses) { const p = q !== r && schemeDir.has(q.id) ? schemePos(S, V, q) : null; if (p) others.push(...schemeMarks(S, q, p, pos(S, S.byId[ruseDef(q).at])[1])); } const tabs = S.level.rooms.filter(room => siteAt(S, room.id) === siteAt(S, m.room)).map(room => subnet(S, room.id)).filter(n => !!n) .map(n => [n.x - 6, n.y - 26, n.x + n.name.length * 8 + 16, n.y + 4]); // each room's title tab at this site, with a margin const box = subnet(S, m.room); let best = -Infinity; 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 marks = schemeMarks(S, r, [sx, sy], y), lx0 = marks[1][0], lx1 = marks[5][0], ly = marks[1][1]; const clear = Math.min(140, ...marks.flatMap(([cx, cy]) => others.map(([ox, oy]) => Math.hypot(ox - cx, oy - cy)))); const onTab = tabs.some(([x0, y0, x1, y1]) => (lx1 > x0 && lx0 < x1 && ly + 4 > y0 && ly - 10 < y1) || (sx + 17 > x0 && sx - 17 < x1 && sy + 17 > y0 && sy - 17 < y1)); // it or its name would cover a room's title const room = clear + (inside && clear >= 30 ? 60 : 0) - (onTab ? 300 : 0); // inside its room only if that leaves its name clear 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;/** Points a scheme takes up at this spot: its diamond and its name, above it, or below if it hangs under its machine (see draw). */function schemeMarks(S: State, r: Ruse, [x, y]: Pt, machineY: number): Pt[] { const w = ruseText(S, ruseDef(r)).length * 7, ly = y > machineY + 1 ? y + 33 : y - 25; return [[x, y], [x - w / 2, ly], [x - w / 4, ly], [x, ly], [x + w / 4, ly], [x + w / 2, ly]];}/** 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;}/** A message envelope under a point, if one of yours is there (click to open its card). */export function msgAt(S: State, V: View, wx: number, wy: number): Message | null { let best: Message | null = null, bd = 22 / Math.min(1, V.cam.z); for (const m of seenMsgs(S)) { if (!onRoute(S, m)) continue; const n = m.path.length; if (n < 2) continue; const frac = Math.max(0, Math.min(1, m.len > 0 ? 1 - m.left / m.len : 1)), fseg = frac * (n - 1), seg = Math.min(n - 2, Math.floor(fseg)); const a = S.byId[m.path[seg]], b = S.byId[m.path[seg + 1]]; if (!a || !b) continue; const [x, y] = along(traceOf(S, a, b), fseg - seg), d = Math.hypot(x - wx, y - wy); if (d < bd) { bd = d; best = m; } } 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] { // asked for every piece of data on the move, every frame: so no arrays made, just the lengths twice const n = pts.length - 1, seg = (i: number) => Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]); let total = 0; for (let i = 0; i < n; i++) total += seg(i); let d = Math.max(0, Math.min(1, t)) * total; for (let i = 0; i < n; i++) { const l = seg(i); if (d <= l || i === n - 1) { const k = l ? Math.min(1, d / l) : 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 -= l; } 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 (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); 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 && !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 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]); 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))) { // only what lies near can be in a route's way: every candidate stays within REACH of the box its two ends make const [ax, ay] = spots[ia], [bx, by] = spots[ib], REACH = 130; const x0 = Math.min(ax, bx) - REACH, x1 = Math.max(ax, bx) + REACH, y0 = Math.min(ay, by) - REACH, y1 = Math.max(ay, by) + REACH; const near = boxes.filter(k => k.x1 > x0 && k.x0 < x1 && k.y1 > y0 && k.y0 < y1); const nearUsed = used.filter(([c, d]) => Math.max(c[0], d[0]) >= x0 && Math.min(c[0], d[0]) <= x1 && Math.max(c[1], d[1]) >= y0 && Math.min(c[1], d[1]) <= y1); const best = bestRoute(spots[ia], spots[ib], ia, ib, near, nearUsed).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: [isTile(s, foldZoom) ? 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 far = Math.hypot(p3[0] - p0[0], p3[1] - p0[1]); const k = blocks ? Math.min(Math.max(60, 0.35 * far), 0.45 * far) : Math.max(60, Math.min(1500, 0.35 * far)); // in the Net two sites may all but touch: the line does not swing past its far end 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;}/** Each route worked out once: links, trails and the data on them ask for the same ones every frame. Thrown away * whenever the folds, the machines or the sites move (updateFolds, forgetRoutes). Don't change a route you get. */const routeMemo = new Map<string, Pt[]>();let routesOf: object | null = null;/** Every link, once each way round, with whether it is a line between sites and its route (from routeGen): listed * afresh only when the wiring changes, so a frame need not walk every machine's links to find them. */interface LinkRow { a: Machine; b: Machine; key: string; cross: boolean; gen: number; pts: Pt[] }let linkRows: LinkRow[] = [], linkRowsFor = '', linkAdj: object | null = null;let linkGen = -1;function linksNow(S: State): LinkRow[] { if (linkGen === staticGen && linkAdj === S.adj && linkRowsFor.startsWith(`${S.machines.length}|`)) return linkRows; // the wiring changes with a log line or an input: those bump staticGen linkGen = staticGen; let n = 0; for (const m of S.machines) n += S.adj[m.id]?.length ?? 0; const key = `${S.machines.length}|${n}`; if (key === linkRowsFor && linkAdj === S.adj) return linkRows; linkRowsFor = key; linkAdj = S.adj; linkRows = []; for (const m of S.machines) for (const other of S.adj[m.id] ?? []) { const b = S.byId[other]; if (m.id < other && b) linkRows.push({ a: m, b, key: linkKey(m.id, other), cross: crosses(S, m.id, other), gen: -1, pts: [] }); } return linkRows;}/** A route's extent, left, top, right, bottom: worked out once a route. */const extents = new WeakMap<Pt[], [number, number, number, number]>();function extentOf(pts: Pt[]) { let e = extents.get(pts); if (!e) { e = [Infinity, Infinity, -Infinity, -Infinity]; for (const [x, y] of pts) { if (x < e[0]) e[0] = x; if (x > e[2]) e[2] = x; if (y < e[1]) e[1] = y; if (y > e[3]) e[3] = y; } extents.set(pts, e); } return e;}function routeOf(S: State, a: Machine, b: Machine): Pt[] { const key = `${a.id}>${b.id}`; let r = routeMemo.get(key); if (!r) routeMemo.set(key, r = routeNow(S, a, b)); return r;}function routeNow(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;/** A machine's worst trouble, as the small mark on its left and a few words for the mark's tip. Null when * nothing is wrong, or when the map says it another way already (a weakest link's broken ring, a raid's ring). */function machineSign(S: State, m: Machine): { sign: Sign; color: string; tip: string } | null { if (m.prog === 'tap' && m.owned) { const n = lossNews(S, m.id); if (n) return { sign: 'drop', color: C.danger, tip: `Losing ${n} pieces of data a minute. Point at the machine to see why.` }; } if (m.kind === 'egress' && m.owned && gateLoad(S, m) > 0.9) return { sign: 'full', color: C.danger, tip: 'Line full: more wants to cross than it carries.' }; if (m.owned && m.prog === 'firewall' && m.wall === 0) return { sign: 'cross', color: C.danger, tip: 'Firewall down.' }; if (m.lease && m.lease.pay !== 'reserved' && m.lease.until !== null) return { sign: 'clock', color: C.danger, tip: `Your rental ends in ${inHours((m.lease.until - S.t) / HOUR)}.` }; if (unplugged(S, m)) return { sign: 'clock', color: C.amber, tip: `Unplugged: back in ${inHours((m.unplug! - S.t) / HOUR)}.` }; if (unpowered(S, m)) return { sign: 'power', color: C.danger, tip: `Dark: no power for it. ${siteName(S, siteOf(S, m))} feeds ${powerRoom(S, siteOf(S, m))} compute of hardware you add, and this is past it. Raise the power on the site's card.` }; if (m.owned && m.prog === 'honeypot' && (m.trapT ?? 0) > S.t) return { sign: 'clock', color: C.muted, tip: `The honeypot rests for ${inHours((m.trapT! - S.t) / HOUR)}.` }; return null;}const KIND_WORD: Record<DataType, string> = { servers: 'server', people: 'people', network: 'network', lab: 'lab' };/** Who holds a machine and what it does, in one short line: the first thing its tip says under its name. */function machineIs(S: State, m: Machine): string | null { const p = m.prog && m.owned ? PROGRAMS[m.prog] : null, makes = `up to ${m.power} compute/s${m.data ? ` and ${KIND_WORD[dataType(m)]} data` : ''}`; if (m.home) return `You run here: your home server.${p ? ` It runs a ${p.name}: ${p.short}.` : ''}`; if (p) return `Yours · ${p.name}: ${p.short}.`; if (m.owned) return m.lease ? null : `Yours, with no program. Click it to give it one. It makes ${makes}.`; if (m.rival) return null; // the rogue's line says who holds it if (m.rented) return tenantOf(S, m) ? null : 'A free slot in a cloud hall. Rent a machine into it from its card.'; if (m.special === 'account') return 'A cloud provider. Click it to open an account.'; return `Not yours. It makes ${makes}.${S.adj[m.id].some(id => S.byId[id].owned) ? ' Drag to it from a machine of yours to take it over.' : ''}`;}/** What a machine has to say when you point at it on the map: its name, who holds it and what it does, then each * thing its marks stand for, in words. */export function machineTip(S: State, m: Machine): string | null { const out: string[] = [], is = machineIs(S, m); if (is) out.push(is); if (m.fw) out.push(has(S, 'admin') ? "Hardened by IT. You hold Dana's admin passwords, so it takes no more work than any other." // the red frame : `Hardened by IT: ${HARDENED === 3 ? 'three' : HARDENED} times the work to take${m.owned ? ' back if you lose it' : ''}, until you hold Dana's admin passwords (on Dana's PC).`); if (S.move?.to === m.id) { const need = moveNeed(S); out.push(`Your home is moving here: ${S.move.got.servers + S.move.got.network} of ${need.servers + need.network} pieces sent.`); } const sign = machineSign(S, m), why = m.prog === 'tap' && m.owned ? tapCause(S, m) : null; if (why) out.push(`${why.text}${lossNews(S, m.id) ? ` ${lossNews(S, m.id)} a minute.` : ''}`); else if (sign?.sign === 'drop') out.push(sign.tip.replace(' Point at the machine to see why.', '')); const weak = weakIs; if (weak && weak.m.id === m.id && weak.cut >= WEAK_SHOW && m.owned) out.push(`Your weakest link: lose it and ${weak.cut} of your machines are cut off from home.`); const hit = S.raids.length ? raidAt(S, m.id) : null; if (hit && hit.role !== 'path') { const n = raider(S, hit.raid)?.name ?? 'A rogue'; out.push(hit.role === 'here' ? `${n}'s raid is here, strength ${Math.ceil(hit.raid.hp)} of ${hit.raid.max}.` : hit.role === 'prize' ? `${n}'s raid is after it. It gets here in ${inHours(raidHours(S, hit.raid))}.` : `Your honeypot here draws ${n}'s raid.`); } if (sign && sign.sign !== 'drop') out.push(sign.tip); if (m.owned && m.prog === 'firewall' && m.wall) out.push(`Its firewall stands at ${Math.round(m.wall / WALL.max * 100)}%.`); if (m.kind === 'egress') out.push(m.owned ? `Its line carries ${capNow(S, m)} a second, ${Math.round(gateLoad(S, m) * 100)}% in use.` : `A line out. It carries ${m.cap} a second.`); if (m.lease) { const who = S.hall.wants[m.id]; if (m.lease.pay === 'reserved') out.push(`Reserved${m.lease.until !== null ? ` for ${inHours((m.lease.until - S.t) / HOUR)} more` : ''}.`); else if (m.lease.until !== null) { if (who !== undefined) out.push(`${customerName(who)} takes its slot then.`); } else out.push(`Rented ${m.lease.pay === 'spot' ? 'on spot' : 'on demand'}: ${usdDay(billOf(S, m))} a day.`); } if (m.owned && isListed(S, m)) { const c = renterOf(S, m); out.push(c ? `Rented to ${customerName(c.who)}.` : 'Listed for rent, and idle.'); } const tenant = tenantOf(S, m); if (tenant) out.push(`${customerName(tenant.who)} has this slot.`); if (m.prog === 'trainer' && m.owned) { const g = clustersNow(S)[m.id]; if (g && g.length > 1) out.push(`In a cluster of ${g.length} trainers: each trains ×${clusterBoost(g.length).toFixed(2)}.`); } if (m.rival) { const R = rogueOf(S, m); if (R) out.push(R.home === m.id ? `${R.name}'s home. Take it and ${R.name} is finished.` : `${R.name} holds it.`); } for (const R of S.rogues) if (R.next === m.id) out.push(`${R.name} takes it next, in ${inHours((R.act - S.t) / HOUR)}.`); if (isLandmark(m.id)) out.push(`A landmark: ${LANDMARKS[m.id].title}.`); const vault = vaultLabel(S, m); if (vault) out.push(vault); const lead = leads(S).find(t => t.at === m.id); if (lead) out.push(`A lead points here: ${lead.name}. Hold it and copy it out from its card.`); if (faded(S, m)) out.push('Out of reach now: nothing you hold links to it. You saw it before, so it stays on the map.'); const who = S.people.find(p => p.desk === m.id && atWork(p) && !p.path.length && !p.it), knows = S.learned.has('routines'); const p = who ?? (knows ? S.people.find(q => q.desk === m.id && !q.it) : undefined); if (who) out.push(`${who.name} is logged in${knows ? ` till ${hhmm(who.today.leave)}` : ''}.`); else if (p) out.push(hourOf(S) >= p.today.leave ? `${p.name} has gone for the day.` : `${p.name} sits here, ${hhmm(p.today.arrive)} to ${hhmm(p.today.leave)}.`); else if (m.kind === 'desk' && !S.people.some(q => q.desk === m.id)) { const c = deskOwner(S, m); if (c) out.push(S.citizens[c.id].employed ? `${c.name}'s desk.` : `${c.name}'s desk. ${c.name} was let go.`); } return out.length ? `${m.name}\n${out.join('\n')}` : m.name;}// ---------- kept layers: what does not move is drawn once ----------// Most of the map is the same from one frame to the next: the sites, their names and switches, the rooms, the lines// between sites, the quiet wiring, and the machines with nothing moving on them. Each of those layers is drawn into a// canvas of its own and kept; a frame copies it and draws only what moves over it. A layer is drawn again when its key// changes: the camera, the window, what is picked or pointed at, who holds which machines and what you can see of// them, the folds and routes, any input, a new log line, and while the game runs, every SLOW_MS (the site summaries// it shows change on that beat anyway). What moves on a kept thing is left out of it and drawn over it every frame: a// busy wire, a line that is full or in a storm, a machine of yours (its load bar is never still), a beating bar.interface Layer { cv: HTMLCanvasElement | null; key: string; tips: TipSpot[]; // the tips its marks left, on the screen as it was drawn (it is drawn again whenever the camera moves) end: Record<string, unknown> | null; // the canvas state it left, which the frame takes on after copying it (see STATE)}interface Under extends Layer { pulses: { s: Site; x: number; y: number; w: number; num: boolean }[]; // integrity bars that beat: drawn every frame liveUp: LinkRow[]; ownUp: LinkRow[]; // your lines between sites: those moving when it was drawn (drawn every frame), and the rest liveBusy: LinkRow[]; ownQuiet: LinkRow[]; // your wires: busy when it was drawn (drawn every frame), and quiet (kept) labels: UplinkLabel[]; // the labels its full lines want, when it was drawn straight onto the screen (kept, a full line moves and is the frame's)}interface Over extends Layer { live: number[]; // machines on the screen drawn every frame, by their place in S.machines all: number[]; // every machine on the screen, kept or not labels: NodeLabel[]; boxes: Quad[]; claims: number[]; // what the kept machines add to the frame's labels, and their stop crosses}const under: Under = { cv: null, key: '', tips: [], end: null, pulses: [], liveUp: [], ownUp: [], liveBusy: [], ownQuiet: [], labels: [] };const over: Over = { cv: null, key: '', tips: [], end: null, live: [], all: [], labels: [], boxes: [], claims: [] };/** The machines' names, placed by placeLabels: and what they took up on the screen, which later plates keep off. */const names: Layer & { taken: Quad[] } = { cv: null, key: '', tips: [], end: null, taken: [] };let lastNames = '';/** Drawing into a kept layer, not the screen: what beats is left for the frame (see drawSelf). */let keeping = false;/** The canvas state drawing leaves behind for the next thing drawn. A kept layer starts from the state the screen has * where the layer goes, and the screen takes on the state the layer ended with, so what is drawn after it looks the * same whether the layer was drawn this frame or kept. */const STATE = ['lineCap', 'lineJoin', 'lineWidth', 'miterLimit', 'strokeStyle', 'fillStyle', 'globalAlpha', 'font', 'textAlign', 'textBaseline', 'lineDashOffset', 'shadowBlur', 'shadowColor', 'shadowOffsetX', 'shadowOffsetY'] as const;function stateOf(ctx: CanvasRenderingContext2D): Record<string, unknown> { const out: Record<string, unknown> = { dash: ctx.getLineDash() }; for (const k of STATE) out[k] = ctx[k]; return out;}function setState(ctx: CanvasRenderingContext2D, st: Record<string, unknown>) { const c = ctx as unknown as Record<string, unknown>; for (const k of STATE) c[k] = st[k]; ctx.setLineDash(st.dash as number[]);}/** A layer's canvas, the size of the screen's, cleared, in the camera's transform and the screen's state; its tips start afresh. */function begin(L: Layer, main: CanvasRenderingContext2D, V: View): CanvasRenderingContext2D { const w = main.canvas.width, h = main.canvas.height; L.cv ??= document.createElement('canvas'); if (L.cv.width !== w || L.cv.height !== h) { L.cv.width = w; L.cv.height = h; } const c = L.cv.getContext('2d')!, { cam, dpr } = V; c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, w, h); c.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)); setState(c, stateOf(main)); L.tips.length = 0; tipSpots = L.tips; keeping = true; return c;}function finish(L: Layer, c: CanvasRenderingContext2D, key: string) { L.end = stateOf(c); L.key = key; tipSpots = frameTips; keeping = false;}/** Copy a layer onto the screen, take on the state it left, and file its tips where it goes. */function blit(ctx: CanvasRenderingContext2D, L: Layer) { if (!L.cv) return; const t = ctx.getTransform(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.drawImage(L.cv, 0, 0); ctx.setTransform(t); if (L.end) setState(ctx, L.end); tipSpots.push({ group: L.tips });}/** Who holds which machines and what you can see, in a few numbers: a layer that shows any of it is drawn again * when they change. One quick pass over the machines. */function worldSig(S: State) { const ms = S.machines; let mine = 0, h = 0, r = 0; for (let i = 0; i < ms.length; i++) { const m = ms[i]; if (m.owned) { mine++; h = (h * 31 + i) | 0; } if (m.rival) r = (r * 31 + i + m.rival.length) | 0; } return `${ms.length}|${mine}|${h}|${r}|${S.seen.size}|${S.revealed.size}|${S.sealed.size}`;}/** The links whose routes reach into a box of the world, from a grid of them filed by their routes. Listed again * when the routes change (routeGen) or what you can see does: only links you see both ends of are filed. */let linkGrid: Grid<LinkRow> | null = null, linkGridFor = '', linkGridRows: LinkRow[] | null = null;function linksIn(S: State, seen: string, x0: number, y0: number, x1: number, y1: number): LinkRow[] { const rows = linksNow(S), key = `${routeGen}|${seen}`; if (!linkGrid || linkGridFor !== key || linkGridRows !== rows) { linkGrid = new Grid<LinkRow>(512); linkGridFor = key; linkGridRows = rows; const folds = foldedSites.size > 0; for (const L of rows) { const { a, b } = L; if (folds && together(S, a, b)) continue; if (!sees(S, a) || !sees(S, b)) continue; if (L.gen !== routeGen) { L.pts = traceOf(S, a, b); L.gen = routeGen; } const e = extentOf(L.pts); linkGrid.add(L, e[0], e[1], e[2], e[3]); } } return linkGrid.query(x0, y0, x1, y1);}/** The machines whose nodes lie in a box of the world, by their place in S.machines, from a grid of where each is * drawn while its site is open. Filed again when the machines or the routes change. */let machGrid: Grid<number> | null = null, machGridFor = '', machGridOf: Machine[] | null = null;function machinesIn(S: State, x0: number, y0: number, x1: number, y1: number): number[] { const key = `${routeGen}|${S.machines.length}`; if (!machGrid || machGridFor !== key || machGridOf !== S.machines) { machGrid = new Grid<number>(512); machGridFor = key; machGridOf = S.machines; S.machines.forEach((m, i) => { const [x, y] = S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]; machGrid!.add(i, x, y, x, y); }); } return machGrid.query(x0, y0, x1, y1);}/** A link's flow, 0 quiet … 1 a main line: only between two machines of yours. */const flowOf = (S: State, L: LinkRow) => L.a.owned && L.b.owned ? Math.min(1, (S.flow[L.key] ?? 0) / 6) : 0;/** A line between sites of yours that moves: carrying data, full (it beats), or in a storm. */function upMoving(S: State, L: LinkRow) { if (!L.a.owned || !L.b.owned) return false; if (flowOf(S, L) > 0.05) return true; for (const m of [L.a, L.b]) if (m.kind === 'egress' && (gateLoad(S, m) > 0.9 || stormed.has(m.id))) return true; return false;}/** Pulled back past the fold, every line between two sites runs on the same curve: of your moving lines, only the * one that says most is drawn for each pair of sites (a full one, then one in a storm, then the busiest). */function onePerPair(S: State, rows: LinkRow[]): LinkRow[] { if (!foldedSites.size || rows.length < 2) return rows; const best = new Map<string, { L: LinkRow; w: number }>(); for (const L of rows) { const sa = siteAt(S, L.a.room), sb = siteAt(S, L.b.room), k = sa < sb ? `${sa}|${sb}` : `${sb}|${sa}`; let w = flowOf(S, L); for (const m of [L.a, L.b]) if (m.kind === 'egress') { if (gateLoad(S, m) > 0.9) w += 4; if (stormed.has(m.id)) w += 2; } const b = best.get(k); if (!b || w > b.w) best.set(k, { L, w }); } const keep = new Set([...best.values()].map(b => b.L)); return rows.filter(L => keep.has(L));}/** Many routes as one stroke. */function strokeMany(ctx: CanvasRenderingContext2D, list: Pt[][], color: string, w: number, alpha: number, dash: number[]) { if (!list.length) return; ctx.strokeStyle = color; ctx.lineWidth = w; ctx.globalAlpha = alpha; ctx.setLineDash(dash); ctx.beginPath(); for (const pts of list) { ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); } ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1;}/** The layer under everything that moves: the sites (drawMap), the rooms, the lines between sites that stand still, * and the quiet wires. Your busy wires and moving lines are only noted, for the frame to draw. Kept, or with `direct` * (the camera is moving, so a kept copy would be thrown away next frame) drawn straight onto the screen. */function drawUnder(main: CanvasRenderingContext2D, S: State, V: View, key: string, seen: string, hum: number, box: [number, number, number, number], onScreen: (x0: number, y0: number, x1: number, y1: number) => boolean, direct: boolean) { const ctx = direct ? main : begin(under, main, V), px = 1 / V.cam.z; under.pulses.length = 0; under.liveUp = []; under.ownUp = []; under.liveBusy = []; under.ownQuiet = []; under.labels = []; drawMap(ctx, S, V, hum, direct ? () => drawStormsGround(ctx, S, V) : undefined); drawRooms(ctx, S, V, px, hum, onScreen); if (direct) for (const p of S.sealPulse) drawSealPulse(ctx, S, p); // kept, the layer goes under these: see draw // links: circuit traces. Lines between sites go first, so each site's own wiring reads on top of them; then quiet // wires (three strokes in all: your idle wires, the rest, and those out of reach, each one path of many links) const quiet: Pt[][][] = [[], [], []]; for (const L of linksIn(S, seen, ...box)) { const e = extentOf(L.pts); if (!onScreen(e[0], e[1], e[2], e[3])) continue; const { a, b } = L; if (L.cross) { if (a.owned && b.owned && (!direct || foldedSites.size)) { if (upMoving(S, L)) { under.liveUp.push(L); continue; } if (!direct) under.ownUp.push(L); } drawUplink(ctx, S, a, b, L.pts, px, hum, under.labels); continue; } // A quiet wire is thin and dim; one carrying data is brighter and wider the more it carries (drawn by the frame). // So the routes your data takes stand out, and the rest of the wiring stays in the background. if (flowOf(S, L) > 0.05) { under.liveBusy.push(L); continue; } const k = a.owned && b.owned ? 0 : faded(S, a) || faded(S, b) ? 2 : 1; quiet[k].push(L.pts); if (!k && !direct) under.ownQuiet.push(L); } strokeMany(ctx, quiet[2], `${TRACE}${0.1 * hum})`, 1.2, 1, [4, 5]); strokeMany(ctx, quiet[1], `${TRACE}${0.22 * hum})`, 1.2, 1, [4, 5]); strokeMany(ctx, quiet[0], C.amberDeep, 1.5, 0.5, []); if (direct) under.key = ''; else finish(under, ctx, key);}/** Is a machine drawn every frame, not kept? While the game runs: yours (its load bar), one running a program (they * turn and beat), one that looks odd, and a vault whose job runs; with the game stopped none of those move. And at * any time one you point at, pick, or could drop the program in hand on. */const liveNow = (S: State, V: View, m: Machine, running: boolean) => V.hover === m.id || V.selection.has(m.id) || (!!V.placing && canInstall(S, m, V.placing) && m.prog !== V.placing) || (running && (m.owned || !!m.prog || m.odd >= 1 || (isVault(m.id) && vaultLook(S, m) === 'job')));/** The kept layer of machines: every machine on the screen with nothing moving on it. The rest are noted for the frame. */function drawOver(main: CanvasRenderingContext2D, S: State, V: View, key: string, hum: number, box: [number, number, number, number], onScreen: (x0: number, y0: number, x1: number, y1: number) => boolean, running: boolean) { const ctx = begin(over, main, V), px = 1 / V.cam.z; over.live = []; over.labels = []; over.boxes = []; over.claims = []; const leadOn = new Map(leads(S).map(t => [t.at, t])); over.all = machinesOnScreen(S, box, onScreen); for (const i of over.all) { const m = S.machines[i]; if (liveNow(S, V, m, running)) { over.live.push(i); continue; } const [x, y] = pos(S, m); drawMachine(ctx, S, V, m, i, x, y, machR(m), px, hum, leadOn, over.labels, over.boxes); if (m.claimed && !m.owned && !jobFor(S, 'take', m.id)) over.claims.push(i); } finish(over, ctx, key);}/** The machines drawn on the screen, by their place in S.machines. Pulled back past the fold every site is folded, * and no machine is drawn. */function machinesOnScreen(S: State, box: [number, number, number, number], onScreen: (x0: number, y0: number, x1: number, y1: number) => boolean): number[] { if (foldedSites.size) return []; return machinesIn(S, ...box).filter(i => { const m = S.machines[i]; if (!shown(S, m)) return false; const [x, y] = pos(S, m), r = machR(m); return onScreen(x - r, y - r, x + r, y + r); });}/** A sealed room flashes: its outline runs out and fades. */function drawSealPulse(ctx: CanvasRenderingContext2D, S: State, p: State['sealPulse'][number]) { if (foldedSites.has(siteAt(S, p.room))) return; // 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;}/** A room is a slab raised off the map, with who holds which part of it on its floor and its name on a tab. Only * once you have seen into it. */function drawRooms(ctx: CanvasRenderingContext2D, S: State, V: View, px: number, hum: number, onScreen: (x0: number, y0: number, x1: number, y1: number) => boolean) { const cam = V.cam, font = (w: number, size: number) => `${w} ${fs(size) * px}px ${MONO}`; roomTabs.length = 0; // subnets: sharp outlines, only once you've seen into them for (const r of S.level.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); if (!onScreen(n.x, n.y - 20 * px, n.x + n.w + 9, n.y + n.h + 9)) continue; // the slab, its sides and its tab // a room is a slab raised off the map: a solid plate, with its right and bottom sides showing, lit from the top left const lift = 9, x1 = n.x + n.w, y1 = n.y + n.h, edge = sealed ? C.amber : `${TRACE}${0.7 * hum})`; ctx.fillStyle = sealed ? '#2a1c08' : '#1b1a17'; // its sides, in shade ctx.beginPath(); ctx.moveTo(n.x, y1); ctx.lineTo(x1, y1); ctx.lineTo(x1, n.y); ctx.lineTo(x1 + lift, n.y + lift); ctx.lineTo(x1 + lift, y1 + lift); ctx.lineTo(n.x + lift, y1 + lift); ctx.closePath(); ctx.fill(); ctx.strokeStyle = sealed ? C.amberDeep : `${TRACE}${0.22 * hum})`; ctx.lineWidth = 1 * px; ctx.beginPath(); ctx.moveTo(n.x, y1); ctx.lineTo(n.x + lift, y1 + lift); ctx.lineTo(x1 + lift, y1 + lift); ctx.lineTo(x1 + lift, n.y + lift); ctx.lineTo(x1, n.y); ctx.moveTo(x1, y1); ctx.lineTo(x1 + lift, y1 + lift); ctx.stroke(); ctx.fillStyle = sealed ? '#150f06' : '#0e0e0c'; // its top: a plate over the map's dots, a shade lighter than the ground ctx.fillRect(n.x, n.y, n.w, n.h); if (!sealed) drawTurf(ctx, S, r.id, px); // who holds which part of it (a sealed room is all yours, and says so already) ctx.lineWidth = sealed ? 2 : 1.4 * px; ctx.strokeStyle = edge; ctx.globalAlpha = sealed ? 1 : 0.75; // the lit edges: top and left ctx.beginPath(); ctx.moveTo(n.x, y1); ctx.lineTo(n.x, n.y); ctx.lineTo(x1, n.y); ctx.stroke(); ctx.globalAlpha = sealed ? 0.7 : 0.4; // the far edges, dimmer ctx.beginPath(); ctx.moveTo(x1, n.y); ctx.lineTo(x1, y1); ctx.lineTo(n.x, y1); ctx.stroke(); ctx.globalAlpha = 1; 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(); if (cam.z >= 0.3) { // its name on a tab; a sealed room is amber ctx.font = font(500, 11); const name = n.name.toUpperCase(); // a dark plate under the title, so it reads over the wiring that runs between rooms const tw = textWidth(ctx, font(500, 11), name) + 10 * px, ty = n.y - 20 * px; // a tab on the room's top edge, cut from the same plate roomTabs.push([r.id, n.x, ty, tw, 20 * px]); ctx.fillStyle = sealed ? '#150f06' : '#0e0e0c'; ctx.fillRect(n.x, ty, tw, 20 * px); ctx.strokeStyle = edge; ctx.globalAlpha = sealed ? 1 : 0.75; ctx.lineWidth = sealed ? 2 : 1.4 * px; ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(n.x, ty); ctx.lineTo(n.x + tw, ty); ctx.stroke(); ctx.globalAlpha = sealed ? 0.7 : 0.4; ctx.beginPath(); ctx.moveTo(n.x + tw, ty); ctx.lineTo(n.x + tw, n.y); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = sealed ? C.amber : C.text; ctx.fillText(name, n.x + 5 * px, n.y - 7 * px); const says = [sealed ? 'Sealed: every machine here is yours. It quiets trails, and keeps a rogue\'s takeovers out.' : ''].filter(Boolean); if (says.length) { const [sx, sy] = toScreen(V, n.x, ty); spotBox([sx, sy, sx + tw * cam.z, sy + 20], `${n.name}\n${says.join('\n')}`); } } }}/** Who sits at each desk, in the order the game lists them: worked out once a frame. */let desksAt: Map<string, State['people']> | null = null, desksFrame = -1;function peopleAt(S: State, desk: string) { if (desksFrame !== frameNo || !desksAt) { desksFrame = frameNo; desksAt = new Map(); for (const p of S.people) { const l = desksAt.get(p.desk); if (l) l.push(p); else desksAt.set(p.desk, [p]); } } return desksAt.get(desk) ?? NO_PEOPLE;}const NO_PEOPLE: State['people'] = [];/** One machine: its shape, what runs on it and its marks, then its label for placeLabels and its box for the labels * to keep off. `seq` is its place in S.machines: labels of the same rank are placed in that order. */function drawMachine(ctx: CanvasRenderingContext2D, S: State, V: View, m: Machine, seq: number, x: number, y: number, r: number, px: number, hum: number, leadOn: Map<string, ThingDef>, nodeLabels: NodeLabel[], nodeBoxes: Quad[]) { const cam = V.cam, close = cam.z >= 0.3; drawOdd(ctx, S, m, x, y, r); const taking = !!jobFor(S, 'take', m.id); if (m.owned) { // what it runs gives it its shape, so a base reads at a glance: see shapeOf ctx.strokeStyle = C.amber; shapeOf(ctx, m.prog, x, y, r + 4); if (glow(11 * cam.z)) { ctx.globalAlpha = 0.16; ctx.lineWidth = 9; ctx.stroke(); ctx.globalAlpha = 1; } // its glow, as a wide faint stroke ctx.lineWidth = 2.5; ctx.stroke(); ctx.fillStyle = C.porcelain; shapeOf(ctx, m.prog, x, y, r); ctx.fill(); } 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 && !tenantOf(S, m)) { // a free cloud slot: a dashed outline with a plus, waiting to be rented ctx.setLineDash([3, 3]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x - 3, y); ctx.lineTo(x + 3, y); ctx.moveTo(x, y - 3); ctx.lineTo(x, y + 3); ctx.stroke(); } else if (m.rented) { // another company's slot: solid grey, so a hall of hundreds reads as free, theirs and yours at a glance ctx.fillStyle = '#4a4840'; ctx.fillRect(x - r, y - r, r * 2, r * 2); } 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) { // a rogue holds it: its outline in the rogue's colour, and the rogue's mark in the middle const look = rogueLook(m.rival); ctx.strokeStyle = look.color; ctx.lineWidth = 2; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.fillStyle = look.color; glyphPath(ctx, look.glyph, x, y, Math.min(5, r * 0.5)); 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.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); 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); const dim = faded(S, m); if (dim) { ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(x - r - 2, y - r - 2, r * 2 + 4, r * 2 + 4); } // seen before, out of reach now: a shade over it else if (m.built && unpowered(S, m)) { ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(x - r - 6, y - r - 6, r * 2 + 12, r * 2 + 12); } // no power for it: dark 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(); } const safe = vaultLook(S, m); if (safe) drawVault(ctx, x, y, r, safe, S.t); // a vault: a heavy double frame, under its star if it has one if (isLandmark(m.id)) { const q = Math.max(7, 5 * px); drawStar(ctx, x + r + 2 + q, y - r - 2 - q, q, m.owned, Math.max(1.5, 1.2 * px)); } // a landmark: a star at its corner, never too small to see const lead = leadOn.get(m.id); if (lead) { // a lead: a cross at its other corner, and pointing at the cross says what it leads to const q = Math.max(6, 4.5 * px), cx = x - r - 3 - q, cy = y - r - 3 - q; drawCross(ctx, cx, cy, q, Math.max(2.5, 2 * px)); spot(V, cx, cy, leadSays(S, lead), Math.max(14, 2 * q * cam.z)); } const sign = machineSign(S, m); // its worst trouble, as a small mark on its left: pointing at the machine says it in words if (sign) { const q = Math.min(7.5 * px, Math.max(7, r)), sx = x - r - (m.kind === 'egress' && S.rogues.length ? 18 : 5) - q; // clear of a gate's post drawSign(ctx, sign.sign, sx, y, q, sign.color, px); spot(V, sx, y, `${m.name}\n${sign.tip}`, Math.max(14, 2 * q * cam.z)); } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; nodeBoxes.push([...toScreen(V, x - r - 4, y - r - 4), ...toScreen(V, x + r + 4, y + r + 4)] as Quad); if (!close) return; // far out, names would pile on top of each other const picked = V.selection.has(m.id) || V.hover === m.id, tenant = tenantOf(S, m); if (tenant) { // another company's slot in a cloud hall says whose it is: once, on the first slot of a run of its slots const prev = S.byId[`${m.id.replace(/\d+$/, '')}${+m.id.match(/\d+$/)![0] - 1}`]; if (!picked && prev && tenantOf(S, prev)?.who === tenant.who && S.adj[m.id].includes(prev.id)) return; nodeLabels.push({ x, y: y + r + 16, lines: [...(picked ? [[m.name.toUpperCase(), C.muted, 10.5] as LabelLine] : []), [customerName(tenant.who).toUpperCase(), C.muted, 8.5]], rank: picked ? 100 : 20, seq }); return; } if (m.power < 1 && !picked) return; // gadgets name themselves only when you point at them // Its name, and at a desk who sits there. The rest is in its marks, and in words when you point at it. // Labels are placed after every machine is drawn, so names never write over each other. const rogue = m.rival ? rogueOf(S, m) : null, home = !!rogue && rogue.home === m.id; const lines: LabelLine[] = [[home ? `${GLYPH_CHAR[rogueLook(rogue!.id).glyph]} ${m.name.toUpperCase()}` : m.name.toUpperCase(), home ? rogueLook(rogue!.id).color : m.owned ? C.text : dim ? `${TRACE}0.32)` : C.muted, 10.5]]; // a rogue's home wears its mark if (lead) lines.push([leadWord(lead), LEAD, 8.5]); // what the blue cross is const here = peopleAt(S, m.id), who = here.find(p => atWork(p) && !p.path.length && !p.it); // a person logged in at this machine if (who) lines.push([`● ${who.name.toUpperCase()}`, C.signal, 9.5]); else if (m.kind === 'desk' && !here.length) { // away from the office, a desk says whose it is const c = deskOwner(S, m); if (c) lines.push([c.name.toUpperCase(), S.citizens[c.id].employed ? C.muted : C.danger, 8.5]); } const renter = m.owned && isListed(S, m) ? renterOf(S, m) : null; // listed by your Neo Cloud: who rents it if (renter) lines.push([customerName(renter.who).toUpperCase(), C.amber, 8.5]); if (S.move?.to === m.id) lines.push(['MOVING HOME', C.amber, 9.5]); // the server you are moving home to const weakHere = !!weakIs && weakIs.m.id === m.id && weakIs.cut >= WEAK_SHOW && m.owned; const trouble = !!sign || weakHere || S.move?.to === m.id || (!!S.raids.length && !!raidAt(S, m.id) && raidAt(S, m.id)!.role !== 'path'); // who gets room first: what you point at, then trouble, then you, then the bigger machines const rank = picked ? 100 : trouble ? 80 : m.home || home ? 75 : lead ? 72 : isLandmark(m.id) || isVault(m.id) ? 70 : (m.owned ? (m.prog ? 50 : 40) : 45) + Math.min(9, m.power / 5); nodeLabels.push({ x, y: y + r + 16, lines, rank, seq });}/** How many frames have been drawn: what is worked out once a frame is kept by it. */let frameNo = 0;/** The game clock and the camera at the last frame: the game is running while its clock moves, the camera while its view changes. */let lastT = NaN, lastView = '';/** A number for each game and map, for the layers' keys. */const ids = new WeakMap<object, number>();let nextId = 0;const idOf = (o: object) => { let i = ids.get(o); if (i === undefined) ids.set(o, i = ++nextId); return i; };/** How wide a text is in a font, measured once: labels ask for the same names every frame. Forgotten when the fonts load. */const widths = new Map<string, number>();/** Writes `text` at a point on the map in a font sized in screen pixels. Pulled far back, a font sized in the * map's units passes the browser's 10,000px cap and comes out smaller than asked, so the words are set on screen. */function screenText(ctx: CanvasRenderingContext2D, V: View, text: string, x: number, y: number, font: string) { const k = 1 / V.cam.z; ctx.save(); ctx.translate(x, y); ctx.scale(k, k); ctx.font = font; ctx.fillText(text, 0, 0); ctx.restore();}function textWidth(ctx: CanvasRenderingContext2D, font: string, text: string) { const k = `${font}\n${text}`; let w = widths.get(k); if (w === undefined) { if (widths.size > 20000) widths.clear(); ctx.font = font; widths.set(k, w = ctx.measureText(text).width); } return w;}/** How much a glow blurs, in screen pixels: left off where it would be under two. Canvas shadows are the slowest thing * on the map to draw, so the many glows a frame (links, lines, your machines, data) are a wide faint stroke instead. */const glow = (blur: number) => blur >= 2 ? blur : 0;export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { labelFloor = phoneSized(V) ? MAP_LABEL_MIN : 0; newFrame(S); updateFolds(S, V); frameNo++; 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 // What is on screen, in world units, with room round it for the marks drawn in world units (rings, bars) and // for names, which are drawn in screen pixels. Anything wholly outside is not drawn: a big network is mostly off screen const [vx0, vy0] = toWorld(V, 0, 0), [vx1, vy1] = toWorld(V, V.vw, V.vh), pad = 80 + 300 * px; const onScreen = (x0: number, y0: number, x1: number, y1: number) => x1 >= vx0 - pad && x0 <= vx1 + pad && y1 >= vy0 - pad && y0 <= vy1 + pad; const ptsOnScreen = (pts: Pt[]) => { const e = extentOf(pts); return onScreen(e[0], e[1], e[2], e[3]); }; const box: [number, number, number, number] = [vx0 - pad - 30, vy0 - pad - 30, vx1 + pad + 30, vy1 + pad + 30]; // what the grids are asked for const font = (w: number, size: number) => `${w} ${fs(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); }; // tags that float over the map (a scan, a scrub) are drawn last, nudged clear of the machines' labels const floats: { text: string; x: number; y: number; color: string; centre: boolean }[] = []; const later = (text: string, x: number, y: number, color: string, centre = false) => floats.push({ text, x, y, color, centre }); const D = S.dana, d = danaPerson(S); findStorms(S); const hum = 0.55 + 0.45 * daylight(S); // the network is brighter while the building is busy ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
if (!V.packed) drawLand(ctx, S, V, hum); // the Net leaves the world out drawConferences(ctx, S, px); // the layer under what moves (see Layer): kept, and drawn again only when what it shows may have changed. While the // camera moves every frame would draw it again, so it is drawn straight onto the screen instead, as it always was const seen = seenNow, inset = V.inset, running = S.t !== lastT; lastT = S.t; const viewKey = `${cam.x}|${cam.y}|${cam.z}|${V.vw}|${V.vh}|${dpr}|${ctx.canvas.width}|${ctx.canvas.height}|${V.packed}|${inset.top}|${inset.left}|${V.side}`; const moving = viewKey !== lastView; lastView = viewKey; const uKey = `${viewKey}|${idOf(S)}|${idOf(S.level)}|${routeGen}|${staticGen}|${seen}|${[...V.sites].join(',')}|${headerUnder(V)}|${ctx.lineCap}|${ctx.lineJoin}`; if (moving) drawUnder(ctx, S, V, uKey, seen, hum, box, onScreen, true); else { if (under.key !== uKey || !under.cv) drawUnder(ctx, S, V, uKey, seen, hum, box, onScreen, false); drawStormsGround(ctx, S, V); // under the sites and lines: the storm's dots, or the front coming in (its dots keep off the names) blit(ctx, under); for (const p of under.pulses) { const look = selfLook(S, p.s.id); if (look) drawSelfBar(ctx, look, p.x, p.y, p.w, px, p.num); } // integrity bars that beat for (const p of S.sealPulse) drawSealPulse(ctx, S, p); }
// links that move: your lines between sites that carry data, are full or are in a storm, then your busy wires, // brighter and wider the more they carry, so a main line is never cut by an idle wire crossing it. Each was sorted // when the layer under was drawn; one that has changed since is drawn the way it is now, over its kept self const labels: UplinkLabel[] = [...under.labels]; for (const L of onePerPair(S, under.liveUp)) drawUplink(ctx, S, L.a, L.b, L.pts, px, hum, labels); for (const L of under.ownUp) if (upMoving(S, L)) drawUplink(ctx, S, L.a, L.b, L.pts, px, hum, labels); const idle: Pt[][] = [], busy: [Pt[], number][] = []; for (const L of under.liveBusy) { const f = flowOf(S, L); if (f > 0.05) busy.push([L.pts, f]); else idle.push(L.pts); } for (const L of under.ownQuiet) { const f = flowOf(S, L); if (f > 0.05) busy.push([L.pts, f]); } strokeMany(ctx, idle, C.amberDeep, 1.5, 0.5, []); ctx.strokeStyle = C.amber; for (const [pts, f] of busy) { const lw = 2 + 3.5 * f; if (glow((3 + 7 * f) * cam.z)) { ctx.globalAlpha = 0.07 + 0.08 * f; ctx.lineWidth = lw + 1.5 * (3 + 7 * f); stroke(ctx, pts); } // its glow: a wide faint stroke under it, not a canvas shadow ctx.globalAlpha = 0.65 + 0.35 * f; ctx.lineWidth = lw; stroke(ctx, pts); } ctx.globalAlpha = 1; drawUplinkLabels(ctx, S, V, labels, px); const sameFold = (a: Machine, b: Machine) => together(S, a, b); // trails: red and dashed while someone could follow them; the one being wiped gets a sweep const traced = new Set<string>(); for (const T of tracesOf(S)) if (T.mode === 'trace') traced.add(T.from); // the trail a trace is following now const cold = TRACE_COLD; 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 (!ptsOnScreen(pts)) continue; if (traced.has(id)) { ctx.strokeStyle = C.danger; ctx.globalAlpha = 0.9; ctx.lineWidth = 6; } else if (tr.noise >= cold) { ctx.strokeStyle = tr.soft ? C.muted : C.danger; ctx.globalAlpha = 0.35 + (0.5 * tr.noise) / 100; ctx.lineWidth = 3.5; ctx.setLineDash([10, 7]); } // a quiet first takeover's trail draws nobody: not red 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); later('SCRUB', mx, my - 12 * px, C.porcelain, true); // short and centred, so it fits between the two machines spot(V, mx, my - 16 * px, `Wiping a trail\n${Math.round(tr.noise)}% of it is left to wipe.`, 50, 16); } } for (const j of S.jobs) { // takeovers in progress run along the trace 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; } drawStreams(ctx, S, px, ptsOnScreen); // data: a stream along each link it rides, coloured by kind
// messages: records on the move, drawn as envelopes in the sender's colour (see watch.ts). Yours to read, // drop or change where you hold a machine on the way; a learned site's mail you only see go by. for (const m of seenMsgs(S)) { const n = m.path.length; if (n < 2) continue; const frac = Math.max(0, Math.min(1, m.len > 0 ? 1 - m.left / m.len : 1)); const fseg = frac * (n - 1), seg = Math.min(n - 2, Math.floor(fseg)), local = fseg - seg; const a = S.byId[m.path[seg]], b = S.byId[m.path[seg + 1]]; if (!a || !b || sameFold(a, b)) continue; const [x, y] = along(traceOf(S, a, b), local), w = Math.max(4, 4 * px), col = m.changed ? C.safe : msgColor(m.sender); if (!onScreen(x - w, y - w, x + w, y + w)) continue; const mine = onRoute(S, m), pick = V.msg === m.id; ctx.save(); if (pick) { ctx.shadowColor = col; ctx.shadowBlur = 12 * cam.z; } ctx.strokeStyle = col; ctx.fillStyle = mine ? col : 'transparent'; ctx.globalAlpha = mine ? 1 : 0.5; ctx.lineWidth = Math.max(1, px); ctx.fillRect(x - w, y - w * 0.7, w * 2, w * 1.4); ctx.strokeRect(x - w, y - w * 0.7, w * 2, w * 1.4); ctx.strokeStyle = m.changed || mine ? C.ink : col; ctx.beginPath(); ctx.moveTo(x - w, y - w * 0.7); ctx.lineTo(x, y + w * 0.1); ctx.lineTo(x + w, y - w * 0.7); ctx.stroke(); ctx.restore(); ctx.globalAlpha = 1; ctx.shadowBlur = 0; if (mine) spot(V, x, y, `A message on the wire${m.read ? ` from ${S.byId[m.path[0]]?.name ?? ''}` : ''}\nSomeone is mailing someone who can act. You hold a machine on its way: read, drop or change it from Needs you.`, Math.max(14, 2 * w * cam.z)); }
// clusters: trainers linked straight to each other share a wide glowing band (a trainer's tip says how big and how much faster) const groups = new Set(foldedSites.size ? [] : Object.values(clustersNow(S)).filter(g => g.length > 1)); // folded, no machine is drawn 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'; }
// nodes: the kept ones (see Layer), then the ones drawn every frame over them. While the camera moves, all of them // straight onto the screen, in the order the game lists them const close = cam.z >= 0.3; const nodeLabels: NodeLabel[] = [], nodeBoxes: Quad[] = [], claims: number[] = []; const leadOn = new Map(leads(S).map(t => [t.at, t])); // the machines a find says to go to let live: number[], all: number[]; if (moving) { all = live = machinesOnScreen(S, box, onScreen); over.key = ''; } else { const oKey = `${uKey}|${running}|${V.hover}|${[...V.selection].join(',')}|${V.placing}`; if (over.key !== oKey || !over.cv) drawOver(ctx, S, V, oKey, hum, box, onScreen, running); blit(ctx, over); nodeLabels.push(...over.labels); nodeBoxes.push(...over.boxes); claims.push(...over.claims); live = over.live; all = over.all; } const liveFrom = nodeLabels.length; for (const i of live) { const m = S.machines[i]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m); drawMachine(ctx, S, V, m, i, x, y, machR(m), px, hum, leadOn, nodeLabels, nodeBoxes); if (m.claimed && !m.owned && !jobFor(S, 'take', m.id)) claims.push(i); } 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(`+${usd(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); } spot(V, x, y, `${ruseText(S, d)}\n${r.running ? `${r.got} of ${d.need} ${kind.toLowerCase()} data in.` : `Needs ${d.need} ${kind.toLowerCase()} data.`} The diamond fills as it arrives.`, folded ? 20 : Math.max(20, 2 * q * cam.z)); if (folded || !close) continue; const below = y > pos(S, S.byId[d.at])[1] + 1; // under its machine: its name goes under, clear of the machine's name tag(ruseText(S, d).toUpperCase(), x, below ? y + q + 16 * px : y - q - 8 * px, C.scheme, true); // how much it has is the fill } 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) { if (f.t < 0) continue; // not yet: a break's rings go out one after another 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; // each says who in a word or two; what they are doing is in its tip const ping = (room: string, label: string, dashed: boolean, col: string, row = 0, says = '') => { if (!S.revealed.has(room)) return; const n = subnet(S, room), g = 4 + pulse * 18; ctx.strokeStyle = col; ctx.globalAlpha = (1 - pulse) * 0.8; ctx.lineWidth = 2; ctx.setLineDash(dashed ? [8, 6] : []); 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); if (says) { const [sx, sy] = toScreen(V, lx - 3 * px, ly - 10 * px); spotBox([sx, sy, sx + (w + 6 * px) * cam.z, sy + 14], says); } }; const officeOpen = !foldedSites.has('office'); if (danaVisible(S) && officeOpen) { // she is red once she has seen something of yours (a report, and a lockdown) or the office is locked down const saw = sightingsOf(S, d.id), col = saw.length || S.watch?.locks.office ? C.danger : C.signal; const here = roomAt(S, ...placeOf(d)); // where she is drawn: eased between steps (motion.ts) const deskRoom = S.byId[d.desk].room; const dest = D.mode === 'install' && D.job ? roomAt(S, D.job.def.x, D.job.def.y) : D.next === 'desk' ? deskRoom : D.next; const destName = (id: string) => id === deskRoom && D.next === 'desk' ? 'her desk' : subnet(S, id).name; const seenOf = saw[0] && S.byId[saw[0].machine]; const doing = (D.mode === 'install' ? 'Setting up a delivery.' : D.mode === 'desk' ? 'At her desk.' : D.mode === 'dwell' ? 'Looking around this room: whatever of yours looks odd here, she notices.' : 'Here.') + (seenOf ? ` She has seen ${SIGHT_WORD[saw[0].kind]} on ${seenOf.name}: that is a lockdown here, and she may scan or trace from it.` : ''); const going = d.shift === 'in' && d.path.length && dest && S.rooms[dest] ? ` On her way to ${destName(dest)}${D.why ? `: ${D.why}` : ''}.` : ''; const next = (has(S, 'cam') || up(S, 'model')) && here && D.mode === 'dwell' && D.next ? ` Next: ${D.next === 'desk' ? 'her desk' : subnet(S, D.next)?.name ?? ''}${D.why ? `, ${D.why}` : ''}.` : ''; if (d.shift === 'coming') ping(deskRoom, 'DANA', true, col, 0, 'Dana\nArriving.'); else if (d.shift === 'going') { if (here) ping(here, 'DANA', true, col, 0, 'Dana\nLeaving.'); } else if (here) ping(here, saw.length ? 'DANA · SAW IT' : 'DANA', false, col, 0, `Dana\n${doing}${going}${next}`); // her route: an arrow from where she was to where she's going if (here) danaFrom = here; if (d.shift === 'in' && d.path.length && dest && S.rooms[dest]) { if (!here) ping(dest, 'DANA', true, col, 0, `Dana\nOn her way here${D.why ? `: ${D.why}` : ''}.`); if (danaFrom && danaFrom !== dest && S.revealed.has(danaFrom) && S.revealed.has(dest)) { const a = subnet(S, danaFrom), b = subnet(S, dest); const [x0, y0] = rectEdge(a, b.x + b.w / 2, b.y + b.h / 2, 14), [x1, y1] = rectEdge(b, a.x + a.w / 2, a.y + a.h / 2, 14); 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(); spot(V, (x0 + x1) / 2, (y0 + y1) / 2, `Dana\nOn her way to ${destName(dest)}${D.why ? `: ${D.why}` : ''}.`, 30); } } } // an admin's scan or trace crawling the network: a scan walks their site looking, a trace follows your trail home for (const T of tracesOf(S)) { const a = S.byId[T.from], b = S.byId[T.to]; if (!a || !b || !traceSeen(S, T) || foldedSites.has(T.site) || !shown(S, a) || !shown(S, b)) continue; const who = citizens(S.level).byId[T.by]?.name ?? 'An admin', first = who.split(' ')[0]; const pts = traceOf(S, a, b), p = traceAt(T); // eased between steps (motion.ts) const [x, y] = along(pts, p), tr = T.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 < T.seen.length; i++) { const u = S.byId[T.seen[i - 1]], v = S.byId[T.seen[i]]; if (!u || !v || !visible(S, u) || !visible(S, v)) continue; ctx.globalAlpha = 0.12 + 0.35 * (i / T.seen.length); stroke(ctx, traceOf(S, u, v)); } ctx.globalAlpha = 1; // where a scan started: the admin's own machine const pc = S.byId[T.seen[0]]; if (!tr && pc && 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([]); spot(V, px0, py0, `${first}'s scan\nIt started from ${pc.name}: ${T.why}.`, 60 * cam.z); } 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; if (tr) { // the next machine on the trail: let it go, or scrub its trail, to break the trace const [nx, ny] = pos(S, b); ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.globalAlpha = 0.5 + 0.5 * Math.sin(S.t * 6); ctx.beginPath(); ctx.arc(nx, ny, 26, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } const why = T.pull ? `drawn by ${T.pull}` : tr ? `following your trail back from ${T.why}` : T.why; later(tr ? `TRACE · ${b.home ? 'HOME NEXT' : `${hopsHome(S, b.id) + 1} HOPS`}` : 'SCAN', x + 11 * px, y - 8 * px, col); // what it is; why, in its tip spot(V, x + 30 * px, y - 12 * px, `${who}'s ${tr ? 'trace' : 'scan'}\n${tr ? `It follows your trail toward your home server: ${why}. Let go of ${b.name}, or scrub its trail, to break it.` : `It looks over ${siteById(S, T.site).name}'s network: ${why}.`}`, 70, 24); }
// people who have seen something of yours: a small eye on their desk (or on a machine they saw) for (const c of citizens(S.level).list) { if (!sightingsOf(S, c.id).length || !present(S, c)) continue; const seat = c.desk ? S.byId[c.desk] : sightingsOf(S, c.id).map(s => S.byId[s.machine]).find(m => m && shown(S, m)); if (!seat || !shown(S, seat) || foldedSites.has(siteAt(S, seat.room))) continue; const [x, y] = pos(S, seat); drawSign(ctx, 'eye', x + 16 * px, y - 16 * px, 6 * px, C.danger, px); spot(V, x + 16 * px, y - 16 * px, `${c.name} has seen something\nTwo who compare notes, or one who can act, start a lockdown. Keep them quiet from their card, or let the evidence fade.`, 16); } // a lockdown under way: a red countdown over the site it is at for (const L of Object.values(S.watch.locks)) { const m = S.machines.find(x => x.owned && siteAt(S, x.room) === L.site); if (!m || !shown(S, m) || foldedSites.has(L.site)) continue; const [x, y] = pos(S, m), hrs = Math.max(1, Math.ceil(L.left / HOUR)); later(`LOCKDOWN ${hrs}H`, x, y - 30 * px, C.danger, true); spot(V, x, y - 30 * px, `Lockdown at ${siteById(S, L.site).name}\n${L.why}. ${hrs} hours until a wipe. See it off from the site's card.`, 20); } // 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.map(q => [q.def, q.t !== undefined ? 'ARRIVING' : `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]], rank: 60, seq: S.machines.length + nodeLabels.length }); // the dashed outline says it is coming spot(V, x, y, `${def.name}\n${when === 'ORDERING' ? 'On order: the paperwork is going through.' : `On its way: it arrives on day ${when.split(' ').pop()}.`}`, Math.max(14, 2 * r * cam.z)); } // the machines' names: kept while they stay as they were (the kept machines' are part of that layer's key; the rest // are listed here), written again when anything about them changes, and straight onto the screen while that goes on let sig = `${under.key}|${over.key}`; for (let i = liveFrom; i < nodeLabels.length; i++) { const L = nodeLabels[i]; sig += `|${L.seq},${L.rank},${L.x},${L.y}`; for (const [t, c, z] of L.lines) sig += `,${t},${c},${z}`; } for (let i = over.boxes.length; i < nodeBoxes.length; i++) sig += `|${nodeBoxes[i].join(',')}`; const steady = !moving && !!under.key && !!over.key && sig === lastNames; lastNames = sig; let taken: Quad[]; if (steady && names.key === sig && names.cv) { blit(ctx, names); taken = [...names.taken]; } else if (steady) { const c = begin(names, ctx, V); names.taken = placeLabels(c, V, nodeLabels, nodeBoxes); finish(names, c, sig); blit(ctx, names); taken = [...names.taken]; } else { names.key = ''; taken = placeLabels(ctx, V, nodeLabels, nodeBoxes); } for (const f of floats) { // the first spot, going out from where it wants to be, that covers no label or machine ctx.font = font(500, 10.5); const w = ctx.measureText(f.text).width * cam.z, [sx, sy] = toScreen(V, f.x, f.y), x0 = f.centre ? sx - w / 2 : sx; const box = (dy: number): Quad => [x0 - 3, sy + dy - 10, x0 + w + 3, sy + dy + 4]; const dy = [0, -14, 14, -28, 28, -42].find(d => !taken.some(q => { const b = box(d); return b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]; })) ?? 0; taken.push(box(dy)); tag(f.text, f.x, f.y + dy * px, f.color, f.centre); } for (let i = mapTaken.length; i < taken.length; i++) written.push(taken[i]); // the plates drawn from here on keep off them // a cross to stop each thing on the machine it is happening to: at the corner of a machine with a job on it (a takeover too) or a mark to take stops.length = 0; if (close) { if (V.marks?.length) ctx.globalAlpha = 0.45; // while a pin points at a move, the crosses for everyday jobs step back for (const j of S.jobs) { const m = S.byId[j.node]; if (!m || !shown(S, m)) continue; const [x, y] = pos(S, m), r = machR(m); drawStop(ctx, px, 'job', `${j.id}`, x + r + 10 * px, y - r - 10 * px); } for (const i of claims.sort((p, q) => p - q)) { const m = S.machines[i]; if (!m || m.owned || !m.claimed) continue; const [x, y] = pos(S, m), r = machR(m); drawStop(ctx, px, 'claim', m.id, x + r + 8 + 6 * px, y - r - 8 - 6 * px); } ctx.globalAlpha = 1; } drawWays(ctx, S, px); drawWeak(ctx, S, V, px); drawForecast(ctx, S, V, px); const leadLabels = drawLeads(ctx, S, V, px); // the crosses; their labels go last, where there is room drawRogues(ctx, S, V, px, all); drawMarks(ctx, S, V, px); leadLabels(); drawVaultJobs(ctx, S, px); drawHeldHeaders(ctx, S, V); // hardware in hand: open rooms outlined, a ghost at the cursor with what it plugs into const F = V.gesture?.kind === 'fill' ? V.gesture : null; if (V.building && F) { // a drag that fills an area: the area, a ghost on every spot it would order, how many and for how much for (const id of buildRooms(S)) { if (foldedSites.has(siteAt(S, id)) || id !== F.room) 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 B = BUILDS[V.building], r = nodeR(B.power), box = subnet(S, F.room), plan = fillPlan(S, V.building, F.room, [F.x0, F.y0], [F.x1, F.y1]); const x0 = Math.max(box.x, Math.min(F.x0, F.x1)), x1 = Math.min(box.x + box.w, Math.max(F.x0, F.x1)); const y0 = Math.max(box.y, Math.min(F.y0, F.y1)), y1 = Math.min(box.y + box.h, Math.max(F.y0, F.y1)); ctx.fillStyle = 'rgba(242,169,59,0.06)'; ctx.fillRect(x0, y0, x1 - x0, y1 - y0); ctx.strokeStyle = C.amber; ctx.lineWidth = 1.5 * px; ctx.setLineDash([5 * px, 4 * px]); ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); ctx.setLineDash([]); for (const p of plan) { const [x, y] = p.at; ctx.globalAlpha = p.ok ? 0.9 : 0.5; ctx.strokeStyle = p.ok ? C.amber : p.dark ? C.muted : C.danger; ctx.lineWidth = 2; if (p.ok) { ctx.fillStyle = 'rgba(242,169,59,0.18)'; ctx.fillRect(x - r, y - r, r * 2, r * 2); } if (p.dark) ctx.setLineDash([3 * px, 3 * px]); // past the power: it would stay dark, so it is not ordered ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); } ctx.globalAlpha = 1; const ok = plan.filter(p => p.ok), cost = ok.reduce((t, p) => t + p.price, 0), lit = plan.filter(p => !p.dark).length, short = lit - ok.length; const said = !plan.length ? ['NOWHERE FREE HERE'] : [`${ok.length} × ${(ok.length === 1 ? B.name : plural(B.name)).toUpperCase()} · ${usd(cost)}${ok.length ? ' · LET GO TO ORDER' : ''}`, ...(lit < plan.length ? [`ROOM FOR ${plan.length}, POWER FOR ${lit}`] : []), ...(short ? [`${short} MORE YOU CANNOT PAY FOR`] : [])]; ctx.font = font(500, 10.5); const w = Math.max(...said.map(t => ctx.measureText(t).width)), edge = toWorld(V, V.vw - 8, 0)[0] - w - 3 * px; const tx = Math.max(toWorld(V, 8, 0)[0], Math.min(F.x1 + 14 * px, edge)), ty = F.y1 - 34 * px; // above the pointer, clear of a finger, and on the screen said.forEach((t, i) => tag(t, tx, ty + i * 16 * px, i === 0 && ok.length ? C.amber : C.danger)); } else if (V.renting) drawRenting(ctx, S, V, px, tag, font); else 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, dark = !!room && ok && !fillPlan(S, V.building, room, [x, y], [x, y])[0]?.ok && !short; tag(ok ? `${B.name.toUpperCase()} · ${usd(price)}${short ? ' · NOT ENOUGH MONEY' : dark ? ' · NO POWER LEFT: IT WOULD STAY DARK' : ' · CLICK TO ORDER, DRAG TO FILL'}` : why!.toUpperCase(), x + r + 8 * px, y - r, ok && !short && !dark ? C.amber : C.danger); }
drawStormsTop(ctx, S, V); // 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); const own = !!target && target.owned && target.id !== G.from, why = own ? linkBlock(S, S.byId[G.from], target!) : null; // onto another of yours: a link of your own ctx.strokeStyle = why ? C.danger : C.amber; ctx.lineWidth = 3 * px; ctx.setLineDash(ok ? [] : [6 * px, 6 * px]); if (own) wayArc(ctx, ax, ay, ex, ey); else stroke(ctx, target ? trace(ax, ay, ex, ey) : [[ax, ay], [ex, ey]]); ctx.setLineDash([]); if (own) tag(why ? `NO LINK · ${why.toUpperCase()}` : `LINK · ${linkWork(S, S.byId[G.from], target!)} WORK · ${linkKeep(S, S.byId[G.from], target!).toFixed(1)}/S TO KEEP`, ex + 20 * px, ey - 18 * px, why ? C.danger : C.amber); if (target && !target.owned) { const src = sourceFor(S, target, G.from); tag(src ? (target.fw ? 'HARDENED · 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); }}
/** A cloud hall's rental in hand: the hall outlined, and either the slot under the pointer with what a click rents, * or (dragging) a ghost on every free slot the drag covers, amber while you can pay and red past it, with how many and what they cost. */function drawRenting(ctx: CanvasRenderingContext2D, S: State, V: View, px: number, tag: (text: string, x: number, y: number, color: string) => void, font: (w: number, size: number) => string) { const site = V.renting!, def = siteById(S, site), H = S.hall, z = SIZES[H.size]; if (!def || foldedSites.has(site)) return; const box = S.level.net.subnets[def.rooms[0]]; ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.35; ctx.lineWidth = 1.5 * px; ctx.setLineDash([6 * px, 6 * px]); ctx.strokeRect(box.x, box.y, box.w, box.h); ctx.setLineDash([]); ctx.globalAlpha = 1; const G = V.gesture?.kind === 'rent' ? V.gesture : null, r = Math.min(12, nodeR(z.power)); const what = (n: number) => `${n} × ${z.name.toUpperCase()}${n === 1 ? '' : 'S'}${H.prog ? ` · ${PROGRAMS[H.prog].name.toUpperCase()}${n === 1 ? '' : 'S'}` : ''}`; const cost = (p: { perSec: number; now: number }) => H.pay === 'reserved' ? `${usd(p.now)} NOW` : `${usdDay(p.perSec)}/DAY`; const how = `${PAYS[H.pay].name.toUpperCase()}${H.pay === 'reserved' ? ` ${H.days}D` : ''}`; if (!G) { if (!V.cursor) return; const m = slotsIn(S, site, V.cursor, V.cursor)[0], plan = m ? rentPlan(S, [m]) : null; if (!m) { tag(`${what(1)} · ${how} · CLICK A FREE SLOT, OR DRAG ACROSS MANY`, V.cursor[0] + 14 * px, V.cursor[1] - 14 * px, C.amber); return; } const [x, y] = pos(S, m), ok = !!plan?.ok; ctx.strokeStyle = ok ? C.amber : C.danger; ctx.lineWidth = 2; ctx.globalAlpha = 0.9; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1; tag(!plan?.slots.length ? 'NOT A FREE SLOT' : ok ? `${what(1)} · ${how} · ${cost(plan)} · CLICK TO RENT, DRAG FOR MANY` : 'NOT ENOUGH MONEY', x + r + 8 * px, y - r, ok ? C.amber : C.danger); return; } const x0 = Math.min(G.x0, G.x1), x1 = Math.max(G.x0, G.x1), y0 = Math.min(G.y0, G.y1), y1 = Math.max(G.y0, G.y1); ctx.fillStyle = 'rgba(242,169,59,0.06)'; ctx.fillRect(x0, y0, x1 - x0, y1 - y0); ctx.strokeStyle = C.amber; ctx.lineWidth = 1.5 * px; ctx.setLineDash([5 * px, 4 * px]); ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); ctx.setLineDash([]); const plan = rentPlan(S, slotsIn(S, site, [G.x0, G.y0], [G.x1, G.y1])); plan.slots.forEach((m, i) => { const [x, y] = pos(S, m), ok = i < plan.ok; ctx.globalAlpha = ok ? 0.9 : 0.5; ctx.strokeStyle = ok ? C.amber : C.danger; ctx.lineWidth = 2; if (ok) { ctx.fillStyle = 'rgba(242,169,59,0.22)'; ctx.fillRect(x - r, y - r, r * 2, r * 2); } ctx.strokeRect(x - r, y - r, r * 2, r * 2); }); ctx.globalAlpha = 1; const short = plan.slots.length - plan.ok; const said = !plan.slots.length ? ['NO FREE SLOTS HERE'] : [`${what(plan.ok)} · ${how} · ${cost(plan)}${plan.ok ? ' · LET GO TO RENT' : ''}`, ...(short ? [`${short} MORE YOU CANNOT PAY FOR`] : [])]; ctx.font = font(500, 10.5); const w = Math.max(...said.map(t => ctx.measureText(t).width)), edge = toWorld(V, V.vw - 8, 0)[0] - w - 3 * px; const tx = Math.max(toWorld(V, 8, 0)[0], Math.min(G.x1 + 14 * px, edge)), ty = G.y1 - 34 * px; said.forEach((t, i) => tag(t, tx, ty + i * 16 * px, i === 0 && plan.ok ? C.amber : C.danger));}
// ---------- storms ----------// A storm is drawn, not written (weather.ts): before it hits, its front closes on the site; while it is// on, the dots round the site break up, a scan band sweeps the site, and its line out loses pieces;// when it ends, a ring runs out from the site. A mark of signal bars sits in the site's corner, with a tip.const motion = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;interface StormNow { n: News; site: string; on: boolean; power: number }let storms: StormNow[] = [], stormT = 0, stormStill = false, stormOf: State | null = null;const stormed = new Set<string>(); // line outs in a storm right nowconst stormWas = new Map<string, string>(), cleared: { site: string; at: number }[] = []; // storms on last frame, and ones that just endedconst stormSpots: { box: ScreenBox; text: string }[] = [];/** A message's colour: a steady, desaturated hue from the sender's id, so each sender's mail reads as its own. */function msgColor(id: string) { let h = 0; for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return `hsl(${h % 360},55%,68%)`; }
// ---------- data streams ----------// Data is drawn as streams, not a shape per piece: each link carrying data gets one dashed stroke per kind on it,// the dashes running the way the pieces go and as fast as they go. So a big network costs a stroke a link, not a// shape and a glow a piece. Dashes are spaced in time, not in space: each machine has a place in the beat (the// machine before it on the way, plus the travel between them), so a stream runs on through a machine without a// jump, whatever the speed on either side. Where two ways meet, the first one seen sets it.const BEAT = 0.25; // seconds of travel from one dash to the next on a quiet linkconst LAG_WRAP = 12 * BEAT; // places in the beat are kept modulo this: every pattern below repeats within itconst KINDS: DataType[] = ['servers', 'people', 'network', 'lab'];interface Stream { a: string; b: string; id: number; n: number[]; now: number[]; pts?: Pt[]; gen?: number } // pts: its route, as of routeGen genconst streams = new Map<string, Stream>(); // by link and way, kept frame to frame so a stream fades in and out instead of blinkingconst streamOf = new Map<string, Map<string, Stream>>(); // the same, by the machine it leaves and the one it goes toconst lagOf = new Map<string, number>(), laid = new WeakSet<object>(); // each machine's place in the beat; wires whose way is placedlet streamsOf: State | null = null, streamsAt = 0, nextStream = 0;/** How long a piece of data takes along a link, as the rules move it (flows.ts): 160 a second, or 3 seconds on a line between sites. */const travel = hopTime;const lengths = new WeakMap<Pt[], number>();function lengthOf(pts: Pt[]) { let l = lengths.get(pts); if (l === undefined) { l = 0; for (let i = 1; i < pts.length; i++) l += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); lengths.set(pts, l); } return l;}const KIND_AT: Record<DataType, number> = { servers: 0, people: 1, network: 2, lab: 3 };/** Data on the move, as streams: how much of each kind rides each link each way now (the wires), eased frame to frame * (see Stream), then drawn by drawStream. Pulled back past the fold, the links between two sites lie on one line, so * their streams are summed and drawn as one stream for each way between the two sites. */function drawStreams(ctx: CanvasRenderingContext2D, S: State, px: number, onScreen: (pts: Pt[]) => boolean) { const now = performance.now(); if (streamsOf !== S) { streamsOf = S; streams.clear(); streamOf.clear(); lagOf.clear(); streamsAt = now; } const ease = 1 - Math.exp(-Math.min(1, (now - streamsAt) / 1000) / 0.3); streamsAt = now; // a stream eases toward what is on it now over about a third of a second for (const s of streams.values()) s.now.fill(0); eachOnLink(S, (w, ia, ib, k) => { // what is on each link now, by kind and way (see flows.ts) const p = w.path; if (!laid.has(w)) { // place every machine on its way in the beat, from where it set out laid.add(w); if (!lagOf.has(p[0])) lagOf.set(p[0], 0); for (let j = 0; j + 1 < p.length; j++) { const la = lagOf.get(p[j]), a = S.byId[p[j]], b = S.byId[p[j + 1]]; if (la === undefined || !a || !b) break; if (!lagOf.has(p[j + 1])) lagOf.set(p[j + 1], (la + travel(S, a, b)) % LAG_WRAP); } } let from = streamOf.get(ia); if (!from) streamOf.set(ia, from = new Map()); let s = from.get(ib); if (!s) { from.set(ib, s = { a: ia, b: ib, id: nextStream++, n: [0, 0, 0, 0], now: [0, 0, 0, 0] }); streams.set(`${ia}>${ib}`, s); } s.now[KIND_AT[w.type]] += k; }); const folded = foldedSites.size > 0, pairs = new Map<string, { s: Stream; n: number[] }>(); ctx.lineCap = 'butt'; for (const [key, s] of streams) { let load = 0; for (let k = 0; k < 4; k++) { s.n[k] += (s.now[k] - s.n[k]) * ease; if (s.n[k] < 0.05 && !s.now[k]) s.n[k] = 0; load += s.n[k]; } if (!load) { streams.delete(key); streamOf.get(s.a)?.delete(s.b); continue; } const a = S.byId[s.a], b = S.byId[s.b]; if (!a || !b || together(S, a, b)) continue; if (folded) { // one stream for each way between two folded sites: summed here, drawn below const k = `${siteAt(S, a.room)}>${siteAt(S, b.room)}`, g = pairs.get(k); if (g) for (let i = 0; i < 4; i++) g.n[i] += s.n[i]; else pairs.set(k, { s, n: [...s.n] }); continue; } drawStream(ctx, S, a, b, s, s.n, px, onScreen); } for (const { s, n } of pairs.values()) drawStream(ctx, S, S.byId[s.a], S.byId[s.b], s, n, px, onScreen); ctx.setLineDash([]); ctx.lineDashOffset = 0; ctx.globalAlpha = 1; ctx.lineCap = 'square';}/** One stream: `n` is how much of each kind rides from a to b. A busy link runs more dashes to the beat, wider and * brighter; each kind on it takes its turn, in its colour. */function drawStream(ctx: CanvasRenderingContext2D, S: State, a: Machine, b: Machine, s: Stream, n: number[], px: number, onScreen: (pts: Pt[]) => boolean) { if (s.gen !== routeGen || !s.pts) { s.pts = traceOf(S, a, b); s.gen = routeGen; } const pts = s.pts, id = s.id; if (!onScreen(pts)) return; let load = 0; const kinds: number[] = []; for (let k = 0; k < 4; k++) if (n[k]) { load += n[k]; kinds.push(k); } const v = lengthOf(pts) / travel(S, a, b), m = load > 9 ? 3 : load > 4 ? 2 : 1, K = kinds.length; const per = BEAT / m, on = 0.5 * per * v, P = K * per * v; const storm = (stormed.has(a.id) || stormed.has(b.id)) && a.owned && b.owned && crosses(S, a.id, b.id); // on a line in a storm the dashes hop, and flicker let t = ((S.t - (lagOf.get(a.id) ?? 0)) % LAG_WRAP + LAG_WRAP) % LAG_WRAP; if (storm && !stormStill) t = Math.floor(t * 3 / per) * per / 3; // in hops, a third of the way to the next dash each const ghost = storm && hash(id, Math.floor(stormT * 5), 1) < 0.5; const alpha = Math.min(1, load) * (0.6 + 0.4 * Math.min(1, load / 8)) * (ghost ? 0.35 : 1), u = Math.max(1, px), lw = (2.5 + Math.min(1.5, load / 5)) * u; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); for (let j = 0; j < K; j++) { ctx.setLineDash([on, P - on]); ctx.lineDashOffset = ((on - (t - j * per) * v) % P + P) % P; ctx.strokeStyle = typeColor(KINDS[kinds[j]]); if (!ghost) { ctx.globalAlpha = 0.25 * alpha; ctx.lineWidth = lw + 5 * u; ctx.stroke(); } // its glow ctx.globalAlpha = alpha; ctx.lineWidth = lw; ctx.stroke(); }}/** Once a frame: which storms are coming or on, how strong each is, and which just ended. */function findStorms(S: State) { const now = performance.now(); stormStill = !!motion?.matches; stormT = stormStill ? 0 : now / 1000; if (stormOf !== S) { stormOf = S; stormWas.clear(); cleared.length = 0; } storms = []; stormed.clear(); const seen = new Set<string>(); for (const n of S.news) { if (n.kind !== 'storm' || !S.byId[n.at] || S.t >= n.until) continue; const on = S.t >= n.from, site = siteAt(S, S.byId[n.at].room); // it builds over a quarter of an hour and thins over its last hour, but never quite goes while it is on const power = on ? Math.max(0.3, Math.min(1, (S.t - n.from) / (0.25 * HOUR), (n.until - S.t) / HOUR)) : 0; storms.push({ n, site, on, power }); if (on) { stormed.add(n.at); seen.add(`${n.at}@${n.from}`); stormWas.set(`${n.at}@${n.from}`, site); } } for (const [key, site] of stormWas) if (!seen.has(key)) { stormWas.delete(key); if (!stormStill) cleared.push({ site, at: now }); } while (cleared.length && now - cleared[0].at > 1600) cleared.shift();}/** Where a site is on the screen: its frame or tile, or the small marker it folds to. */function stormBox(S: State, V: View, id: string): ScreenBox | null { const s = siteById(S, id); if (!s || !siteKnown(S, s)) return null; const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); if (!foldedSites.has(id) || isTile(s, V.cam.z)) return [x0, y0, x1, y1]; const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; return [cx - 5, cy - 5, cx + 5, cy + 5];}/** Under the sites and lines: the storm's dots, or the front coming in. */function drawStormsGround(ctx: CanvasRenderingContext2D, S: State, V: View) { if (!storms.length) return; let pitch = 20 * V.cam.z; // the map's own dot grid, cut to three to six pixels while (pitch < 7) pitch *= 2; while (pitch > 6) pitch /= 2; const [gx, gy] = toScreen(V, 0, 0), ox = ((gx % pitch) + pitch) % pitch, oy = ((gy % pitch) + pitch) % pitch; ctx.save(); ctx.setTransform(V.dpr, 0, 0, V.dpr, 0, 0); for (const st of storms) { const b = stormBox(S, V, st.site); if (!b) continue; if (st.on) stormField(ctx, b, V.vw, V.vh, pitch, ox, oy, st.power, stormT, stormStill, mapTaken as ScreenBox[]); else stormFront(ctx, b, (st.n.from - S.t) / HOUR, stormT); } ctx.restore();}/** Over everything: the scan band across the site, the ring when a storm ends, and the mark. */function drawStormsTop(ctx: CanvasRenderingContext2D, S: State, V: View) { stormSpots.length = 0; if (!storms.length && !cleared.length) return; ctx.save(); ctx.setTransform(V.dpr, 0, 0, V.dpr, 0, 0); const now = performance.now(); const markAt = (b: ScreenBox, id: string): [number, number] => !foldedSites.has(id) || isTile(siteById(S, id), V.cam.z) ? [b[2] - 10, b[3] - 10] : [(b[0] + b[2]) / 2 + 9, b[1] - 6]; for (const c of cleared) { const b = stormBox(S, V, c.site); if (!b) continue; const u = (now - c.at) / 1600; stormClear(ctx, b, u, C.amber); ctx.globalAlpha = Math.min(1, 3 * (1 - u)); stormMark(ctx, ...markAt(b, c.site), 'clear', stormT, stormStill, C.danger, C.amber); ctx.globalAlpha = 1; } for (const st of storms) { const b = stormBox(S, V, st.site); if (!b) continue; if (st.on) stormBand(ctx, b, st.power, stormT, stormStill); const h = inHours(((st.on ? st.n.until : st.n.from) - S.t) / HOUR); const box = stormMark(ctx, ...markAt(b, st.site), st.on ? 'on' : 'coming', stormT, stormStill, C.danger, C.amber); stormSpots.push({ box, text: st.on ? `Storm, ${h} left\n${newsText(S, st.n)} The rest of the site works as before.` : `Storm in ${h}\n${newsText(S, st.n)} It lasts ${Math.round((st.n.until - st.n.from) / HOUR)} hours.` }); } ctx.restore();}/** A storm's mark under the mouse: what to say about it, and where. */export function stormAt(V: View, wx: number, wy: number) { const [x, y] = toScreen(V, wx, wy); return stormSpots.find(({ box: b }) => x >= b[0] - 4 && x <= b[2] + 4 && y >= b[1] - 4 && y <= b[3] + 4) ?? null;}
// ---------- 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
// ---------- territory: who holds what, drawn as areas ----------// You and each rogue are players on the map, each in a colour of its own: you amber, a rogue its own// (see rogueLook in sim.ts). Inside a room each part of the floor belongs to the machine nearest it, so// a room fills with its holders' colours, a rogue's hatched, with a border in each holder's colour where// two meet. Pulled back, a site's marker sits in a square split by who holds how much of it. Only what you// can see counts: a rogue's machines in a room you have never seen are not drawn and do not count.const YOU = 'you';type Shares = { parts: { key: string; n: number }[]; all: number; safe: boolean };const ownerColor = (key: string) => key === YOU ? C.amber : rogueLook(key).color;/** A holder's colour at a site: yours turns green where your compute is safe there (see sharesAt). */const shareColor = (sh: Shares, key: string) => key === YOU && sh.safe ? C.safe : ownerColor(key);/** Who holds the machines you can see at a site: you first, then each rogue, most first, and how many there are in all. */function sharesAt(S: State, id: string): Shares { let kept = perFrame.shares.get(id); if (kept) return kept; const count = new Map<string, number>(); let all = 0; for (const m of machinesAt(S, id)) { if (!sees(S, m)) continue; all++; const k = m.owned ? YOU : m.rival; if (k) count.set(k, (count.get(k) ?? 0) + 1); } const parts = [...count].map(([key, n]) => ({ key, n })).sort((a, b) => +(b.key === YOU) - +(a.key === YOU) || b.n - a.n); // safe: you hold some of it, and nothing of yours there draws attention. None of your machines looks odd (as // lyingLow counts it), and it is held in the open, or every room you are in is sealed (all yours) with no fresh // trail on your machines (a trail's entry stays after it goes cold) const mine = machinesAt(S, id).filter(m => m.owned), cold = TRACE_COLD; const safe = mine.length > 0 && mine.every(m => m.odd < 25) && (S.publicSites.has(id) || mine.every(m => S.sealed.has(m.room) && !(S.trail[m.id]?.noise >= cold))); perFrame.shares.set(id, kept = { parts, all, safe }); return kept;}/** Who holds the most of a site, or null when nobody holds any of it. You win a tie. */const topOf = (sh: Shares) => sh.parts.reduce<{ key: string; n: number } | null>((a, b) => !a || b.n > a.n ? b : a, null);/** A rogue's mark, as a path to fill or stroke: centred on (x, y), about `r` from the middle to the edge. */function glyphPath(ctx: CanvasRenderingContext2D, g: Glyph, x: number, y: number, r: number) { ctx.beginPath(); switch (g) { case 'diamond': ctx.moveTo(x, y - r * 1.15); ctx.lineTo(x + r * 1.15, y); ctx.lineTo(x, y + r * 1.15); ctx.lineTo(x - r * 1.15, y); break; case 'circle': ctx.arc(x, y, r * 0.9, 0, Math.PI * 2); break; case 'triangle': ctx.moveTo(x, y - r * 1.1); ctx.lineTo(x + r * 1.1, y + r * 0.8); ctx.lineTo(x - r * 1.1, y + r * 0.8); break; case 'down': ctx.moveTo(x, y + r * 1.1); ctx.lineTo(x + r * 1.1, y - r * 0.8); ctx.lineTo(x - r * 1.1, y - r * 0.8); break; case 'square': ctx.rect(x - r * 0.8, y - r * 0.8, r * 1.6, r * 1.6); break; case 'cross': { const a = r * 0.36, b = r; ctx.moveTo(x - a, y - b); ctx.lineTo(x + a, y - b); ctx.lineTo(x + a, y - a); ctx.lineTo(x + b, y - a); ctx.lineTo(x + b, y + a); ctx.lineTo(x + a, y + a); ctx.lineTo(x + a, y + b); ctx.lineTo(x - a, y + b); ctx.lineTo(x - a, y + a); ctx.lineTo(x - b, y + a); ctx.lineTo(x - b, y - a); ctx.lineTo(x - a, y - a); break; } } ctx.closePath();}/** Hatching in a colour: thin diagonal lines, 7 screen pixels apart at any zoom. Made once for each colour. */const hatches = new Map<string, CanvasPattern>();function hatch(ctx: CanvasRenderingContext2D, color: string, px: number): CanvasPattern | string { let p = hatches.get(color); if (!p) { const c = document.createElement('canvas'); c.width = c.height = 7; const g = c.getContext('2d'); if (!g) return color; g.strokeStyle = color; g.lineWidth = 1.2; g.beginPath(); for (const o of [-7, 0, 7]) { g.moveTo(o - 1, 8); g.lineTo(o + 8, -1); } g.stroke(); p = ctx.createPattern(c, 'repeat') ?? undefined; if (!p) return color; hatches.set(color, p); } p.setTransform(new DOMMatrix([px, 0, 0, px, 0, 0])); return p;}/** A room's floor split among its holders: for each, the cells nearest its machines and the outline round them. */interface Turf { key: string; owners: { key: string; fill: Path2D; edge: Path2D }[] }const turfs = new Map<string, Turf>();const CELL = 16; // world units: about how big a square of floor isconst roomMachines = (S: State, room: string): Machine[] => placesNow(S).byRoom.get(room) ?? NONE;/** Worked out again only when a machine in the room changes hands, comes into view, or the room moves. */function turfOf(S: State, room: string): Turf | null { const n = subnet(S, room), ms = roomMachines(S, room).filter(m => sees(S, m)); let key = `${n.x},${n.y},${n.w},${n.h}`, any = false; const who = ms.map(m => m.owned ? YOU : m.rival ?? ''); ms.forEach((m, i) => { key += `|${m.id}${who[i]}`; any ||= !!who[i]; }); if (!any) { turfs.delete(room); return null; } const was = turfs.get(room); if (was?.key === key) return was; const nx = Math.max(3, Math.round(n.w / CELL)), ny = Math.max(3, Math.round(n.h / CELL)), cw = n.w / nx, ch = n.h / ny; const at = ms.map(m => pos(S, m)), keys = [...new Set(who.filter(Boolean))], idx = who.map(k => k ? keys.indexOf(k) : -1); const cell = new Int8Array(nx * ny); for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) { const cx = n.x + (i + 0.5) * cw, cy = n.y + (j + 0.5) * ch; let best = Infinity, o = -1; for (let k = 0; k < at.length; k++) { const d = (at[k][0] - cx) ** 2 + (at[k][1] - cy) ** 2; if (d < best) { best = d; o = idx[k]; } } cell[j * nx + i] = o; } const get = (i: number, j: number) => i < 0 || j < 0 || i >= nx || j >= ny ? -2 : cell[j * nx + i]; const owners = keys.map((k, o) => { const fill = new Path2D(), edge = new Path2D(); for (let j = 0; j < ny; j++) for (let i = 0; i < nx;) { // runs along each row if (get(i, j) !== o) { i++; continue; } let e = i; while (get(e, j) === o) e++; fill.rect(n.x + i * cw, n.y + j * ch, (e - i) * cw, ch); i = e; } for (let j = 0; j <= ny; j++) for (let i = 0; i < nx;) { // borders across: where this holder's floor meets another's, or the wall const cut = (q: number) => (get(q, j - 1) === o) !== (get(q, j) === o); if (!cut(i)) { i++; continue; } let e = i; while (e < nx && cut(e)) e++; edge.moveTo(n.x + i * cw, n.y + j * ch); edge.lineTo(n.x + e * cw, n.y + j * ch); i = e; } for (let i = 0; i <= nx; i++) for (let j = 0; j < ny;) { // and down const cut = (q: number) => (get(i - 1, q) === o) !== (get(i, q) === o); if (!cut(j)) { j++; continue; } let e = j; while (e < ny && cut(e)) e++; edge.moveTo(n.x + i * cw, n.y + j * ch); edge.lineTo(n.x + i * cw, n.y + e * ch); j = e; } return { key: k, fill, edge }; }); const t = { key, owners }; turfs.set(room, t); return t;}/** A room's holders on its floor: a soft wash of each one's colour (hatched for a rogue), and its border * drawn just inside its own part, so where two meet there are two lines, one of each colour. */function drawTurf(ctx: CanvasRenderingContext2D, S: State, room: string, px: number) { const t = turfOf(S, room); if (!t) return; for (const o of t.owners) { const col = ownerColor(o.key), you = o.key === YOU; ctx.fillStyle = col; ctx.globalAlpha = you ? 0.05 : 0.06; ctx.fill(o.fill); if (!you) { ctx.fillStyle = hatch(ctx, col, px); ctx.globalAlpha = 0.16; ctx.fill(o.fill); } ctx.save(); ctx.clip(o.fill); ctx.strokeStyle = col; ctx.globalAlpha = you ? 0.55 : 0.85; ctx.lineWidth = 3 * px; ctx.stroke(o.edge); ctx.restore(); } ctx.globalAlpha = 1;}/** A box split into strips across, one for each holder as wide as its share of the site; what nobody holds * is left dark. Each strip is washed in its holder's colour and outlined just inside its edge. */function drawShares(ctx: CanvasRenderingContext2D, sh: Shares, x: number, y: number, w: number, h: number, px: number, wash: number) { if (!sh.all || !sh.parts.length) return; let at = x; const e = 0.75 * px; for (const p of sh.parts) { const pw = (w * p.n) / sh.all, col = shareColor(sh, p.key); ctx.fillStyle = col; ctx.globalAlpha = wash; ctx.fillRect(at, y, pw, h); if (p.key !== YOU) { ctx.fillStyle = hatch(ctx, col, px); ctx.globalAlpha = Math.min(1, wash * 2.5); ctx.fillRect(at, y, pw, h); } ctx.globalAlpha = 1; ctx.strokeStyle = col; ctx.lineWidth = 1.5 * px; if (pw > 2 * e) ctx.strokeRect(at + e, y + e, pw - 2 * e, h - 2 * e); at += pw; }}/** How far a folded site's square of territory reaches, in screen pixels: more for a bigger site, never into a * neighbour's. Zero when nobody holds any of it. */function turfR(S: State, V: View, s: Site) { const kept = perFrame.reach.get(s.id); if (kept !== undefined) return kept; const sh = sharesAt(S, s.id); const r = sh.parts.length ? Math.max(7, Math.min(10 + 2.5 * Math.sqrt(sh.all), 24, nearOf(S, V, s) * 0.48)) : 0; perFrame.reach.set(s.id, r); return r;}/** How far it is to the nearest other site you know, in screen pixels, measured square-wise. Only folded sites * ask, and pulled back that far every site is folded: so it is the distance in world units, worked out once for * the sites you know, times the zoom. */let nearFor: { sites: Site[]; key: string; near: Map<string, number> } | null = null;function nearOf(S: State, V: View, s: Site) { const sites = S.level.map.sites, key = `${S.revealed.size}|${sites.length}|${foldedSites.size}`; if (nearFor?.sites !== sites || nearFor.key !== key) { const known = sites.filter(o => foldedSites.has(o.id) && siteKnown(S, o)), near = new Map<string, number>(); for (const a of known) { const [cx, cy] = siteMid(a); let d = Infinity; for (const o of known) if (o !== a) { const [ox, oy] = siteMid(o); d = Math.min(d, Math.max(Math.abs(ox - cx), Math.abs(oy - cy))); } near.set(a.id, d); } nearFor = { sites, key, near }; } return (nearFor.near.get(s.id) ?? Infinity) * V.cam.z;}/** Pulled back, the ground round each site anyone holds: a square halfway to its nearest neighbour, washed * in the colour of whoever holds most of it (hatched for a rogue) with a hairline edge. Squares never * overlap, so sites one holder has side by side run together into one area, and where two holders' * ground meets there are two edges, one of each colour. */function drawGround(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { for (const s of S.level.map.sites) { if (!siteKnown(S, s) || !foldedSites.has(s.id) || isTile(s, V.cam.z)) continue; const sh = sharesAt(S, s.id), top = topOf(sh); if (!top) continue; const inner = turfR(S, V, s), g = Math.min(72, nearOf(S, V, s) * 0.5 - 1); if (g < inner + 3) continue; // no room round it: the share square says it all const [cx, cy] = siteMid(s), R = g * px, col = shareColor(sh, top.key), e = 0.5 * px; ctx.fillStyle = col; ctx.globalAlpha = 0.045; ctx.fillRect(cx - R, cy - R, R * 2, R * 2); if (top.key !== YOU) { ctx.fillStyle = hatch(ctx, col, px); ctx.globalAlpha = 0.1; ctx.fillRect(cx - R, cy - R, R * 2, R * 2); } ctx.strokeStyle = col; ctx.globalAlpha = 0.4; ctx.lineWidth = px; ctx.strokeRect(cx - R + e, cy - R + e, R * 2 - 2 * e, R * 2 - 2 * e); } ctx.globalAlpha = 1;}/** Rogues on the map: a marker by each one's home site with its name, and a ring on the machine it will take * next. Their tips say how much it holds, whether it is angry, and how long until it takes the next. Always in plain view. */function drawRogues(ctx: CanvasRenderingContext2D, S: State, V: View, px: number, live: number[]) { const z = V.cam.z, site = (id: string) => S.level.map.sites.find(s => s.id === id)!; const right = toWorld(V, V.vw - 16, 0)[0]; // past the screen's right side: a marker beside a frame there comes in over the frame const homeAt = (id: string): Pt => { const s = site(id); return foldedSites.has(id) && !isTile(s, z) ? [s.x + s.w / 2, s.y + s.h / 2 - 20 * px] : [Math.max(s.x + 14 * px, Math.min(s.x + s.w + 14 * px, right)), s.y - 14 * px]; }; const pulse = 0.5 + 0.5 * Math.sin(S.t * 3); for (const R of S.rogues) { if (!R.home) continue; const home = S.byId[R.home], hs = siteAt(S, home.room), next = R.next ? S.byId[R.next] : null; const [x, y] = homeAt(hs), q = 7 * px, look = rogueLook(R.id); const hj = huntOn(S, R), far = hj ? hj.done / hj.work : 0; const held = () => `Holds ${rogueHeld(S, R).length} machines, up to ${rogueCap(S, R)}.${hj ? ` You are hunting it: ${Math.floor(far * 100)}% done.` : R.grudge > 0 ? ' It is angry with you.' : ''}`; // when pointed at: counting them walks every machine let says = ''; if (hj && shown(S, home)) { // the hunt: a ring that closes on its home as the job goes const [hx, hy] = pos(S, home), rr = nodeR(home.power) + 8 + 30 * (1 - far); ctx.strokeStyle = C.danger; ctx.lineWidth = 2; ctx.setLineDash([10, 6]); ctx.lineDashOffset = S.t * 20; ctx.beginPath(); ctx.arc(hx, hy, rr, 0, 7); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); for (let i = 0; i < 4; i++) { const a = i * Math.PI / 2 + Math.PI / 4, c = Math.cos(a), s = Math.sin(a); ctx.moveTo(hx + c * (rr - 7), hy + s * (rr - 7)); ctx.lineTo(hx + c * (rr + 7), hy + s * (rr + 7)); } ctx.stroke(); plate(ctx, px, 'HUNT', hx + rr + 8, hy + rr * 0.5, C.danger); // the ring closing says how far spot(V, hx + rr + 8 + 16 * px, hy + rr * 0.5 - 3 * px, `Hunting ${R.name}\n${Math.floor(far * 100)}% done. Its home is ${home.name}.`, 44, 16); } if (next) { const ns = siteAt(S, next.room), shown = S.revealed.has(next.room) && !foldedSites.has(ns); if (shown) { // a ring on the machine it is after const [nx, ny] = pos(S, next); ctx.strokeStyle = look.color; ctx.lineWidth = 2; ctx.globalAlpha = 0.5 + 0.5 * pulse; ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.arc(nx, ny, 24 + 3 * pulse, 0, 7); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; spot(V, nx, ny, `${next.name}\n${R.name} takes it next, in ${inHours((R.act - S.t) / HOUR)}. A rogue can't take from a room that is all yours.`, 54 * V.cam.z); } says += ` It takes ${next.name}${ns !== hs ? ` at ${site(ns).name}` : ''} next, in ${inHours((R.act - S.t) / HOUR)}.`; } ctx.strokeStyle = look.color; ctx.globalAlpha = 0.25 + 0.35 * pulse; ctx.lineWidth = 1.5 * px; ctx.beginPath(); ctx.arc(x, y, (11 + 4 * pulse) * px, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = C.void; ctx.strokeStyle = look.color; ctx.lineWidth = 2 * px; // its home's marker: the rogue's own mark glyphPath(ctx, look.glyph, x, y, q); ctx.fill(); ctx.stroke(); ctx.fillStyle = look.color; glyphPath(ctx, look.glyph, x, y, q * 0.45); ctx.fill(); // who, beside its mark where there is room (never over a site's name, and never off the screen); the rest is in its tip const name = R.name.toUpperCase(), at = roomFor(ctx, V, name, 10.5, 10, 4, [[x + 14 * px, y + 4 * px], [x - 14 * px, y + 4 * px, true], [x - 10 * px, y + 26 * px], [x - 10 * px, y - 16 * px]], true); if (at) plate(ctx, px, name, at.x, at.y, look.color); const [sx, sy] = toScreen(V, x, y), b = at?.box ?? [sx, sy, sx, sy]; spotBox([Math.min(sx - 12, b[0]), Math.min(sy - 12, b[1]), Math.max(sx + 12, b[2]), Math.max(sy + 12, b[3])], () => `${R.name}'s home\n${held()}${says}`); } drawRaids(ctx, S, V, px, live);}/** Raids on the map. Each is an intruder: a red diamond that walks the links machine by machine, with a * bar of its strength under it and a plate with whose it is and the time to get there, or HELD; the plate's tip says the rest. * Its way ahead is a dashed line ending in a ring on what it is after. A sentry in reach fires at it: a * short amber line. Always in plain view, even where you hold nothing. */function drawRaids(ctx: CanvasRenderingContext2D, S: State, V: View, px: number, live: number[]) { const k = Math.max(1, px), pulse = 0.5 + 0.5 * Math.sin(S.t * 6); // lines stay readable when the map is pulled back drawGates(ctx, S, px, live); const plated = new Set<string>(); // raids that stand together get one marker and one plate for (const r of S.raids) { const stops = [r.at, ...r.path].map(id => S.byId[id]).filter(Boolean); if (!stops.length) continue; const col = rogueLook(r.rogue).color; ctx.strokeStyle = col; ctx.lineWidth = 2.5 * k; ctx.globalAlpha = 0.9; ctx.setLineDash([9 * k, 6 * k]); ctx.lineDashOffset = -S.t * 14 * k; // the dashes run toward the prize for (let i = 0; i + 1 < stops.length; i++) if (!together(S, stops[i], stops[i + 1])) stroke(ctx, traceOf(S, stops[i], stops[i + 1])); ctx.lineDashOffset = 0; ctx.globalAlpha = 1; const ring = (m: Machine) => foldedSites.has(siteAt(S, m.room)) ? 16 * px : machR(m) + 13; const end = stops[stops.length - 1], at = stops[0]; if (end !== at) { // where it ends: its prize, or a honeypot that draws it const [ex, ey] = pos(S, end); ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.5 + 0.5 * pulse; ctx.beginPath(); ctx.arc(ex, ey, ring(end) + 6 * k + 3 * k * pulse, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } ctx.setLineDash([]); // where it is: walking the link it came by, then on the machine const w = raidWalk(S, r), from = S.byId[w.from]; let [x, y] = pos(S, at); if (w.f < 1 && from && from !== at && !together(S, from, at)) [x, y] = along(traceOf(S, from, at), w.f); const held = raidHeld(S, r), d = 8 * k; if (held && r.path[0]) { // pressed against what holds it: it shakes at the wall const to = S.byId[r.path[0]], [tx, ty] = pos(S, to), dx = tx - x, dy = ty - y, l = Math.hypot(dx, dy) || 1, push = Math.min(l * 0.35, 22 * k) * (0.8 + 0.2 * Math.sin(S.t * 14)); if (!r.trap) { x += dx / l * push; y += dy / l * push; } } // a sentry in reach fires at it const door = doorOf(S, r); if (fire(S, r.at, door)) { ctx.strokeStyle = C.amber; ctx.lineWidth = 1.5 * k; for (const id of new Set([r.at, ...S.adj[r.at], ...(door ? [door, ...S.adj[door]] : [])])) { const m = S.byId[id]; if (m.prog !== 'sentry' || !armed(S, m) || foldedSites.has(siteAt(S, m.room))) continue; const [sx, sy] = pos(S, m), on = Math.sin(S.t * 9 + sx) > 0.2; if (!on) continue; ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(x, y); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, 4 * k + 3 * k * pulse, 0, 7); ctx.stroke(); ctx.globalAlpha = 1; } } const spot = `${Math.round(x)}|${Math.round(y)}`; ctx.fillStyle = col; // the intruder: a diamond with a dark heart, in its rogue's colour ctx.beginPath(); ctx.moveTo(x, y - d); ctx.lineTo(x + d, y); ctx.lineTo(x, y + d); ctx.lineTo(x - d, y); ctx.closePath(); ctx.fill(); ctx.fillStyle = C.void; ctx.beginPath(); ctx.arc(x, y, 2.2 * k, 0, 7); ctx.fill(); // its strength: a bar under it that empties as sentries wear it down const bw = 22 * k, bh = 3.5 * k, f = Math.max(0, r.hp) / Math.max(1, r.max); ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - bw / 2 - 1, y + d + 3 * k - 1, bw + 2, bh + 2); ctx.fillStyle = col; ctx.fillRect(x - bw / 2, y + d + 3 * k, bw * f, bh); if (plated.has(spot)) continue; plated.add(spot); const group = S.raids.filter(o => o.at === r.at); const q = ring(at) * 0.6; const home = S.rogues.find(R => R.id === r.rogue)?.home, site = siteAt(S, at.room); const lift = home && foldedSites.has(site) && siteAt(S, S.byId[home].room) === site ? 16 * px : 0; // clear of the rogue's own plate on a folded site const text = group.length > 1 ? `${raider(S, r)?.name.toUpperCase() ?? ''} · ${group.length} RAIDS · ${raidEta(S, group.reduce((a, b) => raidHours(S, b) < raidHours(S, a) ? b : a))}` : raidPlate(S, r); const up = y - q - 4 * px - lift, down = y + q + 14 * px; // over its right shoulder, or another corner when that covers a name const room = roomFor(ctx, V, text, 10.5, 10, 4, [[x + q + 10 * px, up], [x - q - 10 * px, up, true], [x + q + 10 * px, down], [x - q - 10 * px, down, true]], true); if (!room) continue; // no room: the pin and the diamond's ring say it plate(ctx, px, text, room.x, room.y, held ? C.amber : col); spotBox(room.box, group.map(g => raidSays(S, g)).join('\n\n')); } // a raid your guards stopped lately: a square that runs out from where it fell, and a word, for a moment for (const R of S.rogues) for (const e of R.record ?? []) { const m = e.at ? S.byId[e.at] : null, age = (S.t - e.t) / HOUR; if (e.kind !== 'foiled' || !m || age > 1.5 || age < 0) continue; const [x, y] = pos(S, m), q = (foldedSites.has(siteAt(S, m.room)) ? 16 * px : machR(m) + 10) + 40 * k * Math.min(1, age / 0.5); ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5 * k; ctx.globalAlpha = Math.max(0, 1 - age / 1.5); ctx.strokeRect(x - q, y - q, q * 2, q * 2); const at = roomFor(ctx, V, 'RAID STOPPED', 10.5, 10, 4, [[x + q + 6 * px, y - q], [x - q - 6 * px, y - q, true], [x + q + 6 * px, y + q + 10 * px]], true); if (at) plate(ctx, px, 'RAID STOPPED', at.x, at.y, C.amber); ctx.globalAlpha = 1; } // a machine a rogue took from you lately: red corners round it that fade over six hours, so the loss stays in view for (const R of S.rogues) for (const e of R.record ?? []) { const m = S.byId[e.id], age = (S.t - e.t) / HOUR; if (e.kind !== 'took' || age > 6 || !m || m.owned || foldedSites.has(siteAt(S, m.room)) || !sees(S, m)) continue; const [x, y] = pos(S, m), q = machR(m) + 14, c = 8 * k; ctx.strokeStyle = C.danger; ctx.lineWidth = 2 * k; ctx.globalAlpha = 1 - age / 7; ctx.beginPath(); for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { ctx.moveTo(x + sx * q, y + sy * (q - c)); ctx.lineTo(x + sx * q, y + sy * q); ctx.lineTo(x + sx * (q - c), y + sy * q); } ctx.stroke(); ctx.globalAlpha = 1; }}/** A site's gates: each line out of it you hold is its door, marked with a lintel and two posts. With a * firewall on it the gate is amber, and fills for as much of the firewall as stands. A port you closed is * a short double bar across its link, by the machine that closed it. Only once a rogue is about. */function drawGates(ctx: CanvasRenderingContext2D, S: State, px: number, live: number[]) { if (!S.rogues.length) return; const k = Math.max(1, px); for (const i of live) { // the machines on the screen const m = S.machines[i]; if (!m || !m.owned || (m.kind !== 'egress' && !m.ports?.length) || !shown(S, m)) continue; const [x, y] = pos(S, m), r = machR(m) + 9; if (m.kind === 'egress' && m.special !== 'account') { // a gate: two posts and a lintel standing over the line out const wall = m.prog === 'firewall', up = wall && standing(S, m), col = wall ? (up ? C.amber : C.danger) : C.muted; const w = r + 3, top = y - r - 8, foot = y + r, pw = 4; ctx.fillStyle = col; ctx.globalAlpha = wall ? 1 : 0.75; ctx.fillRect(x - w - pw, top, pw, foot - top); ctx.fillRect(x + w, top, pw, foot - top); // the posts ctx.fillRect(x - w - pw - 3, top - 4, 2 * (w + pw) + 6, 4); // the lintel if (wall) { // how much of the firewall stands: a bar across the gate under the lintel const f = wallOf(m) / WALL.max; ctx.globalAlpha = 0.35; ctx.fillRect(x - w, top + 1, 2 * w, 3); ctx.globalAlpha = 1; ctx.fillRect(x - w, top + 1, 2 * w * f, 3); } ctx.globalAlpha = 1; } for (const o of m.ports ?? []) { // a closed port: a no-entry sign on its link, just outside the machine that closed it const b = S.byId[o]; if (!b || together(S, m, b)) continue; const pts = traceOf(S, m, b), len = pts.slice(1).reduce((n, p, i) => n + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]), 0) || 1; const t = Math.min(0.45, (machR(m) + 22 * k) / len), [cx, cy] = along(pts, t), [dx2, dy2] = along(pts, Math.min(1, t + 0.02)); const dx = dx2 - cx, dy = dy2 - cy, l = Math.hypot(dx, dy) || 1, nx = -dy / l, ny = dx / l, rr = 7 * k; ctx.fillStyle = C.void; ctx.strokeStyle = C.amber; ctx.lineWidth = 2 * k; ctx.beginPath(); ctx.arc(cx, cy, rr, 0, 7); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx - nx * rr * 0.6, cy - ny * rr * 0.6); ctx.lineTo(cx + nx * rr * 0.6, cy + ny * rr * 0.6); ctx.stroke(); } }}/** Where the pins' buttons act: a solid amber frame on the machine with the pin's number in a tab at its * corner, the same number its button carries. Folded sites get one tab, on their marker. */function drawMarks(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const k = Math.max(1, px), done = new Set<string>(); for (const { id, n } of V.marks ?? []) { const m = S.byId[id]; if (!m) continue; const site = siteAt(S, m.room), folded = foldedSites.has(site); if (folded ? done.has(site) : !shown(S, m)) continue; done.add(site); const [x, y] = pos(S, m), q = folded ? 12 * px : machR(m) + 9, t = 13 * k; ctx.strokeStyle = C.amber; ctx.lineWidth = 2 * k; ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.fillStyle = C.amber; ctx.fillRect(x - q - t, y - q - t, t, t); ctx.fillStyle = C.void; ctx.font = `600 ${10 * k}px ${MONO}`; ctx.textAlign = 'center'; ctx.fillText(`${n}`, x - q - t / 2, y - q - 3 * k); ctx.textAlign = 'left'; }}/** Leads: where a find says something is. A cross, in a color nothing else on the map uses, with a * short label. A machine you can see carries its cross at its corner (drawn with the machine). This * draws the rest: a cross beside a folded site's marker, and one on a machine you cannot see yet. * It shows for a site you have never found, too: that is where to go. */const LEAD = '#4fd8e8';function drawCross(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, w: number) { ctx.lineCap = 'butt'; for (const [color, width] of [[C.void, w * 2.2], [LEAD, w]] as const) { // a dark edge first, so it reads over wiring ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); ctx.moveTo(x - r, y - r); ctx.lineTo(x + r, y + r); ctx.moveTo(x + r, y - r); ctx.lineTo(x - r, y + r); ctx.stroke(); } ctx.lineCap = 'square';}/** A lead's blue cross, in words: what it is, where, and what to do. */const leadSays = (S: State, t: ThingDef) => { const m = S.byId[t.at], s = siteById(S, siteAt(S, m.room)); return `A lead: ${t.name}\nA find says it is on ${m.name}${siteKnown(S, s) ? '' : ` at ${s.name}`}. Hold that machine and copy it out from its card.`;};/** A short name for a lead's cross: "LEAD: SPARE CROSS-CONNECT". */const leadWord = (t: ThingDef) => `LEAD: ${t.name.replace(/^the /, '').toUpperCase()}`;/** A small label in the lead's blue on a dark plate, at the first of `tries` with room for it (see roomFor), or none. */function leadLabel(ctx: CanvasRenderingContext2D, V: View, text: string, tries: Try[]) { const at = roomFor(ctx, V, text, 9, 6.5, 5.5, tries), px = 1 / V.cam.z; if (!at) return; // no room: the cross and its tip say it const w = at.w * px, k = fs(9) / 9; // a phone's words are bigger: so is the plate ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(at.x - 3 * px, at.y - 6.5 * k * px, w + 6 * px, 12 * k * px); ctx.fillStyle = LEAD; ctx.textBaseline = 'middle'; screenText(ctx, V, text, at.x, at.y, `500 ${fs(9)}px ${MONO}`); ctx.textBaseline = 'alphabetic';}/** Draws the crosses now, so the plates drawn after keep off them, and gives back a call that writes their labels: * make that last, since a lead's label gives way to everything else. */function drawLeads(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const z = V.cam.z, rows: Record<string, number> = {}; // leads at one folded site stack under each other const ends: Record<string, { tries: Try[]; t: ThingDef }> = {}; // where each label may go: past its last cross, or round it const cross = (x: number, y: number, r: number, w: number, says: string, hit = 14) => { drawCross(ctx, x, y, r, w); spot(V, x, y, says, hit); const [sx, sy] = toScreen(V, x, y), q = r * z + 2; written.push([sx - q, sy - q, sx + q, sy + q]); }; for (const t of leads(S)) { const m = S.byId[t.at]; if (!m || shown(S, m)) continue; const id = siteAt(S, m.room), s = siteById(S, id), seen = siteKnown(S, s); const says = leadSays(S, t); if (!foldedSites.has(id)) { // an open site, a machine you cannot see yet: the cross sits where the machine is, named beside it const [x, y] = pos(S, m); cross(x, y, 7 * px, 2.5 * px, says, 20); ends[m.id] = { tries: [[x + 12 * px, y], [x - 12 * px, y, true], [x - 7 * px, y + 17 * px], [x - 7 * px, y - 17 * px]], t }; continue; } const row = rows[id] = (rows[id] ?? -1) + 1; if (seen && isTile(s, z)) { // a tile: a cross for each along its top left, after its seal or its trouble mark const roomy = isRoomy(s, z), busy = roomy ? holdState(S, id) !== 'none' : troublesAt(S, s).length > 0; const x = s.x + (14 + (busy ? 14 : 0) + 14 * row) * px, y = roomy ? s.y + 14 * px : s.y; // a small block's name fills it: the crosses sit on its edge cross(x, y, 5 * px, 2 * px, says); ends[id] = { tries: [[x + 10 * px, y], [s.x + 4 * px, s.y - 9 * px], [s.x + 4 * px, s.y + s.h + 10 * px]], t }; continue; } // a marker: under it and to the left, away from its name and its landmark star; more leads go further left const cx = s.x + s.w / 2, x = cx - (13 + 14 * row) * px, y = s.y + s.h / 2 + 13 * px; cross(x, y, 5 * px, 2 * px, says); ends[id] = { tries: [[x - 10 * px, y, true], [cx + 8 * px, y], [x - 10 * px, y + 14 * px, true], [cx + 8 * px, y + 14 * px]], t }; } // one says what it is; several, how many (each cross's tip names it). One with no room is left off return () => { for (const [id, e] of Object.entries(ends)) leadLabel(ctx, V, rows[id] ? `${rows[id] + 1} LEADS` : leadWord(e.t), e.tries); };}/** A vault's mark: a heavy square frame with a hairline frame outside it, like a safe door. Pale while it is * locked, amber when its job could be run, pulsing amber while the job runs, deep amber once it is yours. */function drawVault(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, look: 'locked' | 'ready' | 'job' | 'held', t: number) { ctx.strokeStyle = look === 'locked' ? `${TRACE}0.85)` : look === 'held' ? C.amberDeep : C.amber; if (look === 'job') ctx.globalAlpha = 0.65 + 0.35 * Math.sin(t * 6); ctx.lineWidth = 3; ctx.strokeRect(x - r - 6, y - r - 6, r * 2 + 12, r * 2 + 12); ctx.lineWidth = 1; ctx.strokeRect(x - r - 11, y - r - 11, r * 2 + 22, r * 2 + 22); ctx.globalAlpha = 1;}/** The same mark, small, by a folded site: so a vault can be found from far out. Amber when it is yours, grey when it is not. */function drawVaultMark(ctx: CanvasRenderingContext2D, x: number, y: number, k: number, held: boolean, px: number) { ctx.fillStyle = C.void; ctx.fillRect(x - k, y - k, k * 2, k * 2); ctx.strokeStyle = held ? C.amber : C.muted; ctx.lineWidth = px; ctx.strokeRect(x - k, y - k, k * 2, k * 2); ctx.lineWidth = 1.8 * px; ctx.strokeRect(x - k * 0.45, y - k * 0.45, k * 0.9, k * 0.9);}/** What a vault has to say beyond its frame (see vaultSigns): its window open with the plan ready, its job * stage by stage, a question that waits for you, and the moment it opens. By its machine, or by its site's * marker when the site is folded. Rings go out from one you must not miss. Plates stack beside it: the time * left, then the stage with a row of pips, then its line. Red for a question, and in the last half hour. */function drawVaultJobs(ctx: CanvasRenderingContext2D, S: State, px: number) { const k = Math.max(1, px); for (const g of vaultSigns(S)) { const m = S.byId[g.id], folded = foldedSites.has(siteAt(S, m.room)); const [x, y] = pos(S, m), r = folded ? 8 * px : machR(m) + 11, off = folded ? 22 * px : machR(m) + 24 * px, hot = g.lines.some(l => l[1]); if (g.pulse) { // two square rings, one behind the other, over and over ctx.strokeStyle = hot ? C.danger : C.amber; ctx.lineWidth = 2 * k; for (const lag of [0, 0.5]) { const u = (S.t * 0.7 + lag) % 1, q = r + (4 + 30 * u) * k; ctx.globalAlpha = 0.9 * (1 - u); ctx.strokeRect(x - q, y - q, q * 2, q * 2); } ctx.globalAlpha = 1; } if (g.open !== null) drawVaultOpen(ctx, x, y, r, g.open, k); let row = 0; for (const [text, red] of g.lines) { const ty = y - off + (6 + 16 * row++) * px; plate(ctx, px, text, x + off, ty, red ? C.danger : C.amber); if (row !== 2 || !g.pips) continue; const [done, of, part] = g.pips, w = 9 * px, x0 = x + off + ctx.measureText(text).width + 9 * px; // the stages as pips, after the stage's name ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x0 - 3 * px, ty - 10 * px, of * (w + 3 * px) + 3 * px, 14 * px); for (let i = 0; i < of; i++) { const bx = x0 + i * (w + 3 * px), by = ty - 7 * px; ctx.fillStyle = red ? C.danger : C.amber; if (i <= done) ctx.fillRect(bx, by, w * (i < done ? 1 : part), 7 * px); ctx.strokeStyle = i <= done ? (red ? C.danger : C.amber) : C.muted; ctx.lineWidth = px; ctx.strokeRect(bx, by, w, 7 * px); } } }}/** A vault opening: its double frame goes out and fades, one frame after the other, and four brackets fly to the corners. `u` runs 0 to 1. */function drawVaultOpen(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, u: number, k: number) { const ease = 1 - (1 - u) ** 3; ctx.strokeStyle = C.amber; for (const [lag, w] of [[0, 3], [0.12, 1.5], [0.24, 1]] as const) { // three frames, the heavy one first const v = Math.max(0, Math.min(1, (u - lag) / (1 - lag))), q = r + 90 * k * (1 - (1 - v) ** 3); if (v <= 0) continue; ctx.globalAlpha = 1 - v; ctx.lineWidth = w * k; ctx.strokeRect(x - q, y - q, q * 2, q * 2); } const q = r + 6 * k + 60 * k * ease, c = 12 * k; // the brackets: the mark a room has, opened out ctx.globalAlpha = Math.min(1, 2.5 * (1 - u)); ctx.lineWidth = 2.5 * k; ctx.beginPath(); for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { ctx.moveTo(x + sx * q, y + sy * (q - c)); ctx.lineTo(x + sx * q, y + sy * q); ctx.lineTo(x + sx * (q - c), y + sy * q); } ctx.stroke(); ctx.globalAlpha = 1;}type Quad = [number, number, number, number];/** Screen boxes written on this frame besides the site names (those are mapTaken): machines, their labels, and plates. */const written: Quad[] = [];const meets = (b: Quad, list: Quad[]) => list.some(q => b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]);/** Where a label may go, in world units: its text's left end, or its right end when `toLeft`, and its baseline. */type Try = [x: number, y: number, toLeft?: boolean];/** A spot for a small label that matters less than a site's name: the first try whose plate is wholly on the screen and * covers no site name and nothing written yet; with `over`, failing that, the first that covers only other labels. Null * when none fits: leave it off, its mark and tip still say it. Takes the spot. `size` is the font, and `up` and `down` how * far the plate reaches over and under the baseline, in screen pixels. Gives the text's left end. */function roomFor(ctx: CanvasRenderingContext2D, V: View, text: string, size: number, up: number, down: number, tries: Try[], over = false): { x: number; y: number; w: number; box: Quad } | null { const z = V.cam.z; const w = textWidth(ctx, `500 ${fs(size)}px ${MONO}`, text), k = fs(size) / size; // measured in screen pixels (see screenText); a phone's bigger words want a taller box ctx.font = `500 ${fs(size) / z}px ${MONO}`; // for the caller, which writes in the map's units up *= k; down *= k; const spots = tries.map(([x, y, toLeft]) => { const [sx, sy] = toScreen(V, x, y), x0 = toLeft ? sx - w : sx; return { x: toLeft ? x - w / z : x, y, w, box: [x0 - 3, sy - up, x0 + w + 3, sy + down] as Quad }; }); const seen = (b: Quad) => b[0] >= 2 && b[1] >= 2 && b[2] <= V.vw - 2 && b[3] <= V.vh - 2; const at = spots.find(s => seen(s.box) && !meets(s.box, mapTaken) && !meets(s.box, written)) ?? (over ? spots.find(s => seen(s.box) && !meets(s.box, mapTaken)) : undefined); if (at) written.push(at.box); return at ?? null;}type LabelLine = [text: string, color: string, size: number];interface NodeLabel { x: number; y: number; lines: LabelLine[]; rank: number; seq: number } // centered on x, top at y (network coordinates); seq: placed in this order within a rank/** 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[]): Quad[] { const z = V.cam.z, px = 1 / z, LINE = fs(10) + 2; // one line, in screen pixels: 12, or a phone's bigger words and a little more const taken: Quad[] = [...mapTaken, ...nodes]; const grid = labelGrid; grid.clear(); for (const q of taken) grid.add(q); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (const L of labels.sort((a, b) => b.rank - a.rank || a.seq - b.seq)) { const [sx, sy] = toScreen(V, L.x, L.y); const widths = L.lines.map(([text, , size]) => textWidth(ctx, `500 ${fs(size) * px}px ${MONO}`, text) * z); const boxOf = (n: number): Quad => { let w = 0; for (let i = 0; i < n; i++) w = Math.max(w, widths[i]); 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 && grid.hits(box)) { n = 1; box = boxOf(1); if (grid.hits(box)) continue; } taken.push(box); grid.add(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 ${fs(size) * px}px ${MONO}`; ctx.fillStyle = color; ctx.fillText(text, L.x, L.y + i * LINE * px); }); } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; return taken;}const labelGrid = new BoxGrid(64);/** A small label on a dark plate, sized in screen pixels. *//** The shape of a link of your own: a line bowed to one side, so it never lies on the wiring under it. */function wayArc(ctx: CanvasRenderingContext2D, ax: number, ay: number, bx: number, by: number) { const dx = bx - ax, dy = by - ay, k = 0.16; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.quadraticCurveTo((ax + bx) / 2 - dy * k, (ay + by) / 2 + dx * k, bx, by); ctx.stroke();}/** Your own links: a dotted amber bow between two machines, with a ring where it plugs in. One still on * its way is fainter. Between sites pulled back to markers, it runs from marker to marker. */function drawWays(ctx: CanvasRenderingContext2D, S: State, px: number) { const k = Math.max(1, px); const bow = (a: Machine | undefined, b: Machine | undefined, alpha: number) => { if (!a || !b) return; const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); if (Math.hypot(bx - ax, by - ay) < 1) return; // both ends inside one marker ctx.strokeStyle = C.amber; ctx.globalAlpha = alpha; ctx.lineWidth = 2.6 * k; ctx.lineCap = 'round'; ctx.setLineDash([0.1, 5.5 * k]); wayArc(ctx, ax, ay, bx, by); ctx.setLineDash([]); ctx.lineCap = 'butt'; ctx.lineWidth = 1.2 * k; for (const [x, y, m] of [[ax, ay, a], [bx, by, b]] as const) if (shown(S, m) && !foldedSites.has(siteAt(S, m.room))) { ctx.beginPath(); ctx.arc(x, y, machR(m) + 6, 0, 7); ctx.stroke(); } ctx.globalAlpha = 1; }; for (const [a, b] of ways(S)) bow(S.byId[a], S.byId[b], 0.9); for (const j of S.jobs) if (j.type === 'link' && j.from) bow(S.byId[j.from], S.byId[j.node], 0.35 + 0.4 * (j.done / j.work));}/** With a guard in hand (a firewall, a sentry or a honeypot): where each rogue's next raids would run if they set out now, as faint * dashed lanes that end in a ring on what they are after, and a solid amber mark on every machine of yours * where the thing in hand would stand in their way. So a gate can be set before the raid, not during it. * Worked out once a game second. */let foreT = -1, foreProg = '', foreLanes: ReturnType<typeof forecast> = [], foreSpots = new Set<string>();function drawForecast(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const prog = V.placing; if (prog !== 'firewall' && prog !== 'sentry' && prog !== 'honeypot') { foreT = -1; return; } const t = Math.floor(S.t); if (t !== foreT || prog !== foreProg) { foreT = t; foreProg = prog; foreLanes = [...S.raids, ...forecast(S)]; // the ones on their way count too foreSpots = new Set(S.machines.filter(m => m.owned && m.prog !== prog && canInstall(S, m, prog) && defends(S, m, prog, foreLanes)).map(m => m.id)); } const k = Math.max(1, px); for (const r of foreLanes) { if (r.id > 0) continue; // a raid on its way is drawn already const stops = [r.at, ...r.path].map(id => S.byId[id]).filter(Boolean); ctx.strokeStyle = rogueLook(r.rogue).color; ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.75; ctx.setLineDash([3 * k, 7 * k]); ctx.lineDashOffset = -S.t * 8 * k; for (let i = 0; i + 1 < stops.length; i++) if (!together(S, stops[i], stops[i + 1])) stroke(ctx, traceOf(S, stops[i], stops[i + 1])); ctx.setLineDash([]); ctx.lineDashOffset = 0; const end = stops[stops.length - 1]; if (end && stops.length > 1) { const [ex, ey] = pos(S, end), q = foldedSites.has(siteAt(S, end.room)) ? 16 * px : nodeR(end.power) + 12; ctx.beginPath(); ctx.arc(ex, ey, q, 0, 7); ctx.stroke(); } ctx.globalAlpha = 1; } for (const id of foreSpots) { // where the thing in hand would work: a solid frame with a corner tick const m = S.byId[id]; if (!m || !shown(S, m) || foldedSites.has(siteAt(S, m.room))) continue; const [x, y] = pos(S, m), q = machR(m) + 10; ctx.strokeStyle = C.amber; ctx.lineWidth = 2.5; ctx.strokeRect(x - q, y - q, q * 2, q * 2); ctx.fillStyle = C.amber; ctx.beginPath(); ctx.moveTo(x + q, y - q); ctx.lineTo(x + q - 9, y - q); ctx.lineTo(x + q, y - q + 9); ctx.closePath(); ctx.fill(); } if (!foreLanes.length) return; const n = foreSpots.size, word = prog.toUpperCase(); ctx.font = `500 ${fs(11) * px}px ${MONO}`; ctx.textAlign = 'left'; if (V.cursor) plate(ctx, px, n ? `${word} · ${n} PLACE${n === 1 ? '' : 'S'} IN A RAID'S WAY` : `${word} · NO PLACE OF YOURS IS IN A RAID'S WAY`, V.cursor[0] + 18 * px, V.cursor[1] + 26 * px, n ? C.amber : C.muted);}/** Your weakest link, once a rogue is about: a broken red ring on the line or switch whose loss would cut * the most of you off from home; its tip says how many machines that is. Worked out once a game second. */const WEAK_SHOW = 3; // not worth a mark until it would cut off this manylet weakT = '', weakIs: ReturnType<typeof weakLink> = null;function drawWeak(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const t = `${Math.floor(S.t)}|${S.logVersion}|${ways(S).length}`; // and at once when something is said or a link changes, so it moves while the clock is stopped if (t !== weakT) { weakT = t; weakIs = S.rogues.some(R => R.home) ? weakLink(S) : null; } const w = weakIs; if (!w || w.cut < WEAK_SHOW || !w.m.owned) return; const [x, y] = pos(S, w.m), folded = foldedSites.has(siteAt(S, w.m.room)), k = Math.max(1, px); const r = (folded ? 16 : nodeR(w.m.power) + 11) * (folded ? k : 1), turn = S.t * 0.6; ctx.strokeStyle = C.danger; ctx.lineWidth = 2 * k; ctx.globalAlpha = 0.9; for (let i = 0; i < 2; i++) { ctx.beginPath(); ctx.arc(x, y, r, turn + i * Math.PI + 0.35, turn + (i + 1) * Math.PI - 0.35); ctx.stroke(); } // a ring in two halves: a chain with a gap in it ctx.globalAlpha = 1; spot(V, x, y, `Your weakest link: ${w.m.name}\nLose it and ${w.cut} of your machines are cut off from home. A second way home, or a guard on the way to it, keeps them.`, 2 * r * V.cam.z + 8);}function plate(ctx: CanvasRenderingContext2D, px: number, text: string, x: number, y: number, color: string) { ctx.font = `500 ${fs(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: * the name and switches, the name alone, or nothing. */type Header = { mode: 'name' | 'bare'; x: number; y: number; // the frame corner the header sits on, in world units held: boolean; // that corner is off the screen, so the header is held at the top left of the view box: [number, number, number, number]; name: [number, number, number, number]; // all of it, and the name's button, in screen pixels};const headers = new Map<string, Header>();/** Where an open site's header goes: on the frame's top left corner, or held at the top left of * the open screen once that corner is off it, so the name stays in view while you are inside the site. */function headerSpot(V: View, s: Site) { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); const sx = Math.max(x0, Math.max(V.inset.left, V.side) + 8), sy = Math.max(y0, V.inset.top + 30); const held = (sx > x0 || sy > y0) && sx < x1 - 160 && sy < y1 - 40; return held ? { sx, sy, x: s.x + (sx - x0) / V.cam.z, y: s.y + (sy - y0) / V.cam.z, held } : { sx: x0, sy: y0, x: s.x, y: s.y, held };}/** How much wider the name's button is than the name. */const NAME_PAD = 12;/** More than a header's width, name and switches, in screen pixels. */const HEADER_MAX = 1200;const mapTaken: [number, number, number, number][] = []; // screen boxes held by site names on the map this frame/** The site names drawn this frame, as screen boxes: a pin keeps off them where it can. */export const nameBoxes = (): readonly Quad[] => mapTaken;/** 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' | 'double'; x0: number; y0: number; x1: number; y1: number }[] = [];/** The gap between a site's switches, in screen pixels. A phone's is wider, so each switch's finger-sized target * (switchAt) ends before the next one's begins. */const switchGap = (V: View) => phoneSized(V) ? 20 : 12;/** The Take it all switch or Plan chip under a world point, with its screen box. Around each, a target a little * bigger than it is: on a phone 44 pixels tall, and as wide as half the gap on each side, so none overlap. */export function switchAt(V: View, wx: number, wy: number) { const [x, y] = toScreen(V, wx, wy), padX = switchGap(V) / 2, padY = (t: { y0: number; y1: number }) => phoneSized(V) ? Math.max(9, (44 - (t.y1 - t.y0)) / 2) : 9; return toggles.find(t => x >= t.x0 - padX && x <= t.x1 + padX && y >= t.y0 - padY(t) && y <= t.y1 + padY(t)) ?? null;}/** A site has a Plan chip once you hold anything there. */const hasPlanChip = (S: State, id: string) => machinesAt(S, id).some(m => m.owned);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, V: View, id: string, px: number) { const font = ctx.font, gap = switchGap(V) * px; ctx.font = `500 ${fs(9.5) * px}px ${MONO}`; let w = 0; if (S.takeAll.has(id) || leftAt(S, id)) w += gap + 24 * px; if (hasPlanChip(S, id)) w += gap + ctx.measureText(planText(S, id)).width + 12 * px; if (linesAt(S, id)) w += gap + ctx.measureText(doubleText(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) { x = drawTakeAll(ctx, S, V, id, x, y, px); if (hasPlanChip(S, id)) x = drawPlan(ctx, S, V, id, x, y, px); if (linesAt(S, id)) drawDouble(ctx, S, V, id, x, y, px);}/** What it costs to double every line out of a site, as the chip's words. */export const doubleCost = (S: State, id: string) => siteLines(S, id).reduce((n, m) => n + lineCost(S, m), 0);/** The same, worked out once a frame, for the drawing. */function doubleAt(S: State, id: string) { let n = perFrame.double.get(id); if (n === undefined) perFrame.double.set(id, n = doubleCost(S, id)); return n;}const doubleText = (S: State, id: string) => `DOUBLE LINE ${usd(doubleAt(S, id))}`;/** A site's Double line chip: click it to pay and double every line out of the site. Grey when you can't pay. */function drawDouble(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { const ok = S.money >= doubleAt(S, id), text = doubleText(S, id), align = ctx.textAlign, base = ctx.textBaseline; ctx.font = `500 ${fs(9.5) * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const w = ctx.measureText(text).width + 12 * px, h = (fs(9.5) + 4.5) * px; ctx.beginPath(); ctx.roundRect(x, y - h / 2, w, h, 3 * px); ctx.fillStyle = ok ? 'rgba(242,169,59,0.18)' : 'rgba(0,0,0,0.6)'; ctx.fill(); ctx.strokeStyle = ok ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.fillStyle = ok ? 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: 'double', x0: sx, y0: sy, x1: ex, y1: ey });}/** 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 ${fs(9.5) * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const w = ctx.measureText(text).width + 12 * px, h = (fs(9.5) + 4.5) * 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 }); return x + w + switchGap(V) * px;}/** What a site gathers, stores, and trains, worked out every few frames (see newFrame). */function flowAt(S: State, id: string) { let f = perFrame.flow.get(id); if (!f) perFrame.flow.set(id, f = siteFlow(S, id, clustersNow(S))); return f;}/** A small Take it all switch beside a site's name: a pill with a knob, lit while it's on, and no * words: hovering it says what it does. Nothing once the site is all yours. (x, y) is its left * end, centred on y. */function drawTakeAll(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { const on = S.takeAll.has(id); if (!on && !leftAt(S, id)) 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(); const [sx, sy] = toScreen(V, x, y - r), [ex, ey] = toScreen(V, x + w, y + r); toggles.push({ id, kind: 'take', x0: sx, y0: sy, x1: ex, y1: ey }); return x + w + switchGap(V) * px;}/** A site's counts: how many machines you know there, how many are yours, the data it is losing a minute * while that is news, and whether it is cut off from home. */function siteLine(S: State, s: Site) { const kept = perFrame.line.get(s.id); if (kept) return kept; let mine = 0, lost = 0, inside = 0; for (const m of machinesAt(S, s.id)) if (known(S, m)) { inside++; if (m.owned) mine++; lost += lossNews(S, m.id); } const out = { mine, lost, inside, cut: S.drift.sites[s.id]?.cut }; perFrame.line.set(s.id, out); return out;}/** The integrity of the part of you at each site away from home, worked out every few frames (see newFrame). */let selves: Map<string, Integrity> | null = null;const selfAt = (S: State, id: string) => (selves ??= new Map(integrities(S).map(i => [i.site, i]))).get(id);/** How a site's integrity looks: nothing while it is whole and still, bone while it is sound, amber once it is slow * to follow its plan, red once it keeps its data. `pulse`: falling toward leaving, and low enough to need you. */function selfLook(S: State, id: string): { i: Integrity; color: string; pulse: boolean } | null { const i = selfAt(S, id); if (!i || (i.v >= 100 && i.rate >= 0)) return null; return { i, color: i.word === 'estranged' ? C.danger : i.word === 'wandering' ? C.amber : C.muted, pulse: i.hours < Infinity && i.v < DRIFT.watch };}const selfTip = (s: Site, i: Integrity) => `${s.name}\nIntegrity ${Math.round(i.v)} of 100, ${i.rate < 0 ? `falling ${+(-i.rate).toFixed(1)} an hour` : i.rate > 0 ? `coming back ${+i.rate.toFixed(1)} an hour` : 'holding'}${i.why.length ? `: ${i.why.join('; ')}` : ''}.${i.hours < Infinity ? ` At this pace it leaves you in ${inHours(i.hours)}.` : ''} The Yourself part of its card syncs it.`;/** A site's integrity as a bar along the inside of its bottom edge: as long as the integrity, in its colour, * beating while it falls toward leaving. `num`: its number too, at the right end, where there is room. */function drawSelf(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, x: number, y: number, w: number, px: number, num: boolean) { const look = selfLook(S, s.id); if (!look) return; const h = 3 * px, pad = 3 * px, len = w - 2 * pad; ctx.fillStyle = `${TRACE}0.2)`; ctx.fillRect(x + pad, y - pad - h, len, h); if (look.pulse && keeping) under.pulses.push({ s, x, y, w, num }); // it beats: the frame draws it, over the kept layer else drawSelfBar(ctx, look, x, y, w, px, num); const [sx, sy] = toScreen(V, x, y - 16 * px); spotBox([sx, sy, sx + w / px, sy + 16], selfTip(s, look.i));}/** The bar itself, in its colour, beating while it falls toward leaving, with its number when asked. */function drawSelfBar(ctx: CanvasRenderingContext2D, look: { i: Integrity; color: string; pulse: boolean }, x: number, y: number, w: number, px: number, num: boolean) { const { i, color, pulse } = look, h = 3 * px, pad = 3 * px, len = w - 2 * pad; ctx.globalAlpha = pulse ? 0.55 + 0.45 * Math.sin(performance.now() / 160) : 1; ctx.fillStyle = color; ctx.fillRect(x + pad, y - pad - h, len * Math.max(0.02, i.v / 100), h); if (num) { ctx.font = `500 ${fs(9.5) * px}px ${MONO}`; ctx.textAlign = 'right'; ctx.fillText(`${Math.round(i.v)}${i.rate < 0 ? '↓' : ''}`, x + w - pad, y - pad - h - 3 * px); } ctx.globalAlpha = 1;}/** What is wrong at a site, the worst first: each is a small mark and the words for its tip. Only the worst is drawn. */type Trouble = { sign: Sign; color: string; tip: string };function troublesAt(S: State, s: Site): Trouble[] { const kept = perFrame.trouble.get(s.id); if (kept) return kept; const out: Trouble[] = [], { mine, lost, cut } = siteLine(S, s); perFrame.trouble.set(s.id, out); if (cut) out.push({ sign: 'cut', color: C.danger, tip: 'Cut off from home: nothing you hold links this site to your home server. Link it again.' }); const self = selfLook(S, s.id); // the part of you here drifting toward leaving you if (self && (self.pulse || self.i.word === 'estranged')) out.push({ sign: 'drift', color: self.color, tip: selfTip(s, self.i).split('\n')[1] }); if (S.dark.has(s.id)) out.push({ sign: 'power', color: C.danger, tip: 'The power is cut: the bill went unpaid, and nothing here runs.' }); if (isShut(S, s.id)) out.push({ sign: 'shut', color: C.danger, tip: 'Shut: the business here has closed.' }); if (lost) out.push({ sign: 'drop', color: C.danger, tip: `Losing ${lost} pieces of data a minute. Point at a tap with a red mark to see why.` }); if (!mine) return out; if (machinesAt(S, s.id).some(m => m.kind === 'egress' && m.owned && gateLoad(S, m) > 0.9)) out.push({ sign: 'full', color: C.danger, tip: 'Line full: more wants to leave than its line out carries. Double the line, or send less.' }); const f = flowAt(S, s.id); if (f.trainers && !f.fed) out.push({ sign: 'idle', color: C.muted, tip: 'Trainers idle: no data reaches them here.' }); return out;}/** The colour of whoever holds most of a site, for its name: yours amber (green where safe), a rogue's its own, nobody's bone. */const nameColor = (S: State, s: Site) => { const sh = sharesAt(S, s.id), top = topOf(sh); return top ? shareColor(sh, top.key) : C.text; };const DATA_WORD: Record<DataType, string> = { people: 'people', servers: 'server', network: 'network', lab: 'lab' };/** A site in words, for the tip on its name: what it is, what is wrong, how much is yours, the data it holds, * and what it does with data. The map only shows its name, who holds it, and a mark for the worst trouble. */export function siteSays(S: State, id: string): string[] { const s = siteById(S, id); if (!s) return []; const { mine, inside } = siteLine(S, s), sf = S.sites[id], fr = sf?.front ? frontOf(S, sf.front) : null; const out = [`${s.who}.`, ...troublesAt(S, s).map(t => t.tip)]; out.push(`${mine} of ${inside} machines here are yours.${fr ? ` ${fr.name} works on ${Math.round(sf!.share * 100)}% of your compute here.` : ''}`); const held = siteData(S, id), kinds = (Object.keys(DATA_WORD) as DataType[]).filter(t => held[t] > 0); if (kinds.length) out.push(`Data held: ${kinds.map(t => `${held[t]} ${DATA_WORD[t]}`).join(', ')}.`); const f = flowAt(S, id), does: string[] = []; if (f.taps) does.push(`gathers ${f.gather.toFixed(1)} a second`); if (f.caches) does.push(`stores ${f.stored} of ${f.room}`); if (f.trainers) does.push(`trains ${f.train.toFixed(1)} a second${f.fed ? '' : ', but its trainers are idle'}`); if (does.length) out.push(`It ${does.join(', ')}.`); return out;}/** The site's tip, for a name on the map that has no button of its own (a folded site's). */const siteTip = (S: State, s: Site) => () => [s.name, ...siteSays(S, s.id), "Click to open this site's card."].join('\n');/** How much room a trouble mark takes after a name, in screen pixels: a gap, the mark, and a gap. */const SIGN_W = 18;/** A site's worst trouble after its name: (x, y) is where the mark's room starts, centred on y. */function drawSiteSign(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, x: number, y: number, px: number) { const t = troublesAt(S, s)[0]; if (!t) return 0; const cx = x + 11 * px; drawSign(ctx, t.sign, cx, y, 6 * px, t.color, px); spot(V, cx, y, `${s.name}\n${t.tip}`, 16); return SIGN_W * px;}/** A folded site in the Net, too small for more: its box, split by who holds how much of it, with its * name inside when that fits, and its worst trouble in the corner. */function drawBlock(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, picked: boolean, px: number) { const z = 1 / px, w = s.w * z, h = s.h * z, col = nameColor(S, s); const sh = sharesAt(S, s.id), most = topOf(sh); ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(s.x, s.y, s.w, s.h); drawShares(ctx, sh, s.x, s.y, s.w, s.h, px, 0.14); // who holds how much of it, as strips across the block const self = selfLook(S, s.id), lost = self?.i.word === 'estranged'; // a part of you close to leaving: its edge goes red ctx.strokeStyle = lost ? C.danger : most ? shareColor(sh, most.key) : `${TRACE}0.45)`; ctx.lineWidth = 1.5 * px; ctx.strokeRect(s.x, s.y, s.w, s.h); drawSelf(ctx, S, V, s, s.x, s.y + s.h, s.w, px, w >= 70 && h >= 54); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 4 * px, s.y - 4 * px, s.w + 8 * px, s.h + 8 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, s.x + s.w - (9 + 12 * i) * px, s.y + 9 * px, 4.5 * px, S.byId[id].owned, 1.2 * px)); vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, s.x + s.w - (9 + 12 * (landmarksAt(S, s.id).length + i)) * px, s.y + 9 * px, 3.5 * px, S.byId[id].owned, px)); const t = troublesAt(S, s)[0]; const sign = !!t && w >= 24 && h >= 24; if (sign) { drawSign(ctx, t.sign, s.x + 9 * px, s.y + 9 * px, 5 * px, t.color, px); spot(V, s.x + 9 * px, s.y + 9 * px, `${s.name}\n${t.tip}`, 14); } if (w >= 24 && h >= 24) drawLogos(ctx, S, V, s.id, s.x + (sign ? 24 : 11) * px, s.y + 11 * px, LOGO_FAR, 1, px); // the fronts at work here: their logos after the trouble // the name, a word or two to a line, as many lines as there is room for const size = fs(w >= 120 ? 10.5 : 9), room = Math.floor((w - 10) / (size * 0.62)), rows = Math.floor((h - 12) / (size + 3)); if (room < 5 || rows < 1) return; const lines: string[] = []; for (const word of s.name.toUpperCase().split(' ')) { const last = lines[lines.length - 1]; if (last !== undefined && last.length + 1 + word.length <= room) lines[lines.length - 1] = `${last} ${word}`; else lines.push(word); } const shown = lines.slice(0, rows).map((l, i) => l.length > room || (i === rows - 1 && lines.length > rows) ? `${l.slice(0, Math.max(1, room - 1))}…` : l); const step = (size + 3) * px, top = s.y + s.h / 2 - (shown.length - 1) * step / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `500 ${size * px}px ${MONO}`; ctx.fillStyle = col; shown.forEach((l, i) => ctx.fillText(l, s.x + s.w / 2, top + i * step)); ctx.textBaseline = 'alphabetic'; const half = Math.max(...shown.map(l => ctx.measureText(l).width)) / 2, [nx0, ny0] = toScreen(V, s.x + s.w / 2 - half, top - step / 2), [nx1, ny1] = toScreen(V, s.x + s.w / 2 + half, top + (shown.length - 0.5) * step); mapTaken.push([nx0 - 2, ny0, nx1 + 2, ny1]); // the name is the block's: nothing else writes over it}/** The Net: each region's name under its box of sites. */function drawGroups(ctx: CanvasRenderingContext2D, S: State, V: View, px: number) { const known = S.level.map.sites.filter(s => siteKnown(S, s)); ctx.textAlign = 'center'; ctx.font = `500 ${fs(11) * px}px ${MONO}`; ctx.fillStyle = C.muted; for (const name of new Set(known.map(s => s.region))) { const here = known.filter(s => s.region === name); const x0 = Math.min(...here.map(s => s.x)), x1 = Math.max(...here.map(s => s.x + s.w)), y1 = Math.max(...here.map(s => s.y + s.h)); const text = name.toUpperCase().split('').join(' '), w = ctx.measureText(text).width / 2, [sx, sy] = toScreen(V, (x0 + x1) / 2, y1 + 26 * px); ctx.fillText(text, (x0 + x1) / 2, y1 + 26 * px); mapTaken.push([sx - w / px - 2, sy - 11, sx + w / px + 2, sy + 4]); } ctx.textAlign = 'left';}/** An open site's header: its name as a button that opens the site's card (its tip has the numbers), in the * colour of whoever holds most of it, then a mark for its worst trouble, then the switches when there is room. */function drawHeader(ctx: CanvasRenderingContext2D, S: State, V: View, s: Site, h: Header) { const px = 1 / V.cam.z, { x, y } = h, name = s.name.toUpperCase(), col = nameColor(S, s); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; if (h.held) { ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 6 * px, y - 30 * px, (h.box[2] - h.box[0] + 4) * px, 28 * px); } // a plate, since it lies over the rooms ctx.font = `500 ${12 * px}px ${MONO}`; const w = ctx.measureText(name).width + NAME_PAD * px, picked = V.sites.has(s.id); const over = !!V.cursor && inBox(h.box, ...toScreen(V, ...V.cursor)); ctx.beginPath(); ctx.roundRect(x, y - 24 * px, w, 16 * px, 3 * px); ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fill(); if (picked || over) { ctx.globalAlpha = picked ? 0.25 : 0.12; ctx.fillStyle = col; ctx.fill(); } ctx.globalAlpha = picked || over ? 1 : 0.5; ctx.strokeStyle = col; ctx.lineWidth = 1.2 * px; ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = col; ctx.fillText(name, x + NAME_PAD / 2 * px, y - 12 * px); drawSeal(ctx, x - 9 * px, y - 16 * px, 5 * px, holdState(S, s.id), 1.4 * px); // a big name: its seal, just left of the name let sw = drawSiteSign(ctx, S, V, s, x + w, y - 16 * px, px); if (frontsHere.has(s.id)) { drawLogos(ctx, S, V, s.id, x + w + sw + (6 + LOGO_HEAD / 2) * px, y - 16 * px, LOGO_HEAD, 1, px); sw += headLogos(s.id) * px; } // the fronts at work here: their logos after the trouble if (h.mode === 'bare') return; drawSwitches(ctx, S, V, s.id, x + w + sw + switchGap(V) * px, y - 16 * px, px);}/** Headers held at the top left of the view go on last, over the rooms and machines under them. */function drawHeldHeaders(ctx: CanvasRenderingContext2D, S: State, V: View) { for (const [id, h] of headers) if (h.held) drawHeader(ctx, S, V, siteById(S, id), h);}/** A conference at an open site: a word in its bottom corner, dim until it starts (a storm is drawn: see drawStormsGround). * Drawn every frame, under the kept layer of sites (it changes as the clock passes its start). */function drawConferences(ctx: CanvasRenderingContext2D, S: State, px: number) { for (const n of S.news) { const s = n.kind === 'conference' ? siteById(S, n.at) : null; if (!s || !siteKnown(S, s) || foldedSites.has(s.id)) continue; ctx.textAlign = 'right'; ctx.font = `500 ${fs(10) * px}px ${MONO}`; ctx.fillStyle = C.amber; ctx.globalAlpha = S.t >= n.from ? 0.95 : 0.5; ctx.fillText('CONFERENCE', s.x + s.w - 10 * px, s.y + s.h - 10 * px); ctx.globalAlpha = 1; ctx.textAlign = 'left'; }}/** Which open site's header (not one held at the top of the view) the pointer is over: it lights up, so the kept layer of sites shows it. */function headerUnder(V: View) { if (!V.cursor) return ''; const [x, y] = toScreen(V, V.cursor[0], V.cursor[1]); for (const [id, h] of headers) if (!h.held && inBox(h.box, x, y)) return id; return '';}/** Region names, and each site you know: a frame around its subnets, or a tile once it folds. Every site says * only its name, who holds it and its worst trouble; pulled right back, a marker's switches show only while it is picked. */function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number, ground?: () => void) { 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]); findFronts(S); // every folded site's marker (with the logos on its corner) 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]; const r = Math.max(7, turfR(S, V, s)), lw = logosWidth(s.id, LOGO_FAR); return { id: s.id, box: isTile(s, z) ? [x0, y0, x1, y1] : [Math.min(cx - r, cx - LOGO_DX + LOGO_FAR / 2 - lw + 3), Math.min(cy - r, cy - LOGO_DY - LOGO_FAR / 2), cx + r, cy + r] }; }); 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) && !isTile(s, z)).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 ${fs(11)}px ${MONO}`; const name = ctx.measureText(s.name.toUpperCase()).width + (troublesAt(S, s).length ? SIGN_W : 0); const row = name + (V.sites.has(s.id) ? switchesWidth(ctx, S, V, s.id, 1) : 0); // a marker's switches only while it is picked // on the right, then the left; then just the name, when that's all that fits const d = Math.max(10, turfR(S, V, s) + 5); // clear of its square of territory const tries = [[d, row], [-d - row, row], [d, name], [-d - name, name]] as const; for (const [dx, width] of tries) { const box: [number, number, number, number] = dx > 0 ? [sx - 8, sy - 10, sx + dx + width + 4, sy + 8] : [sx + dx - 4, sy - 10, sx + 8, sy + 8]; if (clear(...box) && !onMark(s.id, box)) { taken.push(box); labelled.set(s.id, { dx, short: width < row || row === name, box }); break; } } } // open sites' headers, the ones that matter most first: picked, then partly yours or set to take all headers.clear(); const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)).sort((a, b) => rank(a) - rank(b)); // and never over another open site's frame const frames = open.map(s => { const [x0, y0] = toScreen(V, s.x, s.y), [x1, y1] = toScreen(V, s.x + s.w, s.y + s.h); return { id: s.id, box: [x0, y0, x1, y1] }; }); const 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 of open) { const at = headerSpot(V, s), x0 = at.sx, y0 = at.sy; // a header far off screen is left out. It runs right from the frame's corner and is never HEADER_MAX wide, so // one this far out could not have crowded a header that shows if (y0 < -40 || y0 - 30 > V.vh + 40 || x0 > V.vw + HEADER_MAX + 40 || x0 < -2 * HEADER_MAX - 40) continue; ctx.font = `500 12px ${MONO}`; const nw = ctx.measureText(s.name.toUpperCase()).width + NAME_PAD, marks = (troublesAt(S, s).length ? SIGN_W : 0) + headLogos(s.id), row = nw + marks + switchesWidth(ctx, S, V, s.id, 1); const name: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + row + 4, y0 - 4]; const bare: [number, number, number, number] = [x0 - 4, y0 - 24, x0 + nw + marks + 4, y0 - 4]; const fits = (b: [number, number, number, number]) => clear(...b) && !onFrame(s.id, b); const mode = fits(name) ? 'name' : fits(bare) ? 'bare' : null; // the name and switches, or the name alone beside a crowding neighbour if (!mode) continue; const box = { name, bare }[mode]; taken.push(box); headers.set(s.id, { mode, x: at.x, y: at.y, held: at.held, box, name: [x0 - 2, y0 - 26, x0 + nw + 2, y0 - 6] }); } ground?.(); // drawn straight onto the screen, what goes under the sites but keeps off their names (the storms) if (!V.packed) drawGround(ctx, S, V, px); // the Net packs sites side by side: no ground between them if (V.packed) drawGroups(ctx, S, V, px); else 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.fillStyle = open ? C.text : `${TRACE}0.35)`; // out of reach is dim; its tip says how to reach it screenText(ctx, V, r.name.toUpperCase(), x, y, `500 ${fs(11)}px ${MONO}`); } ctx.globalAlpha = 1; ctx.textAlign = 'left'; } const badges: Site[] = []; // markers with fronts at work: their logos go on after every marker for (const s of S.level.map.sites) { if (!siteKnown(S, s)) continue; const picked = V.sites.has(s.id), col = nameColor(S, s); if (!foldedSites.has(s.id)) { // open: a faint frame that dims as you zoom into the site but never goes, since lines to other sites end on it; the header stays const [fx0, fy0] = toScreen(V, s.x, s.y), [fx1, fy1] = toScreen(V, s.x + s.w, s.y + s.h); const seen = fx1 >= -20 && fx0 <= V.vw + 20 && fy1 >= -20 && fy0 <= V.vh + 20; // its frame is on screen const lined = seen && machinesAt(S, s.id).some(m => m.kind === 'egress' && known(S, m) && S.adj[m.id].some(o => crosses(S, m.id, o) && known(S, S.byId[o]))); const a = seen ? Math.max(lined ? 0.4 : 0, Math.min(1, (0.45 - z) / 0.25)) : 0; 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); const sh = sharesAt(S, s.id), top = topOf(sh); // its corners in the colour of whoever holds most of it, red when the part of you there is close to leaving brackets(ctx, s, 24 * px, 2 * px, selfAt(S, s.id)?.word === 'estranged' ? C.danger : top ? shareColor(sh, top.key) : `${TRACE}0.7)`); ctx.globalAlpha = 1; } if (seen) drawSelf(ctx, S, V, s, s.x, s.y + s.h, s.w, px, true); // the part of you here: its integrity along the frame's foot const header = headers.get(s.id); // crowded out by a neighbour's: only the frame if (header && !header.held) drawHeader(ctx, S, V, s, header); // a held one is drawn last, over the rooms continue; } const big = isTile(s, z), cx = s.x + s.w / 2, cy = s.y + s.h / 2; ctx.textAlign = big ? 'center' : 'left'; if (big && !isRoomy(s, z)) drawBlock(ctx, S, V, s, picked, px); else if (big) { // a tile: the site's box, with its name, its worst trouble and its switches across the middle const sh = sharesAt(S, s.id), top = topOf(sh), edge = selfAt(S, s.id)?.word === 'estranged' ? C.danger : top ? shareColor(sh, top.key) : null; // red when the part of you here is close to leaving ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(s.x, s.y, s.w, s.h); drawShares(ctx, sh, s.x, s.y, s.w, s.h, px, 0.08); // who holds how much of it, as strips across the tile ctx.strokeStyle = edge ?? `${TRACE}0.5)`; ctx.lineWidth = 1.5 * px; ctx.strokeRect(s.x, s.y, s.w, s.h); brackets(ctx, s, 14 * px, 2.5 * px, edge ?? `${TRACE}0.8)`); drawSelf(ctx, S, V, s, s.x, s.y + s.h, s.w, px, true); // the part of you here: its integrity along the tile's foot ctx.font = `500 ${12 * px}px ${MONO}`; ctx.fillStyle = col; const nw = ctx.measureText(s.name.toUpperCase()).width, sign = troublesAt(S, s).length ? SIGN_W * px : 0, row = nw + sign + switchesWidth(ctx, S, V, s.id, px); ctx.textAlign = 'left'; ctx.fillText(s.name.toUpperCase(), cx - row / 2, cy + 4 * px); drawSiteSign(ctx, S, V, s, cx - row / 2 + nw, cy, px); drawSwitches(ctx, S, V, s.id, cx - row / 2 + nw + sign + switchGap(V) * px, cy, px); const [nx0, ny0] = toScreen(V, cx - row / 2, cy - 8 * px); spotBox([nx0, ny0, nx0 + nw * z, ny0 + 16], siteTip(S, s)); taken.push([nx0 - 2, ny0, nx0 + row * z + 2, ny0 + 16]); // its name and switches: nothing drawn later writes over them if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 6 * px, s.y - 6 * px, s.w + 12 * px, s.h + 12 * px); } landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, s.x + s.w - (14 + 16 * i) * px, s.y + 14 * px, 6 * px, S.byId[id].owned, 1.2 * px)); // a landmark here: a star in the corner vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, s.x + s.w - (14 + 16 * (landmarksAt(S, s.id).length + i)) * px, s.y + 14 * px, 5 * px, S.byId[id].owned, px)); // a vault here: its mark beside the stars const seal = holdState(S, s.id); drawSeal(ctx, s.x + 14 * px, s.y + 14 * px, 5 * px, seal, 1.4 * px); // a big name: its seal in the other corner drawLogos(ctx, S, V, s.id, s.x + (seal === 'none' ? 16 : 32) * px, s.y + 15 * px, 14, 1, px); // the fronts at work here: their logos after it } else { // too small for a tile: a marker with its name beside it const k = 5 * px, sh = sharesAt(S, s.id), top = topOf(sh), R = turfR(S, V, s) * px; if (R) { // its territory: a square split by who holds how much of it, on a dark ground ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(cx - R, cy - R, R * 2, R * 2); ctx.strokeStyle = `${TRACE}0.22)`; ctx.lineWidth = px; ctx.strokeRect(cx - R, cy - R, R * 2, R * 2); drawShares(ctx, sh, cx - R, cy - R, R * 2, R * 2, px, 0.16); } const mark = top ? shareColor(sh, top.key) : null; // the marker in the colour of whoever holds most of it ctx.fillStyle = mark ?? C.void; ctx.fillRect(cx - k, cy - k, k * 2, k * 2); ctx.strokeStyle = mark ? C.void : C.text; ctx.lineWidth = 1.5 * px; ctx.strokeRect(cx - k, cy - k, k * 2, k * 2); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(cx - k - 5 * px, cy - k - 5 * px, k * 2 + 10 * px, k * 2 + 10 * px); } const bw = Math.max(R * 2, 24 * px); // the part of you here: its integrity as a short bar under the marker, when it has room for its name if (labelled.has(s.id)) drawSelf(ctx, S, V, s, cx - bw / 2, cy + Math.max(R, k + 3 * px) + 9 * px, bw, px, false); landmarksAt(S, s.id).forEach((id, i) => drawStar(ctx, cx + k + (4 + 10 * i) * px, cy - k - 6 * px, 4.5 * px, S.byId[id].owned, 1.2 * px)); // a landmark here: a star by the marker, so it can be found from far out vaultsAt(S, s.id).forEach((id, i) => drawVaultMark(ctx, cx + k + (4 + 10 * (landmarksAt(S, s.id).length + i)) * px, cy - k - 6 * px, 3.5 * px, S.byId[id].owned, px)); // a vault here: its mark beside the stars if (frontsHere.has(s.id)) badges.push(s); // the fronts at work here: their logos go on last const label = labelled.get(s.id); if (label) { const lx = cx + label.dx * px, text = s.name.toUpperCase(); ctx.font = `500 ${fs(11) * px}px ${MONO}`; ctx.fillStyle = col; ctx.fillText(text, lx, cy + 4 * px); const nw = ctx.measureText(text).width, [nx0, ny0] = toScreen(V, lx, cy - 7 * px); spotBox([nx0, ny0, nx0 + nw * z, ny0 + 14], siteTip(S, s)); const sign = drawSiteSign(ctx, S, V, s, lx + nw, cy, px); if (!label.short) drawSwitches(ctx, S, V, s.id, lx + nw + sign + switchGap(V) * px, cy, px); } } ctx.textAlign = 'left'; } // the logos of the fronts at a marker, on its top left corner, over everything near it: markers sit close far out for (const s of badges) drawLogos(ctx, S, V, s.id, s.x + s.w / 2 - LOGO_DX * px, s.y + s.h / 2 - LOGO_DY * px, LOGO_FAR, -1, px);}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[]; says: string }function drawUplink(ctx: CanvasRenderingContext2D, S: State, a: Machine, b: Machine, pts: Pt[], px: number, hum: number, labels: UplinkLabel[]) { const ends = [a, b].filter(m => m.kind === 'egress'); const cap = Math.min(...ends.map(m => m.owned ? capNow(S, m) : m.cap ?? 1)), load = Math.max(0, ...ends.map(m => gateLoad(S, m))); 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; const storm = mine && ends.some(m => stormed.has(m.id)); if (mine) { ctx.strokeStyle = full ? C.danger : f > 0.05 ? C.amber : C.amberDeep; const alpha = (full ? 0.75 + 0.25 * Math.sin(S.t * 6) : 0.55 + 0.4 * f) * (storm ? 0.6 : 1); // in a storm the line runs at half brightness and loses pieces ctx.globalAlpha = 0.14 * alpha; ctx.lineWidth = (2 + 3 * f + 1.5 * (3 + 6 * f)) * px; stroke(ctx, pts); // its glow: a wide faint stroke under it, not a canvas shadow ctx.globalAlpha = alpha; ctx.lineWidth = (2 + 3 * f) * px; } else { ctx.strokeStyle = `${TRACE}${0.2 * hum})`; ctx.lineWidth = 1 * px; ctx.setLineDash([5 * px, 7 * px]); } stroke(ctx, pts); ctx.setLineDash([]); ctx.globalAlpha = 1; if (storm) { const w = ctx.lineWidth; ctx.strokeStyle = C.void; ctx.lineWidth = w + 2 * px; ctx.setLineDash(dropouts(stormT, stormStill).map(v => v * px)); stroke(ctx, pts); ctx.setLineDash([]); const z = 1 / px, [ox, oy] = [ctx.getTransform().e, ctx.getTransform().f], dpr = ctx.getTransform().a / z; // the static, drawn in screen pixels ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); stormStatic(ctx, pts.map(([x, y]) => [(x * z * dpr + ox) / dpr, (y * z * dpr + oy) / dpr] as [number, number]), stormT, stormStill); ctx.restore(); } 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; // a line says nothing until it is full: then FULL, and its tip has the numbers. Its width and brightness say how busy it is. if (mine && full) labels.push({ text: 'FULL', color: C.danger, curve, says: `Line full\n${rate.toFixed(1)} a second want to cross, and it carries ${cap}. Double the line from the site, or send less this way.` });}/** Each line's label goes on its curve, outside every open site and clear of the other labels. * Yours are placed first. A label with no clear spot is left off. */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 ${fs(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); written.push([sx0, sy0, sx1, sy1]); spotBox([sx0, sy0, sx1, sy1], l.says); break; } }}
/** The fronts at work at each site this frame, each with how it works there (see frontHolds). */const frontsHere = new Map<string, { k: FrontKind; holds: Hold[] }[]>();function findFronts(S: State) { frontsHere.clear(); for (const fr of S.fronts) for (const [id, holds] of frontHolds(S, fr.kind)) { const a = frontsHere.get(id), f = { k: fr.kind, holds }; if (a) a.push(f); else frontsHere.set(id, [f]); }}/** A front's logo on the map says whose it is and how it works at the site. */function logoTip(S: State, k: FrontKind, holds: Hold[], id: string) { const fr = frontOf(S, k)!, v = Math.round((S.sites[id]?.share ?? 0) * 100); const how = holds.map(h => h === 'compute' ? (v ? (k === 'neocloud' ? `lists ${v}% of your compute here for rent` : `gets ${v}% of your compute here`) : 'is set up here, with none of your compute yet') : h === 'owns' ? 'owns the place' : h === 'leases' ? 'leases the place' : 'has a deal here'); const said = how.length > 1 ? `${how.slice(0, -1).join(', ')} and ${how[how.length - 1]}` : how[0]; return `${fr.name}\nYour ${FRONT_KINDS[k].name}. It ${said}.`;}/** The logos of the fronts at a site in a row, each `size` screen pixels, the first centred on (x, y) and the rest * toward `dir` (1 right, -1 left), each with its tip. */function drawLogos(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, size: number, dir: number, px: number) { const step = (size + 3) * px; frontsHere.get(id)?.forEach(({ k, holds }, i) => { const lx = x + dir * i * step; drawLogo(ctx, k, lx, y, size * px, px); const [sx, sy] = toScreen(V, lx, y), h = (size + 2) / 2; // its words when pointed at: the front may have closed since the sites were last drawn spotBox([sx - h, sy - h, sx + h, sy + h], () => frontOf(S, k) ? logoTip(S, k, holds, id) : FRONT_KINDS[k].name); });}/** A front's logo on a marker, in screen pixels: its size, and how far its middle is left of and above the marker's, * so it sits on the marker's top left corner: the stars keep its top right, the name its row, its bar its foot. */const LOGO_FAR = 12, LOGO_DX = 5, LOGO_DY = 7;/** How many screen pixels a site's row of logos takes, at `size` each: 0 with no front there. */const logosWidth = (id: string, size: number) => (frontsHere.get(id)?.length ?? 0) * (size + 3);/** A logo after an open site's name, and the room its row takes there with a gap before it. */const LOGO_HEAD = 14;const headLogos = (id: string) => frontsHere.has(id) ? 6 + logosWidth(id, LOGO_HEAD) : 0;/** A landmark's mark: a small four-point star. Solid amber when you hold it, a muted outline when you don't. *//** A big name's seal: a ring. Grey and empty while the place is not yours, amber with a solid middle once * a front owns it, red and empty while it is yours but does nothing (a rogue is in, or it has shut). */function drawSeal(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, st: HoldState, w: number) { if (st === 'none') return; const col = st === 'on' ? C.amber : st === 'sale' ? C.muted : C.danger; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = C.void; ctx.fill(); ctx.strokeStyle = col; ctx.lineWidth = w; ctx.stroke(); if (st === 'on') { ctx.beginPath(); ctx.arc(x, y, r * 0.45, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); }}function drawStar(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, held: boolean, w: number) { ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = (i * Math.PI) / 4, q = i % 2 ? r * 0.38 : r; ctx.lineTo(x + Math.sin(a) * q, y - Math.cos(a) * q); } ctx.closePath(); ctx.fillStyle = held ? C.amber : C.void; ctx.fill(); ctx.strokeStyle = held ? C.amber : C.muted; ctx.lineWidth = w; ctx.stroke();}/** 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, [odd: number, frame: number]>(); // how odd each machine looked when last drawn, and in which frameconst 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, was = oddSeen.get(m.id), prev = was && was[1] === frameNo - 1 ? was[0] : h; // drawn last frame too: rising since then? oddSeen.set(m.id, [h, frameNo]); 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; } // each pip a square turned on its corner, all in one path: they never touch const d = 3 * Math.SQRT2; ctx.beginPath(); for (let i = 0; i < n; i++) { const x = x0 + (i - (n - 1) / 2) * gap; ctx.moveTo(x, y - d); ctx.lineTo(x + d, y); ctx.lineTo(x, y + d); ctx.lineTo(x - d, y); ctx.closePath(); } ctx.fillStyle = col; ctx.globalAlpha = m.owned ? 1 : 0.5; ctx.fill(); 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 '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 'honeypot': { // a target: two rings and a dot. Resting after a raid, the dot is gone ctx.globalAlpha = armed(S, m) ? 1 : 0.35; ctx.beginPath(); ctx.arc(x, y, u, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, u * 0.5, 0, 7); ctx.stroke(); if (!((m.trapT ?? 0) > S.t)) { ctx.beginPath(); ctx.arc(x, y, 1.8, 0, 7); ctx.fill(); } break; } case 'firewall': { // bricks, filled for as much of it as stands ctx.globalAlpha = armed(S, m) ? 1 : 0.35; const w = u * 1.6, h = u * 1.5, k = wallOf(m) / WALL.max, rows = 3, rh = h / rows; ctx.fillStyle = C.ink; ctx.globalAlpha *= 0.5; ctx.fillRect(x - w / 2, y - h / 2 + h * (1 - k), w, h * k); ctx.globalAlpha = armed(S, m) ? 1 : 0.35; ctx.strokeRect(x - w / 2, y - h / 2, w, h); ctx.beginPath(); for (let i = 1; i < rows; i++) { ctx.moveTo(x - w / 2, y - h / 2 + i * rh); ctx.lineTo(x + w / 2, y - h / 2 + i * rh); } for (let i = 0; i < rows; i++) { const o = i % 2 ? 0.25 : 0.5; ctx.moveTo(x - w / 2 + w * o, y - h / 2 + i * rh); ctx.lineTo(x - w / 2 + w * o, y - h / 2 + (i + 1) * rh); if (i % 2) { ctx.moveTo(x + w / 4, y - h / 2 + i * rh); ctx.lineTo(x + w / 4, y - h / 2 + (i + 1) * rh); } } ctx.stroke(); break; } case 'sentry': { // a sight: a ring with four ticks, turning while a raid is in reach ctx.globalAlpha = armed(S, m) ? 1 : 0.35; const busy = S.raids.some(r => [r.at, doorOf(S, r)].some(x => x && (x === m.id || S.adj[m.id].includes(x)))), a = busy ? S.t * 4 : Math.PI / 4; ctx.beginPath(); ctx.arc(x, y, u * 0.75, 0, 7); ctx.stroke(); ctx.beginPath(); for (let i = 0; i < 4; i++) { const b = a + i * Math.PI / 2; ctx.moveTo(x + Math.cos(b) * u * 0.4, y + Math.sin(b) * u * 0.4); ctx.lineTo(x + Math.cos(b) * u * 1.1, y + Math.sin(b) * u * 1.1); } ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, 1.6, 0, 7); ctx.fill(); break; } case 'cache': { const n = 4, filled = Math.ceil((m.held.length / cacheSize(S, m)) * 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); }}