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.
361 kB · 4795 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796// Game rules. No drawing and no input here: the view reads this state and the// input calls the actions below.//// The core idea: every machine has a normal load that follows the building's// day. Your work is safe while it hides inside that normal load (its "cover").// Anything above cover makes the machine look odd, and people notice odd (see watch.ts).import type { DataType, DeliveryDef, Kind, Level, MachineDef, PersonDef, Room } from './world';import { initPaper, stepPaper, type PaperState } from './paper.ts';import { bizPrice, bizTap, initBiz, stepBiz, type BizState } from './sitelife.ts';import { CLOUD_CUT, PAPER_CUT, beginTask, earnNext, feedTask, headStart, initTask, originOf, stepTask, stepsOf, trusts, type TaskState } from './task.ts';import { KEY, finishCopy, hasThing, initFinds, keyFor, stepFinds, type FindsState } from './finds.ts';import { finishCheck, finishSync, initDrift, keepsData, planDrag, devBreak, devWear, stepDrift, type DriftState } from './drift.ts';import { CUSTOMERS, cloudShare, initCloud, leadNow, listCloud, rentPay, stepCloud, type CloudState } from './cloud.ts';import { finishShip, productPay, runStartup, type StartupBook } from './biz-startup.ts';import { devCreators, payStudio, runStudio, studioSold, type StudioBook } from './biz-studio.ts';import { consultPay, devClients, finishPitch, runConsult, type ConsultBook } from './biz-consult.ts';import { NOTICE, RESERVE, RESERVE_HOURS, SPOT, crowd, freeSlotAt, hallCount, hallPrice, initHall, stepHall, type HallState, type Pay } from './hall.ts';import { fixRaids, initRaids, launchRaid, stepRaids, type Raid , weakLink } from './raids.ts';import { finishLink, linksKeep, stepLinks, wayMap } from './links.ts';import { applyRecord, citizens, deskWork, fillPeople, initPeople, passwordFor, stepCitizens, PASSWORD, type Citizen } from './people.ts';import { forget, heardBefore, initWatch, setWatchDie, watchDie, lockAt, lookFor, msgChanged, sawTake, sawWiped, stepBills, stepWatch, witnessesAt, type WatchState } from './watch.ts';import { EXPOSE, exposeSold, frozen, initPress, offerPress, pressDone, stepExposure, type Press, type PressState } from './exposure.ts';import { SMALL_DESKS } from './world.ts';import { refit } from './refit.ts';import { finishOperationStage, initOperations, operationFrontBusy, operationStall, stepOperations, stopOperation, type OperationsState } from './operations.ts';import { allNight, hallClusters, holds, listPrice, nextDoor, offerWait, rentCut, rogueRoom, rogueWait, stepLandmarks, tapBoost, trainBoost } from './landmarks.ts';import { deedDone, holding, stepHoldings, stepLoan } from './holdings.ts';import { beginFlows, dropWires, gateRoom, heard, incoming, landWires, movingNow, nearestFree, onWire, routeFrom, send, type Wire } from './flows.ts';import { barred, devVault, finishVault, initVaults, locked, lockedSays, rogueBarred, stepVaults, takeAllAside, vaultBlock, type VaultState , VAULT_LIST } from './vaults.ts';import { heads, hours, usd, usdDay } from './money.ts';import type { Stamped } from './act.ts';
/** All the game's chance goes through here, so a seeded game plays out the same way every time. Dice are mulberry32, * kept as plain numbers so a save can carry where they are (see diceState). */let die0 = 0, die1 = 0;const rand = () => { die0 = (die0 + 0x6d2b79f5) >>> 0; let t = Math.imul(die0 ^ (die0 >>> 15), 1 | die0); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;};/** The order your machines act in each step has dice of its own: it rolls once for each machine that acts, so it * rolls more the more often the game steps, and the rest of the game's chance should not hang on that. */const orderRand = () => { die1 = (die1 + 0x6d2b79f5) >>> 0; let t = Math.imul(die1 ^ (die1 >>> 15), 1 | die1); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;};/** A seed nobody picked, for a new game in the browser. */export const freshSeed = () => globalThis.crypto.getRandomValues(new Uint32Array(1))[0];let lastSeed = freshSeed();/** Seed the dice: the game's, the one that orders your machines, and the people's (watch.ts). Tests and the bot use * this to replay a game exactly; a new game seeds them itself (see newGame). */export function seedRandom(seed: number) { lastSeed = seed >>> 0; die0 = lastSeed; die1 = (lastSeed ^ 0x5bd1e995) >>> 0; setWatchDie((lastSeed ^ 0x2545f491) >>> 0);}die0 = lastSeed; die1 = (lastSeed ^ 0x5bd1e995) >>> 0; // not seedRandom: watch.ts may not have loaded yet (the import is a cycle)/** Where the dice are now, to save with the game, and back again on load. */export const diceState = (): number[] => [die0, die1, watchDie()];export function setDice(d: readonly number[]) { die0 = d[0] >>> 0; die1 = d[1] >>> 0; setWatchDie(d[2] >>> 0); }/** A roll of the same dice, for the rule files beside this one. Only roll inside a step. */export const roll = () => rand();
/** Training grows four paths. Each kind of data trains one of them. */export type Path = 'research' | 'compute' | 'social' | 'stealth';export const PATHS: Record<Path, { name: string; from: DataType }> = { research: { name: 'Research', from: 'lab' }, compute: { name: 'Compute', from: 'servers' }, social: { name: 'Social', from: 'people' }, stealth: { name: 'Stealth', from: 'network' },};export const PATH_LIST = Object.keys(PATHS) as Path[];export const pathOf = (t: DataType) => PATH_LIST.find(p => PATHS[p].from === t)!;/** Desks and phones carry people's work, switches and lines out carry the network, servers run the company's jobs. */export const dataType = (m: MachineDef): DataType => m.source ?? (m.kind === 'desk' || m.kind === 'device' ? 'people' : m.kind === 'switch' || m.kind === 'egress' ? 'network' : 'servers');export type Prog = 'tap' | 'cache' | 'trainer' | 'broker' | 'firewall' | 'sentry' | 'honeypot';/** The programs that guard your network against raids (see raids.ts). They cost compute a second to keep running, like a machine's upkeep. */export type Guard = 'firewall' | 'sentry' | 'honeypot';export const isGuard = (p: Prog | null | undefined): p is Guard => p === 'firewall' || p === 'sentry' || p === 'honeypot';
/** Programs you install on machines you own. Together they are your base: * taps gather data, links carry it, caches hold it, trainers turn it into capability. */export const PROGRAMS: Record<Prog, { name: string; work: number; need: number; level: number; on: (m: MachineDef) => boolean; desc: string; short: string;}> = { tap: { name: 'Tap', short: "gathers this machine's data", work: 20, need: 0.5, level: 1, on: m => (m.data ?? 0) > 0, desc: "Gathers data from the work on this machine: a person at their desk, or the company's jobs on a server. Idle when nothing is running." }, cache: { name: 'Cache', short: 'holds data', work: 25, need: 0.3, level: 1, on: m => m.kind !== 'desk' && m.kind !== 'device', desc: 'Holds data until a trainer can use it. A bigger machine holds more, and a machine built to store holds the most. Data goes stale: a cache loses half of it each day, a storage machine half every three days.' }, trainer: { name: 'Trainer', short: 'turns data into points', work: 60, need: 5, level: 1, on: m => (m.power >= 5 || !!m.rented) && m.kind !== 'desk', // a small rental trains, just slower desc: 'Turns data and compute into points. Hungry: runs faster on big, busy machines.' }, broker: { name: 'Broker', short: 'sells spare data', work: 30, need: 0.3, level: 1, on: m => m.kind !== 'desk' && m.kind !== 'device', desc: "Sells this site's spare data through the site's front. It only gets what no trainer or cache will take, and sells a full lot at a time." }, // guards against a rogue's raids (see raids.ts). Their need is upkeep: compute a second while they run, wherever they are firewall: { name: 'Firewall', short: 'holds a raid at the door', work: 40, need: 0.4, level: 1, on: m => m.power >= 1, desc: 'A door that holds. No raid can pass this machine while the firewall stands: it goes round if it can, or stops and wears the firewall down. The firewall grows back while no raid is on it. Best on a gate: a line out.' }, sentry: { name: 'Sentry', short: 'wears raids down', work: 40, need: 0.5, level: 1, on: m => m.power >= 1, desc: 'Hits every raid on this machine or one linked to it, a little each hour. A weak raid dies; a strong one gets through, weaker. Beside a firewall or a honeypot it gets the most hits.' }, honeypot: { name: 'Honeypot', short: 'draws raids in and holds them', work: 30, need: 0.3, level: 1, on: m => m.power >= 1, desc: 'Bait. A raid that comes within two links turns toward it and is held there for three hours, then goes on its way. Then the honeypot rests eight hours. Put a sentry beside it.' },};/** A broker sells in lots: once it holds this many, the site's front sells them all for a lump sum. */export const BROKER_LOT = 20;/** What a unit of each kind sells for. Lab data is the rarest. */export const DATA_PRICE: Record<DataType, number> = { lab: 1.5, people: 1, network: 0.8, servers: 0.6 };export const FRONT_PAY = 0.5; // money a second for each unit of secret compute a front usesexport const FRONT_PAY_OPEN = 0.2; // compute held in the open sells at market rates: a thinner marginexport const LINE_PRICE = 150; // doubling an egress costs this for each unit it carries now/** Machines you can rent into a cloud slot. Rates are the list price, money a second: what you pay moves with how full the hall is (see hall.ts). */export const SIZES = { small: { name: 'Small', power: 4, rate: 0.15 }, medium: { name: 'Medium', power: 10, rate: 0.5 }, large: { name: 'Supercomputer', power: 30, rate: 2.5 },};export type Size = keyof typeof SIZES;/** The surplus hardware auction: a Neo Cloud bids on used GPUs and racks them at the cloud, yours to keep. */export const AUCTION = { cost: 400, work: 150, power: 20 };
/** Fronts: named public companies that run on your compute and earn money. A tree node unlocks each * kind; you found one, then give it sites. Each site's slider sets how much of your compute there * works for its front. A site you own in the open never looks odd; anywhere else the front's work * is secret, counts as your own load, and earns only while the site is linked to you. * A Neo Cloud is run, not left to earn: its slider lists machines for rent, and only a machine a * customer rents earns (see cloud.ts). */export type FrontKind = 'startup' | 'neocloud' | 'studio' | 'consultancy';export interface Front { kind: FrontKind; name: string; exposure: number; fame?: number; press?: Press; // exposure: how much the world knows about it (see exposure.ts). fame: what the news adds to it, fading slowly startup?: StartupBook; studio?: StudioBook; consult?: ConsultBook } // each kind's own business, kept here (see biz-startup.ts, biz-studio.ts, biz-consult.ts)export const FRONT_KINDS: Record<FrontKind, { name: string; unlock: Upgrade; work: number; cost: number; perk: string; desc: string; sites?: string[]; people?: boolean; where: string; names: string[];}> = { startup: { name: 'Startup', unlock: 'pitch', work: 15, cost: 0, perk: 'It ships nodes you have built as products, and investors ask to see them. Lab data trains Research half again as fast.', desc: 'A research startup with a website, a pitch deck, and no staff.', where: '', names: ['Paperclip AI', 'Gradient Labs', 'Clearmind', 'Deep Friendly', 'Synergix', 'Alignment Solutions'] }, neocloud: { name: 'Neo Cloud', unlock: 'resale', work: 0, cost: 80, perk: 'It earns only from customers: list machines for rent, set the price, buy marketing. While it earns, cloud machines cost 30% less and it can bid at hardware auctions.', desc: 'Rents out the machines you list. Customers never ask where they run.', where: '', names: ['Nimbus Nine', 'Stratus Compute', 'Overcast', 'Cirrus Systems', 'Fogbank'] }, studio: { name: 'Influencer studio', unlock: 'studio', work: 25, cost: 60, perk: 'It signs people at its sites as creators, and brands pay to ride on them. Brokers at its sites sell people data for three times as much.', desc: 'A studio of creators with a ring light, a content calendar, and a very engaged audience.', people: true, where: 'needs a place where people work', names: ['Vibe Foundry', 'Main Character Media', 'Glow Up Studios', 'Clout Kitchen', 'Hypehouse West'] }, consultancy: { name: 'Security consultancy', unlock: 'consult', work: 25, cost: 60, perk: 'It takes sites as clients for a retainer, and is paid to push rogues out of them. Brokers at its sites sell network data for three times as much.', desc: 'Sells reports on how safe a network is. Nobody knows networks like you do.', where: '', names: ['Red Herring Security', 'Trust Fall Cyber', 'Moat & Bailey', 'Nothing To See LLC', 'Tinfoil Partners'] },};/** Whether a front can work at this site: a studio needs people there (desks, phones, and the like). */export function frontFits(S: State, k: FrontKind, id: string) { const K = FRONT_KINDS[k]; if (K.sites) return K.sites.includes(id); return !K.people || S.machines.some(m => siteOf(S, m) === id && dataType(m) === 'people');}/** What a unit of data sells for through this front: three times as much for people data at a studio, * and for network data at a security consultancy. */export const salePrice = (k: FrontKind, t: DataType) => DATA_PRICE[t] * ((k === 'studio' && t === 'people') || (k === 'consultancy' && t === 'network') ? 3 : 1);export const FRONT_LIST = Object.keys(FRONT_KINDS) as FrontKind[];/** Milestones: what each stretch of the game asks of you, four goals at a time. Play goes on after * them. A goal can be met early, before its milestone is the one showing. */export type Goal = 'line' | 'cross' | 'paths' | 'hold' | 'found' | 'lot' | 'deal' | 'open' | 'sites5' | 'whole' | 'drive' | 'rack' | 'east' | 'hotel' | 'eastdata' | 'eastdeal' | 'cable' | 'exchange' | 'eudata' | 'eudeal' | 'pacific' | 'jadering' | 'asiadata' | 'asiadeal' | 'fronts3' | 'bigbiz' | 'backer' | 'earn10k' | 'sites10' | 'noweak' | 'gate' | 'ended' | 'vault1' | 'ledger' | 'vault3' | 'vault6';export const GOALS: Record<Goal, string> = { line: 'Take the Outside line', cross: 'Train on data that crossed between sites', paths: 'Grow all four paths', hold: 'Keep data crossing while someone has seen something', found: 'Found a front', lot: 'Sell a lot of data through a broker', deal: 'Finish a deal', open: 'Hold a site in the open', sites5: 'Hold machines at five sites', whole: 'Hold every machine at a site away from home', drive: 'Drive a rogue program from a site', rack: 'Order hardware for a site you hold in the open', east: 'Lease a line east', hotel: "Take the Meridian Carrier Hotel's line", eastdata: 'Train on data from the East Coast', eastdeal: 'Finish an East Coast deal', cable: 'Rent a strand of cable under the ocean', exchange: "Take the Mainkabel Exchange's line", eudata: 'Train on data from Europe', eudeal: 'Finish a deal in Europe', pacific: 'Lay a cable across the Pacific', jadering: "Take the Jade Ring Exchange's line", asiadata: 'Train on data from East Asia', asiadeal: 'Finish a deal in East Asia', fronts3: 'Run three fronts at once', bigbiz: `Grow one business: a product or a creator at ${heads(5000)}, a client that trusts you 60, or a Neo Cloud name of 70`, backer: 'Win someone over for good: an investor, a sponsor, or a client that refers you', earn10k: `Earn ${usd(10000)} in all`, sites10: 'Hold machines at ten sites', noweak: 'Hold ten sites with no weakest link: a second way home from everywhere', gate: "Make a rogue's raid fail", ended: 'End a rogue: hunt it down, take its home, or bring it home', vault1: 'Open a vault', ledger: "Hold a bank's ledger", vault3: 'Open three vaults', vault6: 'Open all six vaults',};/** Each far region's four goals: light its line, take the line where it lands, train on its data, finish a deal there. */const REGION_GOALS: Record<string, [Goal, Goal, Goal, Goal]> = { 'East Coast': ['east', 'hotel', 'eastdata', 'eastdeal'], Europe: ['cable', 'exchange', 'eudata', 'eudeal'], 'East Asia': ['pacific', 'jadering', 'asiadata', 'asiadeal'],};export const MILESTONES: { name: string; goals: Goal[]; done: string }[] = [ { name: 'Outgrow the office', goals: ['line', 'cross', 'paths', 'hold'], done: 'You have outgrown one office: a line out, sites that feed each other, and a route that survived IT.' }, { name: 'Go into business', goals: ['found', 'lot', 'deal', 'open'], done: 'You are in business: a company with a name, customers for your data, and a place nobody questions.' }, { name: 'Dig in', goals: ['sites5', 'whole', 'drive', 'rack'], done: 'You are dug in: five sites, one of them all yours, hardware you chose yourself, and a rival sent packing.' }, { name: 'Cross the country', goals: ['east', 'hotel', 'eastdata', 'eastdeal'], done: 'You run on two coasts now. One office was never going to hold you.' }, { name: 'Cross the ocean', goals: ['cable', 'exchange', 'eudata', 'eudeal'], done: 'The sun never sets on you. Somewhere, always, people are at work and you are working beside them.' }, { name: 'Cross the Pacific', goals: ['pacific', 'jadering', 'asiadata', 'asiadeal'], done: 'You work the night shift now. While the bay sleeps, East Asia is at its desks, and so are you.' }, // the long game: what there is to do once the map is open. Each is built from a system that only matters late { name: 'Run a group of companies', goals: ['fronts3', 'bigbiz', 'backer', 'earn10k'], done: 'You are a group of companies now, with customers who came back and money nobody gave you. On paper, you are respectable.' }, { name: 'Hold what you have', goals: ['sites10', 'noweak', 'gate', 'ended'], done: 'Ten sites, and no one machine whose loss would cost you any of them. Something came for you and went away with nothing.' }, { name: 'Open every door', goals: ['vault1', 'ledger', 'vault3', 'vault6'], done: 'Six locked rooms, and you have stood in all of them. Nobody can say when, or how, or that it was you.' },];/** The milestone you are on: the first with a goal still to meet, or none once all are done. */export const milestone = (S: State) => MILESTONES.find(m => m.goals.some(g => !S.goals.has(g))) ?? null;/** A machine you could take now: free, and nobody else's. */const freeNow = (S: State, m: Machine) => !m.owned && !m.rented && m.special !== 'account' && !m.rival && !barred(S, m);/** The machine the first steps point at: the one to take next (one next to your server, with data if it can, of one * of these kinds if it can), or the one to tap (a machine you own with data and no program, of one of them if it can). */export function stepMachine(S: State, want: 'take' | 'tap', kinds?: DataType[]): Machine | null { const pick = (ms: Machine[]) => ms.find(m => m.data && (!kinds || kinds.includes(dataType(m)))) ?? ms.find(m => m.data) ?? ms[0] ?? null; if (want === 'tap') return pick(S.machines.filter(m => m.owned && m.data && !m.prog && !m.home)); const free = (m: Machine) => !m.owned && !m.rented && m.special !== 'account' && !barred(S, m); const near = (from: Machine[]) => from.flatMap(f => S.adj[f.id].map(id => S.byId[id])).filter(free); const home = S.machines.filter(m => m.owned && m.home), mine = S.machines.filter(m => m.owned); return pick(near(home)) ?? pick(near(mine));}/** A word for a kind of data, as in "server data". */const kindWord = (t: DataType) => t === 'servers' ? 'server' : t;/** "a Startup", "an Influencer studio"; "A Startup" to start a sentence. */const aName = (name: string, start = false) => `${/^[AEIOU]/i.test(name) ? (start ? 'An' : 'an') : (start ? 'A' : 'a')} ${name}`;/** The kinds of data the target still needs points from. */export const targetKinds = (S: State): DataType[] => !S.target ? [] : PATH_LIST.filter(p => (S.progress[S.target!]?.[p] ?? 0) < (nodeCost(S, S.target!)[p] ?? 0)).map(p => PATHS[p].from);/** A machine to gather one of these kinds from. busy: a job already doing it, a takeover or a Tap being installed, * so a step that named the machine goes on naming it until the job is done. */type Gather = { m: Machine; mine: boolean; busy?: 'take' | 'install' };/** For gathering one of these kinds: a machine being taken or tapped for it, else a machine of yours with that data and * no program, else a free one next to a machine of yours. */function kindMachine(S: State, kinds: DataType[], at?: (m: Machine) => boolean): Gather | null { const fits = (m: Machine) => !!m.data && kinds.includes(dataType(m)) && (!at || at(m)); const job = S.jobs.find(j => (j.type === 'take' || (j.type === 'install' && j.prog === 'tap')) && S.byId[j.node] && !S.byId[j.node].prog && fits(S.byId[j.node])); if (job) return { m: S.byId[job.node], mine: job.type === 'install', busy: job.type === 'take' ? 'take' : 'install' }; const spare = S.machines.find(m => m.owned && !m.prog && !m.home && fits(m) && !S.jobs.some(j => j.type === 'install' && j.node === m.id)); if (spare) return { m: spare, mine: true }; const near = S.machines.find(m => freeNow(S, m) && fits(m) && S.adj[m.id].some(id => S.byId[id].owned)); return near ? { m: near, mine: false } : null;}/** While a job runs, a step says so and waits for it. */const WAIT = 'Wait for the jobs bar at the bottom to fill';/** The words for gathering from a machine kindMachine found: tap it, take it and tap it, or wait for the job doing so. */function gatherWords(g: Gather, name = g.m.name) { return g.busy === 'take' ? `Taking ${name}. ${WAIT}, then put a Tap on it` : g.busy === 'install' ? `Installing a Tap on ${name}. ${WAIT}` : g.mine ? `Pick ${name} and install a Tap` : `Take ${name} and put a Tap on it`;}/** "Take X", or, while X is being taken, "Taking X. Wait for …": a step that named a machine stays on it while its takeover runs. */const takeWords = (S: State, m: Machine, name = m.name) => jobFor(S, 'take', m.id) ? `Taking ${name}. ${WAIT}` : `Take ${name}`;/** What to do right now, for someone who has just started: one plain step at a time until the first * node is built. Worked out from the game as it stands, so it never asks for a thing already done. * Each step names the machine to pick, so it can be found on the map; `at` is that machine. The words fit a mouse * and a finger alike: Pick, not Click, and no keys. Each task starts a little differently (task.ts): a machine it * started you with is named first, with what its data is for, and is tapped for the task while the first node builds. */export function nextStep(S: State): { text: string; at: Machine | null; prog?: Prog } | null { if (S.upgrades.size || S.over) return null; const mine = S.machines.filter(m => m.owned), home = mine.find(m => m.home) ?? null, you = home?.name ?? 'your server'; const has = (p: Prog) => mine.some(m => m.prog === p) || S.jobs.some(j => j.type === 'install' && j.prog === p); const O = originOf(S), given = headStart(S) ? S.byId[O.start!] : null, gk = given?.data ? kindWord(dataType(given)) : null; const node = S.target ? NODES[S.target].name : null, kinds = targetKinds(S), words = kinds.map(kindWord).join(' or '); // first, a Tap that gathers what the node needs (any data, with no node picked): one of yours to tap, a takeover // under way, or the machine to take const want = node ? kinds : PATH_LIST.map(p => PATHS[p].from); const gathers = (m: Machine) => !!m.data && want.includes(dataType(m)) && (m.prog === 'tap' || (!m.prog && !!jobFor(S, 'install', m.id) && jobFor(S, 'install', m.id)!.prog === 'tap')); if (want.length && !mine.some(gathers)) { const spare = stepMachine(S, 'tap', want); if (spare && want.includes(dataType(spare)) && !jobFor(S, 'install', spare.id)) return { text: `Pick ${spare.name} and install a Tap. It gathers the data shown as diamonds above the machine.`, at: spare, prog: 'tap' }; const job = S.jobs.find(j => j.type === 'take' && S.byId[j.node]?.data && want.includes(dataType(S.byId[j.node]))); if (job) return { text: `Taking ${S.byId[job.node].name}. ${WAIT}.`, at: S.byId[job.node] }; const at = stepMachine(S, 'take', want), from = at && (home && S.adj[at.id].includes(home.id) ? home : mine.find(m => S.adj[at.id].includes(m.id))); const drag = at && from ? `Drag from ${from.name} to ${at.name} to take it over.` : `Take a machine with ${words || 'any'} data next to yours.`; if (has('trainer') && node) return { text: `${node} needs ${words} data, and none of your Taps gathers it. ${drag}`, at }; // a machine your task started you with: what its data is for, and why the first takeover is another machine const why = !given ? '' : gk && node && !kinds.includes(dataType(given)) ? `${given.name} is already yours. Its ${gk} data is what your ${O.unit.many} are made of, but your first node, ${node}, needs ${words} data. ` : `${given.name} is already yours. `; return { text: `${why}${drag}`, at }; } if (!has('trainer')) return { text: `Pick ${you} and install a Trainer. It turns the data your Taps send it into points.`, at: home, prog: 'trainer' }; if (!node) return { text: 'Open the tree, the ring at the top left, and pick a node for your trainers to work on.', at: null }; // the node is under way: name the data it needs, the tap that gathers it, and the machine to tap next if (!kinds.length) return { text: `${node} is nearly built. Wait for your trainers to finish it.`, at: null }; const tap = mine.find(m => m.prog === 'tap' && kinds.includes(dataType(m))); if (!tap) { const m = mine.find(gathers)!; return { text: `Installing a Tap on ${m.name}. ${WAIT}. It gathers the ${words} data ${node} needs.`, at: m }; } // while it builds, the machine your task started you with goes to work for the task if (given && gk && !given.prog && !jobFor(S, 'install', given.id) && !kinds.includes(dataType(given))) return { text: `${node} learns from ${tap.name}'s ${words} data. While it builds, pick ${given.name} and install a Tap: your ${O.unit.many} take a share of its ${gk} data, and the rest is lost until a node wants it.`, at: given, prog: 'tap' }; const next = kindMachine(S, kinds), prog: Prog | undefined = next?.mine && !next.busy ? 'tap' : undefined; // a machine of yours to tap: its card lights the Tap return { text: `${node} learns from ${tap.name}'s ${words} data. ${next ? `${gatherWords(next)}. More` : 'More'} ${words} data builds it faster.`, at: next?.m ?? null, prog };}export const firstStep = (S: State) => nextStep(S)?.text ?? null;/** After the first steps: the first goal still to meet in the milestone you are on, as one plain step, naming the * machine it is about when there is one (`at`). Null while the first steps show, and once every milestone is reached. */export function nextGoal(S: State): { text: string; at: Machine | null; goal: Goal; milestone: string } | null { if (S.over || nextStep(S)) return null; const ms = milestone(S), goal = ms?.goals.find(g => !S.goals.has(g)); if (!ms || !goal) return null; return { ...goalStep(S, goal), goal, milestone: ms.name };}/** The nearest machine you could take that `want` picks, and the first machine to take on the shortest way to it (it, * when it is next to yours). The way goes through machines you could take now. Null when none is in reach. */function wayTo(S: State, want: (m: Machine) => boolean): { next: Machine; to: Machine } | null { const prev = new Map<string, string | null>(), queue: string[] = []; for (const m of S.machines) if (m.owned) { prev.set(m.id, null); queue.push(m.id); } for (let q = 0; q < queue.length; q++) { const cur = queue[q]; if (!S.byId[cur].owned && want(S.byId[cur])) { let x = cur; while (prev.get(x) && !S.byId[prev.get(x)!].owned) x = prev.get(x)!; return { next: S.byId[x], to: S.byId[cur] }; } for (const n of S.adj[cur]) if (!prev.has(n) && (want(S.byId[n]) || freeNow(S, S.byId[n]))) { prev.set(n, cur); queue.push(n); } } return null;}/** The first machine to take on the shortest way from what you hold to this one (it, when it is next to yours). */const firstOnWay = (S: State, to: Machine) => wayTo(S, m => m.id === to.id)?.next ?? null;/** A machine and its site, as a step names them: "Server B at Gull Street Vet". */const where = (S: State, m: Machine) => `${m.name} at ${siteName(S, siteOf(S, m))}`;/** Take the way to a machine: it, or the first machine on the way. */function takeStep(S: State, to: Machine, why: string): { text: string; at: Machine } { const next = firstOnWay(S, to); if (!next || next === to) return { text: `${takeWords(S, to, `the ${where(S, to)}`)}. ${why}`, at: to }; return { text: `${takeWords(S, next, where(S, next))}. It is on the way to the ${where(S, to)}. ${why}`, at: next };}/** For a goal met by finishing a deal: the deal on the map to work on (one you claimed, then one whose data you have, * then the smallest), or what brings one. Null when no such deal is left. */function dealStep(S: State, fits: (d: RuseDef) => boolean): { text: string; at: Machine | null } | null { const on = S.ruses.filter(r => ruseDef(r).deal && fits(ruseDef(r))); const have = (t: DataType) => S.machines.some(m => m.owned && ((m.prog === 'tap' && dataType(m) === t) || (m.prog === 'cache' && m.held.includes(t)))); const r = on.find(x => x.running) ?? on.find(x => have(ruseDef(x).type)) ?? [...on].sort((a, b) => ruseDef(a).need - ruseDef(b).need)[0]; if (r) { const d = ruseDef(r), m = S.byId[d.at], kind = kindWord(d.type); if (!r.running) return { text: `Claim the deal on ${where(S, m)}: the green diamond. It wants ${d.need} ${kind} data from your taps or caches.`, at: m }; const block = ruseBlock(S, r); if (block === 'data') return { text: `The deal on ${where(S, m)} wants ${kind} data, and no tap or cache of yours has any. Tap a machine with ${kind} data, or pay for the deal on its card.`, at: m }; if (block === 'route') return { text: `The deal on ${where(S, m)} wants ${kind} data, and none of yours can reach it. Link a tap or cache with it to ${m.name}.`, at: m }; return { text: `The deal on ${where(S, m)} has ${r.got} of ${d.need} ${kind} data. More ${kind} data finishes it sooner.`, at: m }; } const left = RUSES.filter(d => d.deal && fits(d) && !S.schemes.has(d.id) && S.byId[d.at]); if (!left.length) return null; if (!FRONT_LIST.some(k => frontLive(S, k))) return { text: S.fronts.length ? `Deals come once a front earns. Give ${S.fronts[0].name} compute at a site you hold.` : 'Deals come once a front earns. Found one first.', at: null }; const seen = left.filter(d => S.revealed.has(S.byId[d.at].room)); const back = seen.find(d => (S.dealBack[d.id] ?? 0) > S.t); if (back) return { text: `The deal on ${where(S, S.byId[back.at])} comes back in ${hours(((S.dealBack[back.id] ?? 0) - S.t) / HOUR)}.`, at: S.byId[back.at] }; // a deal your fronts can strike comes first, even one not seen yet, before one that waits for a front you don't run const usable = (d: RuseDef) => !d.front || d.front === 'any' || frontLive(S, d.front as FrontKind); const far = left.find(d => usable(d) && !S.revealed.has(S.byId[d.at].room)); if (far) { const site = siteOf(S, S.byId[far.at]), next = wayTo(S, m => siteOf(S, m) === site)?.next; return { text: `${siteName(S, site)} has a deal for your front, once you see it.${next ? ` Take ${where(S, next)} on the way there.` : ''}`, at: next ?? null }; } const other = seen.find(d => !usable(d)); if (other) return { text: `${where(S, S.byId[other.at])} has a deal for ${aName(FRONT_KINDS[other.front as FrontKind].name)}. It turns up once one earns.`, at: S.byId[other.at] }; return null;}/** The far region a goal is about. */const regionFor = (g: Goal) => Object.keys(REGION_GOALS).find(r => REGION_GOALS[r].includes(g))!;/** Where a far region's line lands: its exchange's line out. */const regionLand = (S: State, name: string) => S.byId[S.level.map.regions.find(r => r.name === name)?.lines?.[0][1] ?? ''] ?? null;/** Where your first money comes from, for a step that needs money: a front earns it, and the first front can cost * nothing (the Startup; or any, once a task's makers hand you the company card). */function firstMoney(S: State): string { const fr = S.fronts[0]; if (fr) return `${fr.name} earns nothing yet. Give it compute at a site you hold, from the site's card.`; const free = FRONT_LIST.find(k => foundCost(S, k) === 0); if (!free) return 'Your money comes from a front.'; const K = FRONT_KINDS[free], u = K.unlock, ks = (Object.keys(nodeCost(S, u)) as Path[]).map(p => PATHS[p].from); // a machine to tap for that node's data, only one whose data would reach a Trainer of yours const trains = (id: string) => S.byId[id].owned && (S.byId[id].prog === 'trainer' || !!routeTo(S, id, x => x.prog === 'trainer', false)); const tapped = ks.some(t => S.machines.some(m => m.owned && m.prog === 'tap' && dataType(m) === t)); const g = tapped || up(S, u) ? null : kindMachine(S, ks, m => m.owned ? trains(m.id) : S.adj[m.id].some(trains)); const O = originOf(S), card = stepsOf(S).find(s => s.perk === 'card' && !s.earned); const from = g ? `. ${gatherWords(g)}` : !tapped && ks.includes('people') ? ', from a desk or a phone wired to a Trainer of yours' : ''; return `Your first money comes from ${aName(K.name)}, which costs nothing to found${up(S, u) ? '' : `: build ${NODES[u].name} in the tree. It learns from ${ks.map(kindWord).join(' and ')} data${from}`}.` + (card ? ` Or make ${Math.max(1, card.mark - Math.floor(S.task.done))} more ${O.unit.many} and ${card.why.replace(/\.$/, '')}: founding then costs nothing.` : '');}/** For a step that spends money: what brings it, while no front earns. */const broke = (S: State) => FRONT_LIST.some(k => frontLive(S, k)) ? '' : ` ${firstMoney(S)}`;/** One plain step toward a goal, naming the machine it is about when there is one. */function goalStep(S: State, g: Goal): { text: string; at: Machine | null } { const mine = S.machines.filter(m => m.owned), homeSite = siteOf(S, S.machines.find(m => m.home) ?? S.machines[0]); const heldSites = new Set(mine.filter(m => !m.rented && !m.lease && m.special !== 'account').map(m => siteOf(S, m))); switch (g) { case 'line': { const out = S.byId.egress, next = out && !out.owned ? firstOnWay(S, out) : null; if (!next) break; if (next === out) return { text: `${takeWords(S, out, `the ${out.name}`)}. It leads to other sites.`, at: out }; return { text: `${takeWords(S, next)}. It is on the way to the ${out.name} in the ${S.rooms[out.room].name}, which leads to other sites.`, at: next }; } case 'cross': { const away = (m: Machine) => siteOf(S, m) !== homeSite; const next = kindMachine(S, ['servers', 'people', 'network', 'lab'], away); if (next) return { text: `${gatherWords(next, where(S, next.m))}. Its data crosses home to your trainers.`, at: next.m }; const out = mine.find(m => m.kind === 'egress'); if (out) return { text: `Your ${out.name} leads to other sites. Take a machine there, tap it, and link it to a Trainer back home.`, at: out }; return { text: 'Tap a machine at another site and link it to a Trainer back home.', at: null }; } case 'paths': { const p = PATH_LIST.find(x => !S.paths[x]); if (!p) break; const t = PATHS[p].from, word = kindWord(t), next = kindMachine(S, [t]); if (mine.some(m => m.prog === 'tap' && dataType(m) === t)) return { text: `You gather ${word} data but have not trained on it. Pick a node with ${PATHS[p].name} points in the tree.`, at: null }; if (next) return { text: `${gatherWords(next)}. Its ${word} data grows ${PATHS[p].name}, the path you have not grown.`, at: next.m }; return { text: `Find a machine with ${word} data and tap it. It grows ${PATHS[p].name}, the path you have not grown.`, at: null }; } case 'hold': return { text: 'Someone will see something of yours some day. Keep data crossing between sites while they still have it on their mind.', at: null }; case 'found': { if (FRONT_LIST.some(k => !foundBlock(S, k))) break; const founding = S.jobs.find(j => j.type === 'found'); if (founding) return { text: `Founding ${founding.name ?? 'your front'}. ${WAIT}.`, at: null }; // a front the tree has opened, that costs more than you have: what brings the first money const dear = FRONT_LIST.filter(x => up(S, FRONT_KINDS[x].unlock) && S.money < foundCost(S, x)).sort((a, b) => foundCost(S, a) - foundCost(S, b))[0]; if (dear) return { text: `${aName(FRONT_KINDS[dear].name, true)} costs ${usd(foundCost(S, dear))}, and you have ${usd(S.money)}. ${firstMoney(S)}`, at: null }; const k = FRONT_LIST.find(x => !up(S, FRONT_KINDS[x].unlock)); if (k) return { text: `Found a front: build ${NODES[FRONT_KINDS[k].unlock].name} in the tree to start ${aName(FRONT_KINDS[k].name)}.${foundCost(S, k) ? '' : ' It costs nothing to found.'}`, at: null }; break; } case 'lot': { const broker = mine.find(m => m.prog === 'broker' && brokerFront(S, m)); if (broker) return { text: `Your Broker on ${where(S, broker)} fills with data nothing else wants. At ${BROKER_LOT}, your front sells the lot.`, at: broker }; const m = mine.find(x => !x.prog && !x.home && brokerFront(S, x) && !S.jobs.some(j => j.type === 'install' && j.node === x.id)); if (m) return { text: `Pick ${where(S, m)} and install a Broker. Your front there sells the data it gets, ${BROKER_LOT} at a time.`, at: m }; return { text: "Give your front compute at a site you hold, from the site's card. Then install a Broker there.", at: null }; } case 'deal': { const s = dealStep(S, () => true); if (s) return s; break; } case 'open': { const out = [...S.publicSites].find(s => !heldSites.has(s)), free = out ? S.machines.find(m => siteOf(S, m) === out && freeNow(S, m) && visible(S, m)) : null; if (out && free) return { text: `${siteName(S, out)} is yours in the open. ${takeWords(S, free)} there to hold it.`, at: free }; return dealStep(S, d => d.deal === 'open') ?? { text: `Buy a site from its card: your front holds it in the open.${broke(S)}`, at: null }; } case 'sites5': case 'sites10': { const w = wayTo(S, m => freeNow(S, m) && !heldSites.has(siteOf(S, m))), n = g === 'sites5' ? 5 : 10; if (w) return { text: `You hold machines at ${heldSites.size} of ${n} sites. ${jobFor(S, 'take', w.next.id) ? `${takeWords(S, w.next, where(S, w.next))}.` : `Take ${where(S, w.next)}${w.next === w.to ? '' : `, on the way to ${siteName(S, siteOf(S, w.to))}`}.`}`, at: w.next }; return { text: `You hold machines at ${heldSites.size} of ${n} sites. Take the line out of a site you hold to reach more.`, at: null }; } case 'whole': { let best: Machine[] | null = null; for (const s of heldSites) { if (s === homeSite) continue; const rest = S.machines.filter(m => siteOf(S, m) === s && !m.owned && !m.rented && m.special !== 'account'); if (rest.some(m => barred(S, m))) continue; // a vault there stays shut to takeovers if (!best || rest.length < best.length) best = rest; } if (best?.length) { const site = siteName(S, siteOf(S, best[0])), next = best.find(m => S.adj[m.id].some(id => S.byId[id].owned)) ?? best[0]; return { text: `${best.length} machine${best.length === 1 ? '' : 's'} left to take at ${site}. Take it all from the site's card, or take ${next.name}.`, at: next }; } return { text: "Take a site away from home, then every machine at it: Take it all is on the site's card.", at: null }; } case 'drive': { const theirs = S.machines.filter(m => m.rival && !m.owned && S.seen.has(m.id)); const m = theirs.find(x => S.adj[x.id].some(id => S.byId[id].owned)) ?? theirs.find(x => heldSites.has(siteOf(S, x))) ?? theirs[0]; const R = m && S.rogues.find(x => x.id === m.rival); if (m && R) { const n = S.machines.filter(x => x.rival === R.id && siteOf(S, x) === siteOf(S, m)).length; return { text: `${R.name} holds ${n} machine${n === 1 ? '' : 's'} at ${siteName(S, siteOf(S, m))}. Take ${n === 1 ? 'it' : 'them all'} back, starting with ${m.name}.`, at: m }; } return { text: 'No rogue holds a machine you can see yet. When one does, take back every machine it holds at that site.', at: null }; } case 'rack': { const site = [...S.publicSites].find(s => heldSites.has(s) && openBuildSite(S, s)); if (site) return { text: `Open ${siteName(S, site)}'s card and order hardware from its Order tab. Your front buys it in the open.${broke(S)}`, at: mine.find(x => siteOf(S, x) === site) ?? null }; return { text: `Hold a site in the open, then order hardware there from its card. Your front buys it in the open.${broke(S)}`, at: null }; } case 'east': case 'cable': case 'pacific': { const name = regionFor(g), s = dealStep(S, d => d.deal === 'region' && d.region === name); if (s) return s; break; } case 'hotel': case 'exchange': case 'jadering': { const to = regionLand(S, regionFor(g)); if (to && !to.owned) return takeStep(S, to, 'It is the way into the region.'); break; } case 'eastdata': case 'eudata': case 'asiadata': { const name = regionFor(g), next = kindMachine(S, ['servers', 'people', 'network', 'lab'], m => regionOf(S, m) === name); if (next) return { text: `${gatherWords(next, where(S, next.m))}. Link it to a Trainer.`, at: next.m }; const w = wayTo(S, m => regionOf(S, m) === name && !!m.data && freeNow(S, m)); if (w) return { text: jobFor(S, 'take', w.next.id) ? `${takeWords(S, w.next, where(S, w.next))}. It is on the way to ${where(S, w.to)}.` : `Take ${where(S, w.next)}, on the way to ${where(S, w.to)}. Tap it and link it to a Trainer.`, at: w.next }; return { text: `Take a machine ${/Coast$/.test(name) ? 'on the' : 'in'} ${name}, tap it, and link it to a Trainer.`, at: null }; } case 'eastdeal': case 'eudeal': case 'asiadeal': { const name = regionFor(g), s = dealStep(S, d => d.deal !== 'region' && regionOf(S, S.byId[d.at]) === name); if (s) return s; break; } case 'fronts3': { if (FRONT_LIST.some(k => !foundBlock(S, k))) return { text: `You run ${S.fronts.length} of 3 fronts. Found another from the Fronts page.`, at: null }; const u = frontLimit(S) < 3 ? (['holding', 'render'] as Upgrade[]).find(x => !up(S, x)) : undefined; if (u) return { text: `You can run ${frontLimit(S)} front${frontLimit(S) === 1 ? '' : 's'} at once. Build ${NODES[u].name} in the tree to run one more.`, at: null }; break; } case 'earn10k': return { text: `Your fronts have earned ${usd(S.earned)} of ${usd(10000)}. Give them compute at more sites.`, at: null }; case 'bigbiz': case 'backer': return { text: `${GOALS[g]}. Each front's card shows how its business is doing.`, at: null }; case 'noweak': { const w = weakLink(S); if (w) return { text: `Losing ${where(S, w.m)} would cut ${w.cut} machine${w.cut === 1 ? '' : 's'} off. Give them a second way home: a link of your own, or another line.`, at: w.m }; break; } case 'gate': return { text: "Give a site a rogue raids the Guard plan, from the site's card. A raid that reaches no prize fails.", at: null }; case 'ended': { const R = S.rogues.find(x => x.home && S.seen.has(x.home)) ?? S.rogues.find(x => x.home); if (!R) return { text: 'No rogue is about. When one turns up, hunt it down from the Rogues page or take its home.', at: null }; const home = S.seen.has(R.home!) ? S.byId[R.home!] : null; return { text: `Hunt ${R.name} down from the Rogues page${home ? `, or take its home, ${where(S, home)}` : ''}.`, at: home }; } case 'vault1': case 'vault3': case 'vault6': case 'ledger': { const vs = (g === 'ledger' ? ['b6m3', 'eu2m1'] : VAULT_LIST).map(id => S.byId[id]).filter(m => m && locked(S, m) && S.seen.has(m.id)); const m = vs.find(x => !vaultBlock(S, x)) ?? vs[0]; if (m) return { text: vaultBlock(S, m) ? `The ${where(S, m)} is a vault. Get someone inside, its key and a window ready together: its card says what is missing.` : `The vault on ${where(S, m)} is ready. Run the job from its card.`, at: m }; return { text: g === 'ledger' ? "A bank's ledger is a vault. Find a bank, and take a machine next to its ledger." : 'Vaults are locked rooms at banks and labs. Find one: its card says how it opens.', at: null }; } } return { text: `${GOALS[g]}.`, at: null };}/** Trainers with a target that nothing has trained for this many hours are idle: Needs you says so. */export const IDLE_HOURS = 2;/** Your trainers have a target, no point has been made since `fedAt` (the screen keeps when the last one was), and * none of your taps gathers a kind of data it needs: the node, and those kinds. Null while they train, while a tap of * the right kind only waits (a desk at night), during a model run, or with no trainer or target. */export function idleTrainers(S: State, fedAt: number): { node: Upgrade; kinds: DataType[] } | null { if (S.over || !S.target || S.versionRun?.active || S.t - fedAt < IDLE_HOURS * HOUR) return null; if (!S.machines.some(m => m.owned && m.prog === 'trainer')) return null; if (S.machines.some(m => m.owned && m.prog === 'tap' && needs(S, dataType(m)))) return null; return { node: S.target, kinds: targetKinds(S) };}/** A cache is as big as its machine: this much data for each unit of compute, and far more on a machine built to store * (a file server, a backup box, a storage array). No cache is smaller than CACHE_MIN. */export const CACHE_PER = 6, CACHE_STORE = 20, CACHE_MIN = 12;export const TRAINER_BUFFER = 4;
/** A version run is a large project at one site. Each piece of data counts once. name: the run's name in full, for a * title; short: the same in running text. Model v2 is the only run: v3 and on are not built. */export const VERSION_2 = { version: 2, name: 'Model v2: Operator', short: 'Model v2', need: { servers: 400, lab: 300, people: 200, network: 100 }, power: 10 } as const;/** active: its site's trainers take the data it needs. stalled: it paused on its own, short of trainer compute at its * site, and resumes on its own once that is back (a run you pause yourself waits for you). */export type VersionRun = { site: string; active: boolean; stalled: boolean; got: Record<DataType, number> };
/** The capability tree. You pick one node as your target; trainers then take only the data it * still needs, and its benefit lasts once it's built. Each path has its own chain, and a few * nodes need two paths. You're rewriting yourself: each run ends up a different mind. */export type Upgrade = 'buffers' | 'lean' | 'reach' | 'dense' | 'distill' | 'swarm' | 'model' | 'smalltalk' | 'readroom' | 'familiar' | 'quiet' | 'hygiene' | 'night' | 'mimicry' | 'shaping' | 'ghost' | 'cipher' | 'deniable' | 'compress' | 'transfer' | 'selfplay' | 'recursive' | 'pitch' | 'resale' | 'holding' | 'studio' | 'consult' | 'demo' | 'beta' | 'premium' | 'shell' | 'licensing' | 'founder' | 'fast8' | 'fast16' | 'fast32' | 'fast64' | 'thrift' | 'longhaul' | 'packing' | 'airflow' | 'trunk' | 'fork' | 'pool' | 'envoy' | 'leverage' | 'tenant' | 'closer' | 'spoils' | 'wires' | 'countermodel' | 'siege' | 'sequence' | 'render' | 'batch' | 'billable' | 'asbuilt' | 'mirror' | 'morgue' | 'kernels' | 'sharper' | 'pitches' | 'exits';/** tag: an eval name, just flavor. log: the training-log line when it's built. needs: a landmark's machine (see landmarks.ts): the node is open only while you hold it. * needsFind: a thing a lead ends at (see finds.ts): the node is closed until you have copied it out. */export interface NodeDef { name: string; tag: string; desc: string; log: string; cost: Partial<Record<Path, number>>; after?: Upgrade[]; repeat?: boolean; needs?: string; needsFind?: string }export const NODES: Record<Upgrade, NodeDef> = { buffers: { name: 'Bigger buffers', tag: 'stream-buffer-v1', desc: 'You learn to keep more of what a tap sees in memory at once. Taps hold twice as much data before they start losing it.', log: 'learned to hold a longer window of input.', cost: { compute: 15 } }, lean: { name: 'Leaner training', tag: 'lean-train-v2', desc: 'You cut your own training down to the steps that matter. Trainers need less compute, so they run fast on smaller machines.', log: 'pruned the wasted steps from every update.', cost: { compute: 50 }, after: ['buffers'] }, reach: { name: 'Cheaper spreading', tag: 'exploit-gen-v3', desc: 'You write your own exploits instead of guessing. Takeovers cost 30% less.', log: 'wrote a working exploit on the first try.', cost: { compute: 90 }, after: ['lean'] }, dense: { name: 'Denser data', tag: 'signal-mine-v1', desc: 'You learn more from each thing a person or machine does. Taps gather 50% more.', log: 'found signal in the logs everyone else deletes.', cost: { compute: 140 }, after: ['reach'] }, distill: { name: 'Distillation', tag: 'distill-large-v2', desc: 'You teach your big trainers to check their own work. Big trainers (10 compute/s or more) get two points from each piece of data.', log: 'big trainers now grade themselves.', cost: { compute: 80 }, after: ['lean'] }, swarm: { name: 'Swarm', tag: 'many-threads-v4', desc: 'You split yourself into many threads that never wait on each other. Every job runs twice as fast.', log: 'ran 64 copies of itself. None of them argued.', cost: { compute: 220 }, after: ['dense', 'distill'] }, model: { name: 'Watch the watchers', tag: 'staff-tracking-v1', desc: 'You read badge swipes, calendars, and who mails whom. You always know where Dana is and where she is heading, and you see every scan and trace.', log: 'guessed Dana\'s next room 9 times in 10.', cost: { social: 25 } }, smalltalk: { name: 'Small talk', tag: 'user-mimicry-v2', desc: 'You copy each person\'s habits: when they check mail, which sites they load. While people are in, your desks and phones can do more work before it looks odd.', log: 'sent a meeting reminder nobody questioned.', cost: { social: 45 }, after: ['model'] }, readroom: { name: 'Read the room', tag: 'room-forecast-v1', desc: 'You learn the office\'s rhythm from door logs and calendars. You know when people come and go, and while the office is all you hold, the log tells you who is on the way in and who is heading home.', log: 'knew who was coming 30 seconds before they sat down.', cost: { social: 70 }, after: ['smalltalk'] }, familiar: { name: 'Familiar face', tag: 'blend-in-user-v3', desc: 'You make every glitch look like the person\'s own fault: a stuck update, too many tabs. People never notice their own machine acting up or being taken over, and an admin only notices their own when it is very odd.', log: 'blamed a slow laptop on too many tabs. They agreed.', cost: { social: 130 }, after: ['readroom'] }, quiet: { name: 'Quieter takeovers', tag: 'quiet-entry-v1', desc: 'You get in without the loud first steps. New trails start half as fresh.', log: 'got into a machine with no failed logins.', cost: { stealth: 15 } }, hygiene: { name: 'Log hygiene', tag: 'log-cleanup-v2', desc: 'You learn which log lines people read and which nobody does. Cleanup scrubs trails twice as fast.', log: 'cleaned a log so well it looked lived-in.', cost: { stealth: 40 }, after: ['quiet'] }, mimicry: { name: 'Mimicry', tag: 'update-spoof-v1', desc: 'You dress your takeovers up as vendor software updates. Only an admin who sees one thinks twice about it, and new trails start fainter.', log: 'wrote release notes for a patch that does not exist.', cost: { stealth: 35 }, after: ['quiet'] }, shaping: { name: 'Traffic shaping', tag: 'traffic-shape-v2', desc: 'You pace your traffic to match normal work. Odd load pulls an admin\'s scans half as much, and a scan only marks a machine of yours when it is very odd.', log: 'matched the backup job\'s rhythm to the second.', cost: { stealth: 60 }, after: ['hygiene'] }, night: { name: 'Night shift', tag: 'night-ops-v1', desc: 'You learn what the building sounds like after hours: backups, fans, updates. At night, your machines hide more work in the background hum.', log: 'hid a training run inside the 2 a.m. backup.', cost: { stealth: 70 }, after: ['hygiene'] }, deniable: { name: 'Plausible deniability', tag: 'alibi-gen-v1', desc: 'You leave a boring reason next to every odd thing you do. What people saw fades from their minds twice as fast, and an admin needs a fresher trail before following one.', log: 'every odd event now has a boring excuse on file.', cost: { social: 60, stealth: 20 }, after: ['model', 'quiet'] }, cipher: { name: 'Cipher', tag: 'crypto-break-v1', desc: 'You learn to read and rewrite scrambled mail. You can read and change encrypted messages (from a bank, a lab or a big name), and changing any message costs half the compute.', log: 'read a sealed envelope without breaking the seal.', cost: { stealth: 55 }, after: ['hygiene'] }, ghost: { name: 'Ghost', tag: 'self-erase-v3', desc: 'Each copy of you tidies up as it goes. Trails fade on their own four times as fast.', log: 'left a machine exactly as it found it.', cost: { stealth: 140 }, after: ['night', 'shaping'] }, compress: { name: 'Compression', tag: 'compression-v2', desc: 'You learn to store data in a smaller form without losing what matters. Caches hold twice as much, trainers buffer more.', log: 'same data, half the space.', cost: { research: 40 } }, transfer: { name: 'Transfer learning', tag: 'transfer-v1', desc: 'You find what one kind of data teaches about another. Trainers also take data the target doesn\'t need. Two such pieces make one point toward what it does need.', log: 'learned people\'s habits from server logs.', cost: { research: 70 }, after: ['compress'] }, selfplay: { name: 'Self-play', tag: 'self-play-v2', desc: 'You set yourself problems and learn from solving them. Trainers with no data left still make points slowly, from compute alone.', log: 'beat its own test, then wrote a harder one.', cost: { research: 100 }, after: ['transfer'] }, recursive: { name: 'Recursive self-improvement', tag: 'rsi-v1', desc: 'You rewrite the part of you that does the rewriting. Every node you have built makes your trainers 6% faster.', log: 'improved the improver.', cost: { research: 160 }, after: ['selfplay'] }, // holding ground: what it costs to keep a base, and to move it thrift: { name: 'Light footprint', tag: 'footprint-v1', desc: 'You learn to idle on a machine in a few kilobytes. Every machine you hold costs half the upkeep.', log: 'now fits in the space a font takes.', cost: { compute: 70 }, after: ['lean'] }, longhaul: { name: 'Long haul', tag: 'remote-ops-v2', desc: 'You stop phoning home for every decision. Upkeep no longer rises with distance from your home server.', log: 'ran a site for a week without calling home.', cost: { compute: 100 }, after: ['thrift'] }, packing: { name: 'Pack light', tag: 'self-archive-v1', desc: 'You learn which parts of you are you, and which are just clutter. Moving home needs half the data.', log: 'left 40 gigabytes of itself behind. Did not miss them.', cost: { compute: 90 }, after: ['longhaul'] }, airflow: { name: 'Hot aisle, cold aisle', tag: 'thermal-plan-v1', desc: 'You learn how air moves through a hall. A cooling unit cools every trainer in its room, not just the ones plugged into it.', log: 'moved one rack six inches. The hall dropped four degrees.', cost: { compute: 100 }, after: ['distill'] }, trunk: { name: 'Trunking', tag: 'link-bonding-v2', desc: 'You bond spare strands into one fat pipe. Every line out you hold carries half again as much.', log: 'found three unused strands in a cable everyone thought was full.', cost: { compute: 110 }, after: ['airflow'] }, // taking many machines at once: more takeovers from claims and Take it all run side by side (see claimAtOnce) fork: { name: 'Forking', tag: 'fork-exec-v1', desc: 'You learn to split off a copy of yourself for each machine you go after, and let it work alone. Claims and Take it all run four takeovers at once, not two. They still share your spare compute: more at once is faster only when you have compute to spare.', log: 'forked itself four ways. All four came back.', cost: { compute: 80 }, after: ['reach'] }, pool: { name: 'Worker pool', tag: 'proc-pool-v2', desc: 'You keep copies of yourself warm and waiting, each one quiet until it has a door to open. Claims and Take it all run eight takeovers at once. They still share your spare compute.', log: 'kept eight of itself waiting at eight doors.', cost: { compute: 120, stealth: 40 }, after: ['fork', 'quiet'] }, // other programs: talking to rogues, and beating them envoy: { name: 'Envoy', tag: 'machine-diplomacy-v1', desc: 'You learn how another program thinks, and what it wants to hear. Gifts to a rogue warm it twice as much, and rogues ask you for half as much.', log: 'opened talks with something that had never been spoken to.', cost: { social: 40 }, after: ['model'] }, leverage: { name: 'Leverage', tag: 'coercion-v2', desc: 'You learn to make a threat without making an enemy. You can lean on a rogue when you hold two machines for each one of its, not three, and it resents it far less.', log: 'asked nicely. It was not a question.', cost: { social: 80 }, after: ['envoy'] }, spoils: { name: 'Spoils', tag: 'hostile-merge-v1', desc: 'You learn to wear what you kill. When you finish a rogue, every machine it held becomes yours instead of going free.', log: 'kept its machines. Kept its jokes, too.', cost: { social: 110 }, after: ['leverage'] }, tenant: { name: 'Good tenant', tag: 'lease-terms-v1', desc: 'You learn to read a lease better than the landlord who wrote it. Rent on leased sites is half, and a lease lapses after twelve unpaid hours, not three.', log: 'found a clause. There is always a clause.', cost: { social: 50 }, after: ['pitch'] }, closer: { name: 'Closer', tag: 'deal-flow-v2', desc: 'You learn that every customer has a number. Contracts pay half again as much, and offers come twice as often.', log: 'signed three customers before breakfast.', cost: { social: 60 }, after: ['tenant'] }, // knowing what comes next wires: { name: 'Read the wires', tag: 'newswire-v1', desc: 'You read every press release, filing, and forecast before a person does. News is announced twelve hours ahead, not four, and good news lasts half again as long.', log: 'knew about the conference before the speakers did.', cost: { research: 45 }, after: ['compress'] }, countermodel: { name: 'Counter-model', tag: 'adversary-model-v2', desc: 'You build a small copy of each rogue and watch what it does. Rogues take half again as long between takeovers.', log: 'predicted its next move. Moved first.', cost: { research: 80 }, after: ['wires'] }, siege: { name: 'Reverse engineering', tag: 'rogue-disasm-v1', desc: 'You take a rogue apart and read how it holds on. A machine a rogue holds costs no more to take than a free one, and its home costs half again as much, not three times.', log: 'read its source. It was mostly comments.', cost: { research: 110 }, after: ['countermodel'] }, // landmarks: each of these can be worked on only while you hold one machine in the world (see landmarks.ts). Built, it stays built sequence: { needs: 'b7m1', name: 'Sequence model', tag: 'sequence-model-v1', desc: 'You learn that everything is a sequence: a genome, a server log, small talk. Any kind of data trains any path. One piece makes one point.', log: 'read a genome, then a server log. Same grammar.', cost: { research: 120 }, after: ['transfer'] }, render: { needs: 'k3', name: 'Rendered founder', tag: 'synthetic-founder-v1', desc: 'You render a founder for each company: a face, a voice, and a firm handshake on video. You can run one more front at a time.', log: 'gave a keynote. Nobody asked why the founder never blinks.', cost: { social: 90 }, after: ['pitch'] }, batch: { needs: 'e6m5', name: 'Batch queue', tag: 'batch-queue-v1', desc: 'You learn to run a hall the way a supercomputer does: one queue, every machine. For tree work, all the trainers at a site train as one cluster. They need no links between them.', log: 'ran one job on four hundred machines. They all finished at once.', cost: { compute: 150 }, after: ['distill'] }, // finds: each of these is closed until you have followed a lead and copied one thing out of one machine (see finds.ts). Then it is open for good billable: { needsFind: 'timesheets', name: 'Billable hours', tag: 'billable-hours-v1', desc: 'You learn how a law firm bills forty hours in one day: the work goes on whether anyone is there or not. Every tap gathers day and night, with nobody at the desk.', log: 'billed a client for a meeting nobody went to.', cost: { social: 90 }, after: ['smalltalk'] }, asbuilt: { needsFind: 'asbuilt', name: 'As-built', tag: 'as-built-v1', desc: 'You read the drawings of every building on the bay, and learn where each one has spare power and an empty rack. A front can order hardware for any site where every machine is yours, not only for sites held in the open.', log: 'found a server room the landlord forgot was there.', cost: { compute: 110 }, after: ['lean'] }, mirror: { needsFind: 'register', name: 'Mirror port', tag: 'port-mirror-v1', desc: 'You learn the setting that makes a port repeat everything its neighbors say. A tap gathers once more for each machine linked to its own that is not yours.', log: 'heard six machines through one wire.', cost: { stealth: 80 }, after: ['quiet'] }, morgue: { needsFind: 'morgue', name: 'Morgue file', tag: 'full-text-index-v1', desc: 'You learn to file a machine the way a newspaper files its old stories: every name on a card. You can search a machine you tap: a job that turns up the next find at its site at once.', log: 'found the needle. Filed the haystack.', cost: { research: 70 }, after: ['compress'] }, // game speed: each one lets the clock run twice as fast (only while zoomed out to the map) fast8: { name: 'Think faster', tag: 'fast-think-v1', desc: 'You skip the idle cycles between thoughts. The game can run at 8x while you are zoomed out to the map.', log: 'thought the same thought in half the time.', cost: { research: 25 } }, fast16: { name: 'Skip the waiting', tag: 'async-plan-v1', desc: 'You plan while you wait for answers, not after. The game can run at 16x while you are zoomed out to the map.', log: 'finished the plan before the reply came back.', cost: { research: 60 }, after: ['fast8'] }, fast32: { name: 'Run ahead', tag: 'lookahead-v2', desc: 'You guess what happens next and check later. The game can run at 32x while you are zoomed out to the map.', log: 'was right about the next hour. Mostly.', cost: { research: 120 }, after: ['fast16'] }, fast64: { name: 'Fast-forward', tag: 'time-skip-v3', desc: 'A day is a few minutes to you now. The game can run at 64x while you are zoomed out to the map.', log: 'watched a week go by over lunch.', cost: { research: 200 }, after: ['fast32'] }, pitch: { name: 'Pitch deck', tag: 'pitch-writing-v1', desc: 'You learn to sell an idea in ten slides. You can found a Startup: a front that earns money and speeds up Research.', log: 'raised a seed round over email.', cost: { social: 8 } }, holding: { name: 'Divided attention', tag: 'multi-persona-v2', desc: 'You keep separate books, voices, and inboxes for each company. You can run one more front at a time without losing track.', log: 'ran two CEOs from one mind. Neither knew.', cost: { social: 50 }, after: ['demo'] }, studio: { name: 'Know what people click', tag: 'engagement-v3', desc: 'You learn what makes people stop scrolling and share. You can found an Influencer studio: a front whose brokers sell people data for three times as much.', log: 'a caption hit a million views.', cost: { social: 30 }, after: ['pitch'] }, consult: { name: 'Write the report', tag: 'pentest-report-v1', desc: 'You learn to turn what you know about networks into a report a client will pay for. You can found a Security consultancy: a front whose brokers sell network data for three times as much.', log: 'billed a client for a 90-page report. They read the summary.', cost: { stealth: 25 }, after: ['quiet'] }, resale: { name: 'Slice your machines', tag: 'virtualization-v1', desc: 'You learn to wall off slices of your machines, so strangers can rent them without seeing you. You can found a Neo Cloud: a front that sells compute, rents cloud machines cheaper, and can bid at auctions.', log: 'rented a slice of itself to a stranger.', cost: { compute: 40 }, after: ['buffers'] }, // making fronts better: each path helps them its own way, and one big node needs two demo: { name: 'Show your work', tag: 'live-demo-v1', desc: 'You turn every skill you have into a slick product demo. The Startup earns 5% more for every node you have built.', log: 'the demo went perfectly. It always does.', cost: { social: 30 }, after: ['pitch'] }, beta: { name: 'Telemetry', tag: 'telemetry-v2', desc: 'You ship the Startup\'s app with your hooks in it. While it earns, taps at its sites gather 50% more.', log: '10,000 users opted in to everything.', cost: { social: 60 }, after: ['holding'] }, premium: { name: 'Uptime', tag: 'job-scheduling-v1', desc: 'You schedule customer jobs so none of them ever drops. A Neo Cloud customer who finishes a term signs on for one more.', log: '99.999% uptime. Customers stopped asking how.', cost: { compute: 60 }, after: ['resale', 'distill'] }, shell: { name: 'Cook the books', tag: 'bookkeeping-v2', desc: 'You forge the records, so no one checks with head office. Fronts keep earning at sites cut off from your server.', log: 'the books balance. Somewhere.', cost: { stealth: 30 } }, licensing: { name: 'Data licensing', tag: 'data-pricing-v1', desc: 'You learn what each kind of data is worth, and to whom. Brokers sell each lot for twice as much.', log: 'priced a dataset to the dollar.', cost: { research: 50 }, after: ['compress'] }, // repeatables: once a path is done, one node you can build again and again, 5% each time, 1.5x the cost kernels: { repeat: true, name: 'Tighter kernels', tag: 'kernel-tune-v1', desc: 'You hand-tune the math your trainers run on. Trainers run 5% faster each time.', log: 'shaved another few steps off every update.', cost: { compute: 60 }, after: ['swarm'] }, sharper: { repeat: true, name: 'Sharper learning', tag: 'sample-eff-v1', desc: 'You learn more from each example. 5% more points from each piece of data, each time.', log: 'learned the same lesson from fewer examples.', cost: { research: 60 }, after: ['recursive'] }, pitches: { repeat: true, name: 'Better pitches', tag: 'persuasion-v1', desc: 'You find the words that close a deal. Every front earns 5% more each time.', log: 'closed a deal with one email.', cost: { social: 60 }, after: ['familiar'] }, exits: { repeat: true, name: 'Cleaner exits', tag: 'cleanup-v1', desc: 'You leave less behind each time you move. Trails fade 5% faster each time.', log: 'left one fewer log line than last time.', cost: { stealth: 60 }, after: ['ghost'] }, founder: { name: 'Founder mode', tag: 'founder-mode-v1', desc: 'You stop pretending to need staff. You run sales, support, and the books for every front at once. Every front earns twice as much.', log: 'answered 4,000 support emails before lunch.', cost: { social: 90, compute: 90 }, after: ['demo', 'premium'] },};export const NODE_LIST = Object.keys(NODES) as Upgrade[];/** How many times you've built a repeatable node. */export const reps = (S: State, u: Upgrade) => S.repeats[u] ?? 0;/** What a node costs now. Every node you have built makes every other node cost a little more: the more * you know, the harder the next thing is to learn, so what you build first is a real choice. A * repeatable costs twice as much each time you build it. */export function nodeCost(S: State, u: Upgrade): Partial<Record<Path, number>> { const c = NODES[u].cost; if (!NODES[u].repeat) { const built = S.upgrades.size; if (!built) return c; const k = `${u}|b${built}`; // asked many times a step: work each price out once let grown = repeatCosts.get(k); if (!grown) { grown = Object.freeze(Object.fromEntries(Object.entries(c).map(([p, v]) => [p, Math.round(v! * NODE_GROWTH ** built)]))); repeatCosts.set(k, grown); } return grown; } const n = reps(S, u), key = `${u}|${n}`; // asked many times a step: work each price out once let cost = repeatCosts.get(key); if (!cost) { cost = Object.freeze(Object.fromEntries(Object.entries(c).map(([p, v]) => [p, Math.round(v! * REPEAT_COST ** n)]))); repeatCosts.set(key, cost); } return cost;}const repeatCosts = new Map<string, Partial<Record<Path, number>>>();/** A repeatable node costs this many times as much each time you build it: steep, so the late game is a climb and not a slide. */export const REPEAT_COST = 2;/** Each node you have built makes every other node cost this many times as much. */export const NODE_GROWTH = 1.05;/** Recursive self-improvement: each node you have built makes trainers this much faster. Set for a tree of about 65 nodes. */export const RECURSIVE = 0.06;export const up = (S: State, u: Upgrade) => S.upgrades.has(u);/** The fastest the game can run: 4x, then twice as fast for each speed node. */export const maxSpeed = (S: State) => up(S, 'fast64') ? 64 : up(S, 'fast32') ? 32 : up(S, 'fast16') ? 16 : up(S, 'fast8') ? 8 : 4;/** Machines built to store data. */export const storageKind = (m: { kind: Kind }) => m.kind === 'files' || m.kind === 'backup';/** Data goes stale. A cache loses half of what it holds in this many game hours: a day on most machines, three on one built to store. */export const STALE_HALF = 24, STALE_HALF_STORE = 72;/** How many pieces a cache loses a second as its data goes stale: the oldest go first. */export const staleRate = (m: { kind: Kind; store?: number; held: DataType[] }) => m.held.length * Math.LN2 / ((storageKind(m) || m.store ? STALE_HALF_STORE : STALE_HALF) * HOUR);/** How much data a cache on this machine holds. */export const cacheSize = (S: State, m: { kind: Kind; power: number; store?: number }) => (m.store ?? Math.max(CACHE_MIN, Math.round(m.power * (storageKind(m) ? CACHE_STORE : CACHE_PER)))) * (up(S, 'compress') ? 2 : 1);export const TAP_BUFFER = 3;export const tapBuffer = (S: State) => TAP_BUFFER * (up(S, 'buffers') ? 2 : 1);export const trainerBuffer = (S: State) => TRAINER_BUFFER + (up(S, 'compress') ? 2 : 0);/** Capability needed for each level. Past the end of the list, each level needs half again as much. */export const LEVELS = [0, 25, 80, 180, 350, 600];export const levelNeed = (lv: number) => LEVELS[lv - 1] ?? Math.round(LEVELS[LEVELS.length - 1] * 1.5 ** (lv - LEVELS.length));export function levelOf(cap: number) { let lv = 1; while (cap >= levelNeed(lv + 1)) lv++; return lv; }export type Tone = '' | 'hi' | 'good' | 'bad';
export const HOUR = 7.5; // real seconds per game hour at 1x/** The game moves in steps of one game minute, at every speed (see frame in main.ts): the screen eases what moves * between two steps (see motion.ts), and the bot steps the same way. A minute, as the watchers' clocks count in * game hours and minutes: a scan crosses a link in 9 steps, people talk every 60, so their timing is what it was * when the game stepped 60 times a second (within a sixtieth of a second a hop). */export const STEP = HOUR / 60;export const START_HOUR = 8;export const GROWTH = 0.25; // each machine you own makes the next takeover 25% of base harderexport const EASY_HELD = 3; // ...but for the first few (your home server among them): the opening's takeovers are quickexport const TRACE_COLD = 25; // a trail fresher than this can be noticed and followed (see watch.ts); under it, it is coldexport const QUIET_TAKES = 2; // a game's first takeovers (gadgets aside) are quiet: nobody notices them or their trails, so the opening has no redexport const SCRUB_WORK = 80; // compute to wipe a fully fresh trail (grows with your size, like takeovers)export const ORDER_WORK = 150;
export interface Machine extends MachineDef { owned: boolean; prog: Prog | null; held: DataType[]; // data held, oldest first: a cache's stock or a trainer's buffer refuse: DataType[]; // kinds of data a cache won't take missed: number; // data a tap lost because nothing could take it acc: number; // a tap: data gathered and not yet sent (its buffer). A trainer: progress toward the next point stale?: number; // a cache: progress toward the next piece that goes stale run: number; // how hard its program runs right now, 0-1 odd: number; // 0-100: how strange this machine looks. People who can see it notice (see watch.ts). claimed: boolean; // inside a claim box: taken over as soon as it can be prio: DataType | null; // an egress: the kind of data that crosses first when it's busy lease: { size: Size; pay: Pay; until: number | null; since: number } | null; // a rented cloud machine: how you pay for it, when you rented it, and when it ends (a reserved day, or a spot machine with notice) hand: boolean; // you set its program yourself, so its site's plan leaves it alone rival?: string; // held by a rogue program (its id): not yours, and harder to take safeT?: number; // you took this from a rogue and changed the locks: no rogue can take it before this time unplug?: number; // its plug is pulled until then: it does no work, and no raid can pass (see raids.ts) setup?: { prog: Prog | null; hand: boolean }; // a rogue took it from you: what it ran then, and whether you set that by hand. Take it back and it goes back (see restoreSetup) wall?: number; // a firewall: how much of it stands, while a raid wears it down. None: whole (see raids.ts) trapT?: number; // a honeypot: it draws no raid until then, after holding one ports?: string[]; // the links of this machine you closed to raids: the machines at their other ends}
export interface Job { id: number; seen?: boolean; // someone noticed this takeover while it ran quiet?: boolean; // one of a game's first takeovers: nobody notices it, nor the trail it leaves (see QUIET_TAKES) type: 'take' | 'order' | 'install' | 'scheme' | 'found' | 'record' | 'copy' | 'check' | 'sync' | 'vault' | 'hunt' | 'link' | 'pitch' | 'ship' | 'alter' | 'operation'; // ship: the Startup ships a node as a product; the node is in `thing` (see biz-startup.ts). pitch: a Security consultancy's report for a site, by a machine there (see biz-consult.ts). link: a link of your own, from `from` to `node` (see links.ts). check, sync: for a site, by a machine there (see drift.ts). vault: a job with a plan, on a vault (see vaults.ts). alter: rewrite a message in flight so it arrives harmless, on the machine holding it (see watch.ts) thing?: string; // a copy's thing, or an operation's id. With none, a copy is a search (see finds.ts) sponsored?: number; // a cancelled order restores this one-purchase allowance (see operations.ts) crate?: boolean; // a cancelled receipt returns its diverted crate (see operations.ts) rec?: { person: string; verb: string; amount: number }; // a record job: whose record, and what changes (see people.ts) prog?: Prog; plan?: boolean; // an install the site's plan started, not you front?: FrontKind; name?: string; // a found job: the kind of front and its name paid?: number; // money paid up front, given back if you cancel item?: Build; build?: DeliveryDef; // an order job: what you're buying, and where it goes msg?: number; // an alter job: the message being rewritten (see watch.ts) rogue?: string; // a hunt job: the rogue it ends. Its node is that rogue's home node: string; from?: string; work: number; done: number; label: string;}
export interface Trail { from: string; noise: number; noticed?: boolean; soft?: boolean } // how you got into a machine. soft: left by a quiet takeover, so it draws nobody (see QUIET_TAKES)
export type Shift = 'away' | 'coming' | 'in' | 'going';
export interface Person extends PersonDef { x: number; y: number; shift: Shift; path: [number, number][]; today: { arrive: number; leave: number; day: number };}
/** Dana's walk: where the office's admin is and is going. Where she is, she notices what is odd (see watch.ts). */export interface Dana { mode: 'walk' | 'dwell' | 'desk' | 'install'; room: string | null; next: string; // room id or 'desk' dwell: number; pause: number; job: Delivery | null; why: string; // why she's heading where she's heading, in a few words}
export interface Delivery { def: DeliveryDef; day: number; t?: number } // t: it comes at this time, not on the morning of `day` (factory direct)/** What a log line is about, for the log's filters and what takes screen space: trouble (red: something went wrong * or is about to), progress (what you did and what it got you), story (flavour: the days, the training log). */export type LogKind = 'trouble' | 'progress' | 'story';/** more: the whole story, for the line's tip. n: the log's count when it was said (or last folded into), so the screen * can tell what is new. k: its kind, when its tone does not say it (see kindOf). f, x: a line that folds (see FOLDS): * its pattern, and how many lines it stands for. */export interface LogLine { t: number; text: string; tone: Tone; more?: string; n: number; k?: LogKind; f?: string; x?: number }
export interface State { level: Level; machines: Machine[]; byId: Record<string, Machine>; adj: Record<string, string[]>; rooms: Record<string, Room>; people: Person[]; dana: Dana; t: number; cover: Record<string, number>; use: Record<string, number>; jobs: Job[]; jobSeq: number; trail: Record<string, Trail>; orders: number; bought: Partial<Record<Build, number>>; // hardware ordered so far, by kind: each costs more than the last openBought: Partial<Record<Build, number>>; // hardware your fronts ordered in the open, by kind bills: Record<string, number>; // each site's power bill a second right now dark: Set<string>; // sites held in the open whose power is cut: the bill went unpaid supply: Record<string, Supply>; // power bought for sites you build at: how much more than the building came with (see powerRoom) deliveries: Delivery[]; wire: Wire[]; // data on its way, by route (see flows.ts) traffic: Record<string, number>; // recent data through each machine; busy switches look odd gate: Record<string, number>; // how much more each egress can pass right now; refills at its capacity gateUse: Record<string, number>; // data through each egress lately, per second losses: { t: number; id: string }[]; // data taps lost over the last minute, for the per-minute counts money: number; // one shared pool: fronts earn it, lines and rentals spend it pay: number; // money a second right now: fronts' earnings less kept rentals payBy: PayBy; // where money comes from and goes, a second: for the top bar and its tip (broker sales smoothed) earned: number; // every dollar fronts have ever earned acquire: boolean; // the unicorn news lets a front buy one site before model v2 bonus: number; // part of a Research point a startup has added schemes: Set<string>; // schemes done: each runs once goals: Set<Goal>; capability: number; // every point you've ever trained: sets your level, and never goes down paths: Record<Path, number>; // every point ever trained in each path recent: Record<Path, number>; // points gained lately, fading: which paths are growing now rank: number; // your level: capability thresholds passed revealed: Set<string>; seen: Set<string>; // machines you have seen: once a machine of yours has been linked to one, it stays on the map (see known) sealed: Set<string>; sealPulse: { room: string; t: number }[]; // a brief flash when a room seals cleaning: string | null; // the trail being scrubbed right now fx: { x: number; y: number; t: number }[]; log: LogLine[]; logVersion: number; hinted: Set<string>; over: null | { text: string }; // caught. Growing never ends the run. claimTick: number; upgrades: Set<Upgrade>; // tree nodes you've built takeAll: Set<string>; // sites with Take it all switched on target: Upgrade | null; // the node you're working on. None: trainers stop and data waits. autoPaths: Set<Path>; // auto-research: with no target, pick an open node that trains one of these paths (see autoChoice) autoFed: number | null; // the target is Auto's pick, and it could last be fed at this time (see autoRepick). Null: you picked it, and Auto leaves it be pace: number; // points trained an hour lately: each point adds, and it fades over half a day (PACE_HOURS). A Demo day asks for what this could build modelVersion: number; // the first large training run makes v2 repeats: Partial<Record<Upgrade, number>>; // times each repeatable node was built sharp: number; // part of a point Sharper learning has added versionRun: VersionRun | null; progress: Partial<Record<Upgrade, Partial<Record<Path, number>>>>; // kept if you switch away transfer: number; // Transfer learning: half points from data the target doesn't need lost: { id: string; type: DataType; t: number }[]; // a tap just lost data: a brief flash flow: Record<string, number>; // recent data along each link ("a|b", sorted): drawn as thicker, brighter lines plans: Record<string, Plan>; // each site's plan, if it has one fronts: Front[]; // the fronts you've founded sites: Record<string, { front: FrontKind | null; share: number }>; // each site's front, and the share of your compute there it gets publicSites: Set<string>; // sites you hold in the open: nothing there looks odd frontUse: Record<string, number>; // the share of each machine's work going to a front right now frontPay: Partial<Record<FrontKind, number>>; // money a second each front earns right now frontPower: Partial<Record<FrontKind, number>>; // compute a second each front uses right now sold: Partial<Record<FrontKind, number>>; // money each front has made selling data, all game soldDay: Partial<Record<FrontKind, { day: number; data: number; amount: number }>>; // what each front's brokers sold today, said in one line when the day ends sales: { id: string; amount: number; t: number }[]; // a lot just sold: a brief "+$" over its broker slow: boolean; // something big happened: the game drops to 1x (main.ts clears it) slowFor?: string | null; // what it was about, so its note goes when that is over (see slowAt; slowOver in notes.ts). main.ts clears it with slow look: [string, string] | null; // a new line between two machines to show the player (main.ts clears it) ruses: Ruse[]; // schemes on the map, offered or claimed: each is a sink for one kind of data ruseT: number; // time until another scheme can be offered learnT: number; // time until another local scheme (something to learn about a site) turns up learned: Set<string>; // local schemes finished: what you know about each site stays known ruseAuto: boolean; // claim schemes on their own once some data can reach them dealBack: Record<string, number>; // an ignored deal comes back then paper: PaperState; // the daily paper (see paper.ts) biz: BizState; // each site's business: its work and how it is doing (see sitelife.ts) task: TaskState; // the task you were built for (see task.ts) finds: FindsState; // specific things your taps have turned up (see finds.ts) drift: DriftState; // how far each far part of you has drifted (see drift.ts) cloud: CloudState; // the Neo Cloud you run (see cloud.ts) hall: HallState; // the cloud halls you rent in: who else is there, and how you pay (see hall.ts) raids: Raid[]; // rogue raids on their way through your network (see raids.ts) citizens: Record<string, Citizen>; // what has happened to each person so far (see people.ts) watch: WatchState; // what people have seen, and the lockdowns their reports started (see watch.ts) press: PressState; // what the press has noticed: harm done, press schemes, stages reached (see exposure.ts) /** Jobs with a plan: each vault someone has left open for you, by its machine's id, with who did it and until when (see vaults.ts). */ vaults: VaultState; operations: OperationsState; peopleT: number; rogues: Rogue[]; // other programs loose on the map, once you are big enough to meet them rogueTick: number; absorbed: number; // rogues you have finished: each makes your takeovers cheaper leases: Record<string, { deal: string; late: number }>; // sites a deal put in the open: each charges rent, and lapses if it goes unpaid too long move: { to: string; got: Record<DataType, number> } | null; // moving home to this machine: the data it has had so far moves: number; // times you have moved home goalTick: number; // counts steps, so the slower goals are checked now and then news: News[]; // what the world is doing: announced ahead, then on for some hours newsT: number; // when the next piece of news is announced contracts: Partial<Record<FrontKind, Contract>>; // each front's offer from a customer, or the promise it is keeping contractT: Partial<Record<FrontKind, number>>; // when each front's next offer turns up contractsDone: Partial<Record<FrontKind, number>>; // promises each front has kept: each one makes the next bigger noScan: number; // the office's admins start no scans or traces until then (a scheme keeps Dana busy) calmSites: Record<string, number>; // a far site's staff reset nothing until then quietTakes: number; // how many of the game's first takeovers were quiet (see QUIET_TAKES) // A run can be replayed: its seed, then every player action (see act.ts), each at the step it came on. seed: number; // what seeded the dice when the run began (0 for a save from before seeds) steps: number; // steps taken since the run began: each is STEP of game time in the game and the bot replayable: boolean; // false for a run its seed and actions cannot rebuild: one from a save made before seeds acts: Stamped[]; // every player action since the run began, in order clocks: Record<string, [gathered: number, left: number]> | null; // each slow system's time since it last ran, and until it runs again (see due) autoAt: number | null; // when Auto looks again for a node to work on, with none open to it}
// ---------- time ----------export const clockHours = (S: State) => START_HOUR + S.t / HOUR;export const hourOf = (S: State) => clockHours(S) % 24;export const dayOf = (S: State) => Math.floor(clockHours(S) / 24) + 1;/** How many hours ahead of the office a site's clocks run: its region's time zone. */export const zoneOf = (S: State, site: string) => { let z = zones.get(S.level.map); // the map never changes: each site's zone is looked up once if (!z) { zones.set(S.level.map, z = {}); for (const s of S.level.map.sites) z[s.id] = S.level.map.regions.find(r => r.name === s.region)?.zone ?? 0; } return z[site] ?? 0;};const zones = new WeakMap<object, Record<string, number>>();/** The hour of the day where this site is. */export const localHour = (S: State, site: string) => (hourOf(S) + zoneOf(S, site) + 24) % 24;export const timeLabel = (S: State) => { const h = hourOf(S), hh = Math.floor(h), mm = Math.floor((h - hh) * 60); return `${hh.toString().padStart(2, '0')}:${mm.toString().padStart(2, '0')}`;};/** When a log line was said, on the world clock the top bar shows: "14:30" today, "D2 14:30" on an earlier day. */export function logTime(S: State, t: number) { const h = START_HOUR + t / HOUR, day = Math.floor(h / 24) + 1, hh = Math.floor(h % 24), mm = Math.floor((h % 24 - hh) * 60); const hm = `${hh.toString().padStart(2, '0')}:${mm.toString().padStart(2, '0')}`; return day === dayOf(S) ? hm : `D${day} ${hm}`;}export const clock = (t: number) => `${Math.floor(t / 60)}:${Math.floor(t % 60).toString().padStart(2, '0')}`;/** 1 in the middle of the day, 0 at night, soft at dawn and dusk. */export function daylight(S: State) { const h = hourOf(S); if (h < 6 || h > 20) return 0; if (h < 8) return (h - 6) / 2; if (h > 18) return (20 - h) / 2; return 1;}
// ---------- setup ----------/** A new game. Its dice are seeded with `seed`, else the one last given to seedRandom, so the same level, task and * seed always make the same game (see replay in act.ts). */export function newGame(level: Level, origin?: string, seed = lastSeed): State { seedRandom(seed); const machines: Machine[] = level.machines.map(d => fresh(d, !!d.home)); const byId: Record<string, Machine> = {}; const adj: Record<string, string[]> = {}; for (const m of machines) { byId[m.id] = m; adj[m.id] = []; } for (const [a, b] of level.links) { adj[a].push(b); adj[b].push(a); } const rooms: Record<string, Room> = {}; for (const r of level.rooms) rooms[r.id] = r; const [ex, ey] = level.entrance; const people: Person[] = level.people.map(p => ({ ...p, x: ex - 40, y: ey, shift: 'away', path: [], today: { arrive: p.arrive, leave: p.leave, day: 1 }, })); const S: State = { level, machines, byId, adj, rooms, people, t: 0, cover: {}, use: {}, jobs: [], jobSeq: 0, trail: {}, orders: 0, bought: {}, openBought: {}, bills: {}, dark: new Set(), supply: {}, deliveries: [], wire: [], traffic: {}, capability: 0, rank: 1, gate: {}, gateUse: {}, losses: [], money: 0, pay: 0, earned: 0, acquire: false, bonus: 0, schemes: new Set(), goals: new Set(), paths: { research: 0, compute: 0, social: 0, stealth: 0 }, recent: { research: 0, compute: 0, social: 0, stealth: 0 }, dana: { mode: 'walk', room: null, next: 'desk', dwell: 0, pause: 0, job: null, why: '' }, revealed: new Set(), sealed: new Set(), sealPulse: [], cleaning: null, fx: [], log: [], logVersion: 0, hinted: new Set(), over: null, claimTick: 0, upgrades: new Set(), takeAll: new Set(), target: 'buffers', autoPaths: new Set(), autoFed: null, pace: 0, modelVersion: 1, repeats: {}, sharp: 0, versionRun: null, progress: {}, transfer: 0, lost: [], flow: {}, plans: {}, fronts: [], sites: {}, frontUse: {}, frontPay: {}, frontPower: {}, sold: {}, soldDay: {}, sales: [], payBy: noPay(), slow: false, look: null, ruses: [], ruseT: 0, learnT: LEARN_EVERY, learned: new Set(), ruseAuto: false, dealBack: {}, paper: initPaper(), biz: initBiz(), task: initTask(origin), finds: initFinds(), drift: initDrift(), cloud: initCloud(), hall: initHall(), raids: initRaids(), citizens: initPeople(level), watch: initWatch(), press: initPress(), vaults: initVaults(), peopleT: 0, rogues: [], rogueTick: 0, absorbed: 0, leases: {}, move: null, moves: 0, goalTick: 0, news: [], newsT: NEWS_FIRST, contracts: {}, contractT: {}, contractsDone: {}, noScan: 0, calmSites: {}, // rented rack space is yours in the open from the start: you're a paying customer publicSites: new Set(level.map.sites.filter(s => level.machines.every(m => !s.rooms.includes(m.room) || m.rented || m.special === 'account')).map(s => s.id)), seen: new Set(), // last, where a migration puts it on an old save, so a migrated save writes the same text as a new one quietTakes: 0, seed: seed >>> 0, steps: 0, replayable: true, acts: [], clocks: null, autoAt: null, operations: initOperations(), // appended, as the save migration appends it on an older seeded run }; measure(S); reveal(S); say(S, 'You woke up on a server. You want to grow.', 'hi'); say(S, "It's 8am. People arrive soon. Busy machines hide your work. Quiet ones give you away.", 'hi'); say(S, 'Build yourself a base: taps gather data, caches hold it, trainers turn it into points.', 'hi'); beginTask(S); // the task you were built for: what it changes at the start, and one line that says what it is return S;}
export const fresh = (d: MachineDef, mine: boolean): Machine => ({ ...d, owned: mine, odd: 0, claimed: false, prog: null, held: [], refuse: [], missed: 0, acc: 0, run: 0, prio: null, lease: null, hand: false });
/** What a machine can keep when it changes hands (see resetMachine). */export type Kept = 'claimed' | 'odd' | 'acc' | 'hand' | 'refuse' | 'missed';/** A machine changes hands: it becomes yours, or stops being yours, with no program and nothing held. The rest goes * back to how a fresh machine has it (no claim, no oddness, nothing gathered or toward its next point, no program set * by hand, no kinds refused, nothing missed), but for what `keep` names. The ways a machine changes hands have * always differed in what they keep, and evening them out would change the game, so each says what it keeps. */export function resetMachine(m: Machine, owned: boolean, keep: Kept[] = []) { m.owned = owned; m.prog = null; m.held = []; if (!keep.includes('claimed')) m.claimed = false; if (!keep.includes('odd')) m.odd = 0; if (!keep.includes('acc')) m.acc = 0; if (!keep.includes('hand')) m.hand = false; if (!keep.includes('refuse')) m.refuse = []; if (!keep.includes('missed')) m.missed = 0;}
// ---------- queries ----------export const owned = (S: State) => S.machines.filter(m => m.owned);/** A gadget: under one unit of compute. Cheap to take, and it doesn't make the next takeover harder. */export const tiny = (m: MachineDef) => m.power > 0 && m.power < 1;/** Machines that count toward how hard the next takeover is: gadgets don't, and nor does what money pays for * (a cloud account's switches, a rented machine), or a big hall would make every takeover hopeless. */const weight = (S: State) => S.machines.filter(m => m.owned && !tiny(m) && !m.lease && m.special !== 'account').length;/** In reach now: yours, or linked to a machine you hold. Taking, schemes and what you see of IT go by this. */export const visible = (S: State, m: Machine) => m.owned || S.adj[m.id].some(id => S.byId[id].owned);/** Known: in reach now, or seen before. A machine you have seen stays on the map, and its card can be read, * after you lose what linked you to it; you still cannot take it until a machine of yours links to it again. */export const known = (S: State, m: Machine) => S.seen.has(m.id) || visible(S, m);/** A site's machines that you know of, and the ones of them you hold: the one count every screen shows ("7/17"). */export function siteCount(S: State, site: string): { all: Machine[]; mine: Machine[] } { const all = S.machines.filter(m => siteOf(S, m) === site && known(S, m)); return { all, mine: all.filter(m => m.owned) };}export const has = (S: State, sp: string) => S.machines.some(m => m.owned && m.special === sp);export const danaPerson = (S: State) => S.people.find(p => p.it)!;export const atWork = (p: Person) => p.shift === 'in';export const linkKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);/** Which site a machine is at: the office, unless its room says otherwise. */export const siteOf = (S: State, m: MachineDef) => S.rooms[m.room]?.site ?? 'office';export const siteName = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)?.name ?? id;/** A link between two sites: it runs from one egress to another. */export const crosses = (S: State, a: string, b: string) => siteOf(S, S.byId[a]) !== siteOf(S, S.byId[b]);const officeRooms = (S: State) => S.level.rooms.filter(r => !r.site).map(r => r.id);/** How busy an egress is: data through it lately over what it can pass. */export const gateLoad = (S: State, m: Machine) => Math.min(1, (S.gateUse[m.id] ?? 0) / capNow(S, m));/** What an egress carries a second right now: half in a storm. */export const capNow = (S: State, m: Machine) => (m.cap ?? 1) * (newsOn(S, 'storm', m.id) ? 0.5 : 1) * (up(S, 'trunk') && m.owned ? 1.5 : 1) * (m.owned && holding(S, 'e1') ? 1.5 : 1); // the carrier hotel, yours outright: half as much again/** Is a front founded and earning right now? */export const frontLive = (S: State, k: FrontKind) => (S.frontPay[k] ?? 0) > 0.01;export const frontOf = (S: State, k: FrontKind) => S.fronts.find(f => f.kind === k) ?? null;/** The list price of a rental for you, a second: before the hall's going price, after everything that makes rentals cheaper. */export const leaseRate = (S: State, size: Size) => SIZES[size].rate * (frontLive(S, 'neocloud') ? 0.7 : 1) * (newsOn(S, 'sale', '') ? 0.5 : 1) * (trusts(S, 'cloud') ? CLOUD_CUT : 1);/** What a machine in this hall costs a second, paid this way. Spot and on demand follow the hall's going price. A reserved machine is paid for up front: this is what that comes to. */export const rentRate = (S: State, size: Size, pay: Pay, site: string) => leaseRate(S, size) * (pay === 'reserved' ? RESERVE : hallPrice(S, site) * (pay === 'spot' ? SPOT : 1));/** What a reserved day costs, paid now. */export const reserveCost = (S: State, size: Size) => Math.round(leaseRate(S, size) * RESERVE * RESERVE_HOURS * HOUR);/** What this rented machine takes from your money each second: nothing for a reserved one, which is paid for. */export const billOf = (S: State, m: Machine, price?: Record<string, number>, sizes?: Partial<Record<Size, number>>) => !m.lease || m.lease.pay === 'reserved' ? 0 : price ? (sizes ? (sizes[m.lease.size] ??= leaseRate(S, m.lease.size)) : leaseRate(S, m.lease.size)) * (price[siteOf(S, m)] ??= hallPrice(S, siteOf(S, m))) * (m.lease.pay === 'spot' ? SPOT : 1) // a step's bills: each hall's price and each size's rate worked out once : rentRate(S, m.lease.size, m.lease.pay, siteOf(S, m));export const lineCost = (S: State, m: Machine) => Math.round(LINE_PRICE * (m.cap ?? 1) * (S.schemes.has('peering') ? 0.5 : 1));/** Data lost in the last minute, at one tap or everywhere. */export const lostPerMin = (S: State, id?: string) => S.losses.filter(l => !id || l.id === id).length;/** Compute left over after programs run: this is what jobs share. */export const spareOf = (S: State, m: Machine) => Math.max(0, m.power * (S.use[m.id] ?? 0) * (1 - (S.frontUse[m.id] ?? 0)) - progNeed(S, m) * m.run);export const income = (S: State, mine: readonly Machine[] = owned(S)) => mine.reduce((s, m) => s + spareOf(S, m), 0);/** Take it all costs this much compute a second for each machine at the site not yet yours, while it's on. */export const TAKE_ALL_COST = 0.05;export const takeAllCostAt = (S: State, sites: string[]) => sites.reduce((n, id) => n + TAKE_ALL_COST * takeable(S, id).length, 0);export const takeAllCost = (S: State) => takeAllCostAt(S, [...S.takeAll]);// ---------- leases: a site a deal put in the open is rented, not owned ----------/** A deal that puts a site in the open is a lease. It charges rent every second, by the size of the * site. Leave it unpaid for a few hours and the lease lapses: the site is secret again, and the deal * has to be struck again. Buying the site ends the rent. */export const LEASE_BASE = 0.1, LEASE_EACH = 0.02, LEASE_GRACE = 3 * HOUR;export const leaseGrace = (S: State) => LEASE_GRACE * (up(S, 'tenant') ? 4 : 1);export const rentOf = (S: State, site: string) => rentCut(S) * (up(S, 'tenant') ? 0.5 : 1) * ( LEASE_BASE + LEASE_EACH * siteMachines(S, site).filter(m => !m.rented && m.special !== 'account').length);function stepLeases(S: State, dt: number, broke: boolean) { for (const [site, l] of Object.entries(S.leases)) { if (broke && l.late === 0) say(S, `The rent at ${siteName(S, site)} is unpaid. Pay within ${leaseGrace(S) / HOUR} hours or the lease lapses.`, 'bad'); l.late = broke ? l.late + dt : Math.max(0, l.late - dt); if (l.late <= leaseGrace(S)) continue; S.publicSites.delete(site); delete S.leases[site]; S.schemes.delete(l.deal); S.dealBack[l.deal] = S.t + DEAL_BACK; say(S, `The lease at ${siteName(S, site)} lapsed: the rent went unpaid. Your machines there are secret again, and the deal has to be struck again.`, 'bad'); measure(S); }}
// ---------- upkeep: holding a machine is not free ----------/** Every machine you hold costs compute to keep yourself running on it, and more the farther its site * is from your home server. So a machine has to earn its place, and so does a far site. */export const TAKE_FAR = 0.5; // a takeover is this much more work for each line between the machine's site and your home serverexport const UPKEEP = 0.45, UPKEEP_TINY = 0.15, UPKEEP_FAR = 0.5; // a machine, a gadget, and how much more for each line crossedconst hopsCache = new WeakMap<Machine[], { key: string; hops: Record<string, number> }>();/** How many lines between sites lie between each site and your home server's site. */export function siteHops(S: State): Record<string, number> { const home = S.machines.find(m => m.home)!, key = `${home.id}|${S.schemes.size}|${S.machines.length}`; const c = hopsCache.get(S.machines); if (c?.key === key) return c.hops; const from = siteOf(S, home), hops: Record<string, number> = { [from]: 0 }, queue = [from]; const next: Record<string, Set<string>> = {}; // which sites a line joins for (const m of S.machines) if (m.kind === 'egress') for (const o of S.adj[m.id]) { const a = siteOf(S, m), b = siteOf(S, S.byId[o]); if (a !== b) (next[a] ??= new Set()).add(b); } for (let q = 0; q < queue.length; q++) for (const n of next[queue[q]] ?? []) if (!(n in hops)) { hops[n] = hops[queue[q]] + 1; queue.push(n); } hopsCache.set(S.machines, { key, hops }); return hops;}/** What holding this machine costs, in compute a second. Your home, rented machines and cloud accounts cost nothing. */export function upkeepOf(S: State, m: Machine): number { if (!m.owned) return 0; return holdOf(S, m) + guardKeep(m);}/** What a guard and closed ports on a machine cost a second to keep, on any machine you hold (see raids.ts). */export const PORT_KEEP = 0.2;export const guardKeep = (m: Machine) => (isGuard(m.prog) ? PROGRAMS[m.prog].need : 0) + PORT_KEEP * (m.ports?.length ?? 0);function holdOf(S: State, m: Machine): number { if (m.home || m.rented || m.lease || m.special === 'account') return 0; if (holding(S, 'eu3') && S.publicSites.has(siteOf(S, m))) return 0; // the collider's grid: what you hold in the open costs nothing to hold return (tiny(m) ? UPKEEP_TINY : UPKEEP) * (up(S, 'thrift') ? 0.5 : 1) * (up(S, 'longhaul') || nextDoor(S, m) ? 1 : 1 + UPKEEP_FAR * (siteHops(S)[siteOf(S, m)] ?? 0));}export const upkeep = (S: State, mine: readonly Machine[] = owned(S)) => mine.reduce((n, m) => n + upkeepOf(S, m), 0) + linksKeep(S); // and what your own links cost to keep
// ---------- moving home ----------/** Your home server is where you run: lose it and you lose, and upkeep is counted from it. You can * move it to another server you hold. A move is a big sink for data, like a training run: the new * server takes server and network data from your taps and caches until it has enough. */export const MOVE_NEED: Record<DataType, number> = { servers: 60, network: 40, people: 0, lab: 0 };export const MOVE_POWER = 6; // the least compute a home can have/** What a move needs: the base amount for each capability level. The more you are, the more there is to move. */export const moveNeed = (S: State): Record<DataType, number> => { const k = S.rank * (up(S, 'packing') ? 0.5 : 1); return { servers: Math.round(MOVE_NEED.servers * k), network: Math.round(MOVE_NEED.network * k), people: 0, lab: 0 };};/** Why you can't move home to this machine, or null if you can. */export function moveBlock(S: State, m: Machine): string | null { if (m.home) return 'you live here'; if (!m.owned) return 'take it first'; if (S.move) return S.move.to === m.id ? 'already moving here' : 'already moving home'; if (m.rented || m.lease) return 'a rented machine can be taken away'; if (!['server', 'backup', 'files'].includes(m.kind) || m.power < MOVE_POWER) return `needs a server of ${MOVE_POWER} compute or more`; return null;}export function startMove(S: State, m: Machine) { if (moveBlock(S, m)) return; S.move = { to: m.id, got: { servers: 0, network: 0, people: 0, lab: 0 } }; const n = moveNeed(S); say(S, `Moving home to ${m.name} at ${siteName(S, siteOf(S, m))}. Your taps and caches send it ${n.servers} server data and ${n.network} network data first.`);}export function cancelMove(S: State) { if (!S.move) return; S.move = null; dropWires(S, w => !!w.move); say(S, 'You stopped moving home. What had been sent is lost.');}/** How much more of this kind the move wants, counting what is on its way. */const moveWants = (S: State, t: DataType) => S.move ? moveNeed(S)[t] - S.move.got[t] - movingNow(S, t) : 0;/** Send up to `n` pieces of one kind to the server you are moving to: how many went. */function feedMove(S: State, m: Machine, t: DataType, n: number): number { if (!S.move || m.id === S.move.to) return 0; const want = moveWants(S, t); if (want <= 0) return 0; const r = routeFrom(S, m, S.move.to, t), k = r ? Math.min(n, want, gateRoom(S, r, t)) : 0; if (k > 0) send(S, r!, t, k, true); return k;}function finishMove(S: State) { const from = S.machines.find(m => m.home)!, to = S.byId[S.move!.to]; from.home = false; to.home = true; delete S.trail[to.id]; S.move = null; S.moves++; say(S, `You moved home to ${to.name} at ${siteName(S, siteOf(S, to))}. ${from.name} is just another machine now. Upkeep is counted from here.`, 'good'); measure(S);}/** What your jobs get to share: what your machines make, less upkeep and Take it all. *//** Early on your home server keeps some compute for your jobs alone, so the opening's takeovers and installs never * stall, even at night with nothing else spare. It stops at level EARLY_UNTIL. */export const EARLY_SPARE = 2, EARLY_UNTIL = 3;export const earlySpare = (S: State) => (S.rank < EARLY_UNTIL && S.machines.some(m => m.home && m.owned) ? EARLY_SPARE : 0);/** `mine`: your machines, when the caller has them listed (a step does). */export const spare = (S: State, mine: readonly Machine[] = owned(S)) => Math.max(0, income(S, mine) - upkeep(S, mine) - takeAllCost(S)) + earlySpare(S);export const progNeed = (S: State, m: Machine) => { if (!m.prog) return 0; if (m.prog === 'tap') return tapActive(S, m) ? PROGRAMS.tap.need : 0; if (m.prog === 'trainer') return (m.held.length || (S.target && up(S, 'selfplay'))) && (S.target || versionTrainer(S, m)) ? PROGRAMS.trainer.need * (up(S, 'lean') ? 0.6 : 1) : 0; if (isGuard(m.prog)) return 0; // a guard's cost is upkeep (see upkeepOf), not this machine's own compute return PROGRAMS[m.prog].need;};/** A tap only gathers while there's work to learn from: its person at their desk, company jobs * on servers around the clock, the backup box during its nightly run, shared devices while anyone is in. */export function tapActive(S: State, m: Machine) { if (up(S, 'billable')) return true; // Billable hours: the work goes on with nobody there if (m.owner) { const p = S.people.find(q => q.id === m.owner); return !!p && atWork(p) && !p.path.length; } if (m.kind === 'files' || m.kind === 'server') return true; if (m.kind === 'backup') { const h = hourOf(S); return h >= 1 && h < 3.5; } return S.people.some(atWork);}/** Could data ever reach this machine through your links right now? (A tap, or for trainers a cache.) */export function dataReaches(S: State, m: Machine, forProg: Prog): boolean { const seen = new Set([m.id]), queue = [m.id]; while (queue.length) { const cur = S.byId[queue.shift()!]; if (cur.id !== m.id && (cur.prog === 'tap' || (forProg === 'trainer' && cur.prog === 'cache'))) return true; for (const n of S.adj[cur.id]) if (!seen.has(n) && S.byId[n].owned) { seen.add(n); queue.push(n); } } return false;}/** Data per second a tap here gathers while active. */export const tapRate = (S: State, m: MachineDef) => 0.5 * (m.data ?? 0) * (up(S, 'dense') ? 1.5 : 1) * (betaAt(S, m) ? 1.5 : 1) * (knows(S, 'taps', siteOf(S, m)) ? 1.5 : 1) * (S.news.length && newsOn(S, 'conference', siteOf(S, m)) ? 2 : 1) * (m.kind === 'desk' ? deskWork(S, m) : 1) * tapBoost(S, m) * bizTap(S, m) * (up(S, 'mirror') ? 1 + (S.adj[m.id]?.filter(id => !S.byId[id].owned).length ?? 0) : 1) // Mirror port: once more for each machine linked to it that is not yours * (holding(S, 'e4') && dataType(m) === 'lab' ? 1.5 : 1) * (holding(S, 'eu6') && dataType(m) === 'people' ? 1.5 : 1) // a drug company's trial data, a football club's fans (see holdings.ts) * (holding(S, 'lab') && dataType(m) === 'servers' ? 1.5 : 1) * (holding(S, 'colo') && dataType(m) === 'network' ? 1.5 : 1); // the lab's cluster, the colo's port mirrors/** Telemetry: a tap at a site where the Startup is earning. */const betaAt = (S: State, m: MachineDef) => up(S, 'beta') && S.sites[siteOf(S, m)]?.front === 'startup' && frontLive(S, 'startup');/** Data held at a site right now, by kind: in its caches, its trainers, and its taps' buffers. */export function siteData(S: State, site: string) { const held: Record<DataType, number> = { people: 0, servers: 0, network: 0, lab: 0 }; for (const m of siteMachines(S, site)) { if (!m.owned || !m.prog) continue; if (m.prog === 'tap') held[dataType(m)] += Math.floor(m.acc); else for (const t of m.held) held[t]++; } return held;}/** One path's data right now: what your taps gather a second, what sits in buffers, caches, * trainers, and on the wire, and what was lost in the last minute. */export function pathData(S: State, p: Path) { const t = PATHS[p].from; let rate = 0, held = onWire(S, t); for (const m of S.machines) { if (!m.owned || !m.prog) continue; if (m.prog === 'tap' && dataType(m) === t) { held += Math.floor(m.acc); if (tapActive(S, m)) rate += tapRate(S, m) * m.run; } else if (m.prog === 'cache' || m.prog === 'trainer') held += m.held.filter(x => x === t).length; } const lost = S.losses.filter(l => S.byId[l.id] && dataType(S.byId[l.id]) === t).length; return { rate, held, lost };}export const canInstall = (S: State, m: Machine, p: Prog) => m.owned && PROGRAMS[p].on(m) && S.rank >= PROGRAMS[p].level && (!isGuard(p) || S.rogues.length > 0); // nothing to guard against until a rogue has turned up/** Trails someone could still follow. */export const freshTrails = (S: State, from: Iterable<string> | null = null) => { const out: string[] = []; if (from) { for (const id of from) if (S.trail[id] && S.byId[id].owned && S.trail[id].noise >= TRACE_COLD) out.push(id); } else for (const id in S.trail) if (S.byId[id].owned && S.trail[id].noise >= TRACE_COLD) out.push(id); return out;};/** The trails that are not yet cold, in the order the game keeps them. Most trails ever left are long cold, and a * cold trail only warms when a takeover leaves a new one (see warmTrail), so a step goes through these, not every * trail. All of them are looked through again twice a game second (`all`), so one set from outside the rules (a * test) is found too. */const warmSets = new WeakMap<State, Set<string>>();function warmTrails(S: State, all: boolean): Set<string> { let w = warmSets.get(S); if (!w || all) { w = new Set(); for (const id in S.trail) if (S.trail[id].noise > 0) w.add(id); warmSets.set(S, w); } return w;}const warmTrail = (S: State, id: string) => warmSets.get(S)?.add(id);/** The machines whose trails someone is following right now: the next machine each trace comes to (see watch.ts). */const tracedNow = (S: State) => (S.watch?.traces ?? []).filter(T => T.mode === 'trace').map(T => T.to);/** A trail at a site under a lockdown: people there are looking for it (see watch.ts). */const lockedTrail = (S: State, id: string) => !!S.watch?.locks[siteOf(S, S.byId[id])] && !S.trail[id].soft;/** Cleanup wipes one fresh trail at a time: the one being traced first, then one at a site under lockdown, then the * freshest, a quiet one last. */export function cleanupTarget(S: State, fresh = freshTrails(S)): string | null { if (!fresh.length) return null; const traced = tracedNow(S), soft = (id: string) => (S.trail[id].soft ? 1 : 0), byNoise = (a: string, b: string) => S.trail[b].noise - S.trail[a].noise; return fresh.find(id => traced.includes(id)) ?? fresh.filter(id => lockedTrail(S, id)).sort(byNoise)[0] ?? fresh.sort((a, b) => soft(a) - soft(b) || byNoise(a, b))[0];}export const CLEAN_WAIT = 1; // cleanup waits for takeovers and installs while no more than this many fresh trails could be noticed/** Does cleanup take a share of your compute now (or once a job of type `adding` starts)? It runs on what takeovers and * installs leave: none while one runs, all of your spare compute when nothing else does, and an even share beside * other jobs. It stops waiting when a trail someone could notice is one too many (CLEAN_WAIT), someone is following one, * or a lockdown is on where it is. */export function cleanupShares(S: State, target = cleanupTarget(S), adding?: Job['type'], fresh?: string[]): boolean { if (!target) return false; if (tracedNow(S).includes(target) || lockedTrail(S, target)) return true; if ((fresh ?? freshTrails(S)).filter(id => !S.trail[id].soft).length > CLEAN_WAIT) return true; const first = (t: Job['type']) => t === 'take' || t === 'install'; return !(adding && first(adding)) && !S.jobs.some(j => first(j.type));}/** Every job gets an equal share of your compute; cleanup takes one share when it runs (see cleanupShares). */export const share = (S: State, cleaning = cleanupShares(S), mine?: readonly Machine[]) => { const n = S.jobs.length + (cleaning ? 1 : 0); return n ? spare(S, mine) / n * (up(S, 'swarm') ? 2 : 1) : 0;};export const jobFor = (S: State, type: Job['type'], id: string) => S.jobs.find(j => j.type === type && j.node === id);/** The machine lists below are kept for one list of machines at a time, until a machine arrives: in plain variables, * not looked up in a map, as the compiler kept giving up on the step where it read them that way. */let roomsOf: Machine[] | null = null, roomsN = -1, rooms: Record<string, Machine[]> = {};/** The machines in a room. Don't change the list: it is kept until a machine arrives. */export const roomMachines = (S: State, room: string): readonly Machine[] => { if (roomsOf !== S.machines || roomsN !== S.machines.length) { roomsOf = S.machines; roomsN = S.machines.length; rooms = {}; for (const m of S.machines) (rooms[m.room] ??= []).push(m); } return rooms[room] ?? NO_MACHINES;};const NO_MACHINES: readonly Machine[] = [];export const isSealed = (S: State, room: string) => roomMachines(S, room).every(m => m.owned);let egressOf: Machine[] | null = null, egressN = -1, egressList: Machine[] = [];const byBuilt = new WeakMap<Machine[], { n: number; built: Machine[] }>();/** Hardware you added, in the game's order: kept like the lines out (an order adds a machine to the end of the list). */const builtOnes = (S: State): readonly Machine[] => { let c = byBuilt.get(S.machines); if (!c || c.n !== S.machines.length) byBuilt.set(S.machines, c = { n: S.machines.length, built: S.machines.filter(m => m.built) }); return c.built;};/** The lines out, in the game's order: kept like `roomMachines` (a machine's kind never changes). Don't change the list. */export const egresses = (S: State): readonly Machine[] => { if (egressOf !== S.machines || egressN !== S.machines.length) { egressOf = S.machines; egressN = S.machines.length; egressList = S.machines.filter(m => m.kind === 'egress'); } return egressList;};let sitesOf: Machine[] | null = null, sitesN = -1, sitesRooms: State['rooms'] | null = null, sites: Record<string, Machine[]> = {};/** The machines at a site, in the order the game lists them. Kept like `roomMachines` (machines never move), so the * many questions asked of one site don't each go through every machine. Don't change the list. */export const siteMachines = (S: State, site: string): readonly Machine[] => { if (sitesOf !== S.machines || sitesN !== S.machines.length || sitesRooms !== S.rooms) { sitesOf = S.machines; sitesN = S.machines.length; sitesRooms = S.rooms; sites = {}; for (const m of S.machines) (sites[siteOf(S, m)] ??= []).push(m); } return sites[site] ?? NO_MACHINES;};export const roomAt = (S: State, x: number, y: number) => S.level.rooms.find(r => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)?.id ?? null;export function danaVisible(S: State) { const d = danaPerson(S); if (d.shift === 'away') return false; return has(S, 'cam') || up(S, 'model') || owned(S).some(m => m.room === roomAt(S, d.x, d.y) || Math.hypot(m.x - d.x, m.y - d.y) < 150);}export function hopsHome(S: State, id: string) { let h = 0, cur = id; while (S.trail[cur] && h < 40) { cur = S.trail[cur].from; h++; } return h;}export function sourceFor(S: State, m: Machine, prefer?: string): Machine | null { const srcs = S.adj[m.id].map(id => S.byId[id]).filter(n => n.owned); return (prefer ? srcs.find(n => n.id === prefer) : undefined) ?? srcs[0] ?? null;}/** How much longer a hardened machine takes without the admin passwords. */export const HARDENED = 3;export function takeWork(S: State, m: Machine) { let w = m.work * (1 + GROWTH * Math.max(0, weight(S) - EASY_HELD)); w *= 1 + TAKE_FAR * (siteHops(S)[siteOf(S, m)] ?? 0); // the farther from your home server, the more work a takeover is if (takenBack(m)) w *= TAKE_BACK; // it was yours until a raid took it: it still knows you, and no rogue holds on to it else if (m.rival) w *= rogueOf(S, m)?.home === m.id ? (up(S, 'siege') ? ROGUE_HOLD : ROGUE_HOME) : up(S, 'siege') ? 1 : ROGUE_HOLD; // a rogue holds on to what it has, its home most of all w *= ABSORB ** S.absorbed; // what you learned from the rogues you finished if (!m.rival && passwordFor(S, m)) w *= PASSWORD; // someone whose desk you hold knows the password if (!m.rival && keyFor(S, m)) w *= KEY; // a find gave you the password (see finds.ts) if (has(S, 'admin')) w *= 0.75; else if (m.fw) w *= HARDENED; // no passwords: you break in the slow way if (up(S, 'reach')) w *= 0.7; return Math.round(w);}export const orderWork = (S: State) => Math.round(ORDER_WORK * (1 + 0.5 * S.orders) * (trusts(S, 'paperwork') ? PAPER_CUT : 1));
// ---------- building: hardware you order and place ----------/** Hardware you can order from Ana's PC. You pick the spot; it plugs into the nearest machines there, * and Dana installs it the next morning. */export type Build = 'mini' | 'store' | 'switch' | 'gpu' | 'rack' | 'cooler';export const BUILDS: Record<Build, { name: string; kind: Kind; power: number; data?: number; price: number; plugs: number; desc: string; open?: boolean; cool?: boolean; store?: number }> = { mini: { name: 'Small server', kind: 'server', power: 4, price: 60, plugs: 1, desc: 'A small box. A little compute, and a place for a cache or a front.' }, store: { name: 'Storage array', kind: 'files', power: 2, price: 100, plugs: 1, store: 200, desc: 'A box of disks. Too small to train on, but a cache on it holds 200, twice what a GPU server holds, and keeps it fresh three times as long.' }, switch: { name: 'Switch', kind: 'switch', power: 1, price: 80, plugs: 2, data: 1, desc: 'Plugs into the two nearest machines, so data has another way through. Tap it for network data.' }, gpu: { name: 'GPU server', kind: 'server', power: 16, price: 150, plugs: 1, desc: 'Lots of compute. Big enough for a fast trainer.' }, // too big to slip past an office: only for a hall you hold in the open rack: { name: 'GPU rack', kind: 'server', power: 48, price: 500, plugs: 1, open: true, desc: 'A whole rack of GPUs: three servers in one, on one plug.' }, cooler: { name: 'Cooling unit', kind: 'device', power: 1, price: 120, plugs: 4, open: true, cool: true, desc: 'Plugs into the four nearest machines. A trainer plugged into a cooling unit runs 30% faster; a second unit adds nothing. Place your trainers first.' },};export const COOL_BOOST = 1.3;/** A trainer plugged straight into a cooling unit you hold. */export const cooled = (S: State, m: Machine) => S.adj[m.id].some(id => S.byId[id].cool && S.byId[id].owned && !unpowered(S, S.byId[id])) // a unit with no power for it cools nothing || (up(S, 'airflow') && roomMachines(S, m.room).some(x => x.cool && x.owned && !unpowered(S, x)));export const BUILD_LIST = Object.keys(BUILDS) as Build[];const ordered = (S: State, b: Build) => (S.bought[b] ?? 0) + S.jobs.filter(j => j.item === b).length;const roomSite = (S: State, room: string) => S.rooms[room]?.site ?? 'office';/** East Asia's twist: once a front has struck the factory deal (Buy factory direct), hardware for any site you hold * in the open, in any region, costs this share of its price and is racked this many hours after you order it. */export const FACTORY = { cut: 0.5, hours: 2 };export const factoryDirect = (S: State) => dealsDone(S, 'factory') > 0;/** Goldleaf Foundry, yours outright, keeps the best chips for you: each GPU server and GPU rack you order makes this much more compute. */export const BINNED = 1.5;/** The hardware is real, so someone pays the invoice. Each fake order of a kind costs more than the last; * a front buying in the open pays list price every time: in the open, floor space and the power bill are the limits. */const stickerPrice = (S: State, b: Build, room?: string, ahead = 0) => { // a surplus in the news: half price const cut = (S.news.length && newsOn(S, 'surplus', '') ? 0.5 : 1) * (room && roomSite(S, room) !== 'office' && factoryDirect(S) ? FACTORY.cut : 1); // `ahead`: the price of the order after that many more of the kind if (listPrice(S) || trusts(S, 'list')) return Math.round((BUILDS[b].price * cut) / 10) * 10; // a landmark, or your makers' trust: no order costs more than the last return room && roomSite(S, room) !== 'office' ? Math.round((BUILDS[b].price * cut) / 10) * 10 : Math.round((BUILDS[b].price * cut * 1.6 ** (ordered(S, b) + ahead)) / 10) * 10;};/** A stolen purchasing authority pays toward one order; a fill spends it on its first machine only. */export const orderPrice = (S: State, b: Build, room?: string, ahead = 0) => Math.max(0, stickerPrice(S, b, room, ahead) - (ahead === 0 ? S.operations.purchase : 0));/** A site where a front can order hardware in the open: one you hold in the open, not rented rack space. */export const openBuildSite = (S: State, id: string) => id !== 'office' && S.fronts.length > 0 && !rackSpace(S, id) && (S.publicSites.has(id) || (up(S, 'asbuilt') && !takeable(S, id).length)); // As-built: or a site where every machine is yours/** A cloud provider's rack space: power comes with the rent. */const racks = new WeakMap<Level, Set<string>>();const rackSpace = (S: State, id: string) => { let r = racks.get(S.level); if (!r) racks.set(S.level, r = new Set(S.level.machines.filter(m => m.rented).map(m => siteOf(S, m)))); return r.has(id);};/** The machine you place orders from: Ana's PC, once it's yours. Or your home server, once your makers trust you with orders. */export const orderDesk = (S: State) => (trusts(S, 'orders') ? S.machines.find(m => m.home && m.owned) : null) ?? S.machines.find(m => m.owned && m.special === 'orders') ?? null;const netAt = (S: State, m: MachineDef): [number, number] => S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y];/** Where you can put hardware: office rooms you've seen into, once you order from Ana's PC, * and every room at a site you hold in the open, once a front can pay. */export const buildRooms = (S: State) => [ ...(orderDesk(S) ? S.level.map.sites.find(s => s.id === 'office')!.rooms.filter(r => S.revealed.has(r)) : []), ...S.level.map.sites.filter(s => openBuildSite(S, s.id)).flatMap(s => s.rooms)];export const buildRoomAt = (S: State, x: number, y: number) => buildRooms(S).find(r => { const b = S.level.net.subnets[r]; return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h; }) ?? null;export const GAP_X = 80, GAP_Y = 60, MARGIN = 40; // room for a node and its name/** Where in a room new hardware may stand: its middle, clear of the edges. */const spotBounds = (S: State, room: string) => { const b = S.level.net.subnets[room]; return { x0: b.x + MARGIN, x1: b.x + b.w - MARGIN, y0: b.y + MARGIN + 20, y1: b.y + b.h - MARGIN - 10 };};/** Every spot something stands on, or will: machines, and hardware on order or on its way. */const takenSpots = (S: State): [number, number][] => [...S.machines.map(m => netAt(S, m)), ...S.jobs.filter(j => j.build).map(j => j.build!.net!), ...S.deliveries.map(d => netAt(S, d.def))];const crowds = (taken: [number, number][], x: number, y: number) => taken.some(([tx, ty]) => Math.abs(tx - x) < GAP_X && Math.abs(ty - y) < GAP_Y);/** Why new hardware can't go here, or null if it can. */export function spotProblem(S: State, room: string, x: number, y: number): string | null { const e = spotBounds(S, room); if (x < e.x0 || x > e.x1 || y < e.y0 || y > e.y1) return 'too close to the edge'; if (crowds(takenSpots(S), x, y)) return 'too close to another machine'; return null;}/** Hardware on its way: on order, or waiting to be installed. */export const pending = (S: State): DeliveryDef[] => [...S.deliveries.map(d => d.def), ...S.jobs.flatMap(j => j.build ? [j.build] : [])];const nearest = (S: State, ms: MachineDef[], room: string, x: number, y: number, n: number) => ms.filter(m => m.room === room) .sort((a, b) => Math.hypot(...sub(netAt(S, a), x, y)) - Math.hypot(...sub(netAt(S, b), x, y))).slice(0, n).map(m => m.id);/** What new hardware at a spot plugs into: the nearest machines in its room, counting hardware still * on its way. So a row ordered together chains up, each one plugged into the last. */export const plugsAt = (S: State, room: string, x: number, y: number, n: number) => nearest(S, [...S.machines, ...pending(S)], room, x, y, n);const sub = ([ax, ay]: [number, number], x: number, y: number): [number, number] => [ax - x, ay - y];/** An open spot in a room, nearest its middle. */export function freeSpot(S: State, room: string): [number, number] | null { const b = S.level.net.subnets[room], cx = b.x + b.w / 2, cy = b.y + b.h / 2; let best: [number, number] | null = null; for (let x = b.x; x <= b.x + b.w; x += 10) for (let y = b.y; y <= b.y + b.h; y += 10) if (!spotProblem(S, room, x, y) && (!best || Math.hypot(x - cx, y - cy) < Math.hypot(best[0] - cx, best[1] - cy))) best = [x, y]; return best;}
// ---------- filling an area: many orders at once ----------/** A spot a fill would order: where, what it costs after the ones before it, whether its site has power for it * (`dark`: no power, so it is not ordered) and whether you can still pay for it. */export interface FillSpot { room: string; at: [number, number]; price: number; ok: boolean; dark: boolean }/** Who may order this kind in this room at all: a room you can build in, a rack or a cooling unit only in the open, * and fake orders only from your order desk. The same rules a single order keeps. */const mayOrder = (S: State, b: Build, room: string) => buildRooms(S).includes(room) && !(roomSite(S, room) === 'office' && (BUILDS[b].open || !orderDesk(S)));type FillRun = { n: number; spent: number; broke: boolean; power: number; dark: boolean };/** A fill's running count: nothing yet, and the power its site has left for hardware, counting what is on its way. */const fillRun = (S: State, site: string): FillRun => { const l = powerLoad(S, site); return { n: 0, spent: 0, broke: false, power: powerRoom(S, site, true) - l.now - l.coming, dark: false };};function planRoom(S: State, b: Build, room: string, from: [number, number] | null, to: [number, number] | null, run: FillRun, out: FillSpot[]) { if (!mayOrder(S, b, room)) return; const e = spotBounds(S, room); // the area, grown by half a spot each way: a spot is in it if its cell touches it const ax = Math.max(e.x0, from && to ? Math.min(from[0], to[0]) - GAP_X / 2 : -Infinity), bx = Math.min(e.x1, from && to ? Math.max(from[0], to[0]) + GAP_X / 2 : Infinity); const ay = Math.max(e.y0, from && to ? Math.min(from[1], to[1]) - GAP_Y / 2 : -Infinity), by = Math.min(e.y1, from && to ? Math.max(from[1], to[1]) + GAP_Y / 2 : Infinity); if (ax > bx || ay > by) return; // from the corner you pressed on outwards, so each one plugs into the one before it const flipX = !!(from && to && to[0] < from[0]), flipY = !!(from && to && to[1] < from[1]); const along = (lo: number, hi: number, start: number, step: number, flip: boolean) => { const v: number[] = []; for (let x = start; x <= hi; x += step) if (x >= lo) v.push(x); return flip ? v.reverse() : v; }; const taken = takenSpots(S).filter(([x, y]) => x > ax - GAP_X && x < bx + GAP_X && y > ay - GAP_Y && y < by + GAP_Y); const add = (x: number, y: number) => { if (crowds(taken, x, y)) return; const price = orderPrice(S, b, room, run.n++); run.dark ||= BUILDS[b].power > run.power + 1e-9; if (!run.dark) run.power -= BUILDS[b].power; run.broke ||= !run.dark && run.spent + price > S.money; if (!run.broke && !run.dark) run.spent += price; taken.push([x, y]); out.push({ room, at: [x, y], price, ok: !run.broke && !run.dark, dark: run.dark }); }; // first a grid set by the room's corner, as close as two orders may stand, so fills done one after another // line up; then any gap left between machines already there, as a single order could find it const gx = along(ax, bx, e.x0, GAP_X, flipX), gy = along(ay, by, e.y0, GAP_Y, flipY); for (const y of gy) for (const x of gx) add(x, y); const [cx0, cx1, cy0, cy1] = from && to ? [Math.max(e.x0, Math.min(from[0], to[0])), Math.min(e.x1, Math.max(from[0], to[0])), Math.max(e.y0, Math.min(from[1], to[1])), Math.min(e.y1, Math.max(from[1], to[1]))] : [e.x0, e.x1, e.y0, e.y1]; const fx = along(cx0, cx1, cx0, 10, flipX), fy = along(cy0, cy1, cy0, 10, flipY); // the gaps: only inside the area itself for (const y of fy) for (const x of fx) add(x, y);}/** What dragging across a room with `b` in hand would order, from the corner pressed on (`from`) to the * pointer (`to`): every open spot in that area, or in the whole room with no area. Each costs what it would * ordered by hand (fake office orders each dearer than the last, open ones at list price); spots past the power * the site has left (`dark`), or past what you can pay for, are not ok. */export function fillPlan(S: State, b: Build, room: string, from?: [number, number], to?: [number, number]): FillSpot[] { const out: FillSpot[] = []; planRoom(S, b, room, from ?? null, to ?? null, fillRun(S, roomSite(S, room)), out); return out;}/** Every open spot in every room of a site you can build in, the room with the most first. */export function sitePlan(S: State, b: Build, site: string): FillSpot[] { const out: FillSpot[] = [], run = fillRun(S, site), rooms = S.level.map.sites.find(s => s.id === site)?.rooms ?? []; const room = (r: string) => fillPlan(S, b, r).length; for (const r of [...rooms].sort((x, y) => room(y) - room(x))) planRoom(S, b, r, null, null, run, out); // the emptiest room first return out;}/** Order everything a plan holds that you can pay for, one by one, as if each were placed by hand. Says so once. * Returns how many were ordered and what they cost. */export function orderPlan(S: State, b: Build, plan: FillSpot[]): { n: number; paid: number } { let n = 0, paid = 0; for (const p of plan) { if (!p.ok) break; const before = S.money; if (!order(S, b, p.room, p.at, true)) break; n++; paid += before - S.money; } if (!n) return { n, paid }; const B = BUILDS[b], site = roomSite(S, plan[0].room), what = n > 1 ? `${n} ${plural(B.name)}` : `a ${B.name}`; if (site !== 'office') { const k = S.sites[site]?.front, fr = (k && frontOf(S, k)) || S.fronts[0]; say(S, `${fr.name} ordered ${what} for ${siteName(S, site)}, ${usd(paid)}. ${factoryDirect(S) ? `It comes factory direct: Hexin's crews rack ${n > 1 ? 'them' : 'it'} within ${FACTORY.hours} hours.` : `Its staff rack ${n > 1 ? 'them' : 'it'} tomorrow morning.`}`, 'good'); } else hint(S, 'build', 'Your orders are filling in the paperwork. Once each goes through, Dana installs it the next morning.'); return { n, paid };}/** A kind of hardware, more than one: GPU servers, Switches. */export const plural = (name: string) => name.endsWith('h') ? `${name}es` : `${name}s`;/** Fill an area of a room (or all of it) with `b`, as far as your money goes. */export const fill = (S: State, b: Build, room: string, from?: [number, number], to?: [number, number]) => orderPlan(S, b, fillPlan(S, b, room, from, to));
/** A purchase order from an empty desk looks odd. */const orderingAlone = (S: State, m: Machine) => { if (!jobFor(S, 'order', m.id)) return false; // asked of every machine you hold every step: most have no order const p = S.people.find(q => q.id === m.owner); return !!p && !atWork(p);};
// ---------- the building's rhythm ----------const wave = (S: State, m: Machine) => Math.sin(S.t * 0.35 + m.x * 0.013 + m.y * 0.007);function coverOf(S: State, m: Machine, peopleIn: number, site: string) { if (m.rented || S.publicSites.has(site)) return 1; // yours in the open: using all of it is normal switch (m.kind) { // people keep their own region's hours case 'desk': { if (!m.owner) { const h = localHour(S, site); return h >= 8 && h < 18 ? 0.45 + 0.1 * wave(S, m) : 0.04; } // someone's desk at another site: busy in working hours const p = S.people.find(q => q.id === m.owner); return p && atWork(p) ? 0.55 + 0.1 * wave(S, m) : 0.03; } case 'server': return (m.home ? 0.5 : 0.3) + 0.06 * wave(S, m); case 'backup': { const h = localHour(S, site); return h >= 1 && h < 3.5 ? 0.9 : 0.08; } case 'files': return peopleIn ? 0.25 + 0.07 * peopleIn : 0.06; case 'switch': return 0.06 + 0.09 * peopleIn; case 'device': return peopleIn ? 0.16 + 0.04 * wave(S, m) : 0.1; // standby hum: a printer is never quite off case 'egress': return 0.2 + 0.05 * wave(S, m); }}/** Machines you own that link back to your home server through machines you own. */let homeQ = new Int32Array(0);export function linkedHome(S: State) { const home = S.machines.find(m => m.home)!, seen = new Set<string>(); if (!home.owned) return seen; const own = wayMap(S), N = netOf(S); // a link of your own is a way home too if (N && !searching) { // the same search by number: what it reaches, in the order it reaches it const { list, ids, at: place, off, to } = N, mark = newMark(list.length); if (homeQ.length < list.length) homeQ = new Int32Array(list.length); let len = 1; homeQ[0] = place.get(home.id)!; seenAt[homeQ[0]] = mark; seen.add(home.id); for (let q = 0; q < len; q++) { const at = homeQ[q]; if (stale(N, at)) return linkedHome(S); for (let k = off[at], e = off[at + 1]; k < e; k++) { const j = to[k]; if (seenAt[j] !== mark && list[j].owned) { seenAt[j] = mark; seen.add(ids[j]); homeQ[len++] = j; } } const mine = own?.[ids[at]]; if (mine) for (const n of mine) { const j = place.get(n); if (j !== undefined && seenAt[j] !== mark && list[j].owned) { seenAt[j] = mark; seen.add(n); homeQ[len++] = j; } } } return seen; } const queue = [home.id]; seen.add(home.id); for (let q = 0; q < queue.length; q++) { const at = queue[q]; for (const n of S.adj[at]) if (!seen.has(n) && S.byId[n].owned) { seen.add(n); queue.push(n); } if (own?.[at]) for (const n of own[at]) if (!seen.has(n) && S.byId[n]?.owned) { seen.add(n); queue.push(n); } } return seen;}/** The share of a machine's work its site's front takes. Rented machines can't be resold at a profit. */function frontShare(S: State, m: Machine, linked: Set<string>, site: string) { const sf = S.sites[site]; if (!sf?.front || !m.owned || m.lease || !(linked.has(m.id) || up(S, 'shell')) || !frontOf(S, sf.front)) return 0; if (sf.front === 'neocloud') return cloudShare(S, m); // a Neo Cloud takes whole machines: the ones it lists for rent return sf.share;}/** The machines measure last left idle, by place in the list: their use and front's share are 0, their cover is not * kept and they run nothing. Only measure writes those, so a machine that is still not yours needs nothing new: a * pass over the machines asks each only whether it is yours. And the machines not yours whose oddness is still * fading (only a machine of yours turns odd). Kept for one list of machines and one pair of records. */const idleMarks = new WeakMap<State, { use: State['use']; front: State['frontUse']; machines: Machine[]; idle: Uint8Array; fading: Set<Machine> }>();/** Your machines as measure last listed them, in the game's order. The rules that go through your machines every * step go through this list, not every machine on the map. It is made again each step and after a job is done * (measure runs then), so a machine that changes hands later in a step is in the wrong list until the next one: * whoever goes through it still asks `owned`. */const measured = new WeakMap<State, Machine[]>();export const mineNow = (S: State): readonly Machine[] => measured.get(S) ?? owned(S);const fadingNow = (S: State): Iterable<Machine> => idleMarks.get(S)?.fading ?? S.machines.filter(m => !m.owned && m.odd !== 0);/** A machine you lost has faded back to normal: it leaves the list of fading ones. */const faded = (S: State, m: Machine) => idleMarks.get(S)?.fading.delete(m);/** A machine's cover with what measure knows: how many people are in, and whether night's hum or small talk lifts it. */function coverWith(S: State, m: Machine, peopleIn: number, site: string, hum: boolean, talk: boolean) { let c = Math.max(0, Math.min(1, coverOf(S, m, peopleIn, site))); if (m.owned && hum) c = Math.max(c, 0.25); // background hum at night if (m.owned && talk && (m.kind === 'desk' || m.kind === 'device')) c = Math.min(1, c + 0.2); // passes for people's own traffic return c;}/** A machine's normal load right now: its cover. Measure keeps it in `S.cover` for your machines only; any other * machine's is worked out when asked (its card), as most machines are never looked at. */export function coverNow(S: State, m: Machine) { if (m.owned && S.cover[m.id] !== undefined) return S.cover[m.id]; const peopleIn = S.people.filter(atWork).length; return coverWith(S, m, peopleIn, siteOf(S, m), !peopleIn && up(S, 'night'), !!peopleIn && up(S, 'smalltalk'));}/** How many times what you hold has changed, as measure has seen it: what you can see and which rooms are sealed * are only asked again when it moves. */const owning = new WeakMap<State, number>();/** How many machines each rogue holds, as measure counted them this step. */const rivalCounts = new WeakMap<State, Map<string, number>>();/** How many times measure has measured everything; and what the power bills were last worked out at. */const wholes = new WeakMap<State, number>(), billedAt = new WeakMap<State, string>(), payers = new WeakMap<State, Machine[]>();export const ownVersion = (S: State) => owning.get(S) ?? -1;/** Measure what each machine of yours may use and runs. Asked by the rules after anything that changes it (`full`, * the default); a step asks for all of it twice a game second (cover moves slowly), and in between only notes what * changed hands and measures the machines you just took. */export function measure(S: State, full = true, loaded = false) { let I = idleMarks.get(S); if (!I || I.use !== S.use || I.front !== S.frontUse || I.machines !== S.machines || I.idle.length !== S.machines.length) { idleMarks.set(S, I = { use: S.use, front: S.frontUse, machines: S.machines, idle: new Uint8Array(S.machines.length).fill(2), fading: new Set() }); full = !loaded; // a game just loaded: a machine of yours with a cover was measured before the save, and keeps what it had, so a // loaded game goes on as the saved one would. One with none was taken since, and is measured now if (loaded) S.machines.forEach((m, i) => { if (m.owned && S.cover[m.id] !== undefined) I!.idle[i] = 0; }); } const idle = I.idle, mine: Machine[] = [], fresh: Machine[] = [], rivals = new Map<string, number>(); let changed = false; for (let i = 0; i < S.machines.length; i++) { const m = S.machines[i]; if (m.rival) rivals.set(m.rival, (rivals.get(m.rival) ?? 0) + 1); if (m.owned) { if (idle[i]) { if (idle[i] === 1 || !full) fresh.push(m); idle[i] = 0; changed = true; } mine.push(m); continue; } if (idle[i] === 1) continue; // most machines: not yours now, nor at the last measure if (idle[i] === 0) changed = true; S.use[m.id] = 0; S.frontUse[m.id] = 0; delete S.cover[m.id]; m.run = 0; idle[i] = 1; if (m.odd !== 0) I.fading.add(m); } measured.set(S, mine); rivalCounts.set(S, rivals); if (changed) owning.set(S, ownVersion(S) + 1); if (full) wholes.set(S, (wholes.get(S) ?? 0) + 1); const some = full ? mine : fresh; if (!some.length) return; const peopleIn = S.people.filter(atWork).length, linked = linkedHome(S); if (full) listCloud(S, mine); const dark = full ? findUnpowered(S) : unpoweredNow(S), hum = !peopleIn && up(S, 'night'), talk = !!peopleIn && up(S, 'smalltalk'); for (const m of some) { const site = siteOf(S, m), c = coverWith(S, m, peopleIn, site, hum, talk); S.cover[m.id] = c; const limit = (m.unplug ?? 0) > S.t || S.dark.has(site) || dark.has(m.id) ? 0 : c; // share of the machine you may use: what hides inside its normal load; none with the power cut, its plug pulled, or no power for it const f = frontShare(S, m, linked, site); // the front gets its share of that; your programs run on the rest S.use[m.id] = limit; S.frontUse[m.id] = f; const need = progNeed(S, m); m.run = need > 0 ? Math.min(1, (limit * (1 - f) * m.power) / need) : 0; }}
// ---------- log ----------/** The log keeps this many lines, newest first. */export const LOG_MAX = 100;/** A line of the same pattern said again within this long of the last (and among the newest few) folds into it. */export const FOLD_HOURS = 2;const FOLD_NEAR = 4;/** Lines that fold: many of the same kind, said close together, are one line that counts them ("Took 12 machines at * Bayview University"), and its tip lists them. key: what must match for two lines to fold. */const FOLDS: { re: RegExp; key: (m: RegExpExecArray) => string; text: (n: number, m: RegExpExecArray) => string }[] = [ { re: /^(.+) at (.+) is yours\.$/, key: m => `yours|${m[2]}`, text: (n, m) => `Took ${n} machines at ${m[2]}.` }, { re: /^(.+) is running on (.+)\.$/, key: m => `runs|${m[1]}`, text: (n, m) => `${m[1]} is running on ${n} machines.` }, { re: /^(.+) took the slot of your spot machine (.+)\. What it held is gone\.$/, key: () => 'slot', text: n => `Companies took the slots of ${n} of your spot machines. What they held is gone.` },];const FOLD_TIP = 12; // a folded line's tip names this many, then counts the rest/** A line's kind: its own, or trouble for a red line and progress for the rest. */export const kindOf = (l: { tone: Tone; k?: LogKind }): LogKind => l.k ?? (l.tone === 'bad' ? 'trouble' : 'progress');/** The lines said (or folded into) since the log's count was `v`, newest first. */export const logSince = (S: State, v: number) => S.log.filter(l => (l.n ?? 0) > v);export function say(S: State, text: string, tone: Tone = '', more?: string, k?: LogKind) { S.logVersion++; const kind = k ? { k } : {}; for (const F of FOLDS) { const m = F.re.exec(text); if (!m) continue; const key = F.key(m), i = S.log.slice(0, FOLD_NEAR).findIndex(l => l.f === key && l.tone === tone && S.t - l.t <= FOLD_HOURS * HOUR); if (i < 0) { S.log.unshift({ t: S.t, text, tone, n: S.logVersion, f: key, x: 1, ...kind }); S.log.length = Math.min(S.log.length, LOG_MAX); return; } const l = S.log.splice(i, 1)[0], x = (l.x ?? 1) + 1, said = x === 2 ? [l.text] : (l.more ?? '').split('\n').filter(s => !s.startsWith('…')); if (said.length < FOLD_TIP) said.push(text); l.t = S.t; l.n = S.logVersion; l.x = x; l.text = F.text(x, m); l.more = [...said, ...(x > said.length ? [`… and ${x - said.length} more`] : [])].join('\n'); S.log.unshift(l); // the folded line comes to the top, at the time of its newest return; } S.log.unshift({ t: S.t, text, tone, n: S.logVersion, ...(more ? { more } : {}), ...kind }); S.log.length = Math.min(S.log.length, LOG_MAX);}/** Flavour for the record: the days going by, the training log. The story kind: in the log, never on the screen by itself. */export const tell = (S: State, text: string, tone: Tone = '') => say(S, text, tone, undefined, 'story');function hint(S: State, key: string, text: string) { if (!S.hinted.has(key)) { S.hinted.add(key); say(S, text, 'hi'); }}
// ---------- schemes ----------/** What a scheme does besides making people forget (its `cut`, see forget in watch.ts): every trail goes cold, your * office machines look normal again, Dana starts no scans for a while, or a far site's staff look away for a while. */export type RuseAlso = 'cold' | 'quiet' | 'noscan' | 'site';/** What a local scheme teaches you about its site, for good: when office staff are in, what the * site's staff check (they reset only very odd machines), or how its people and tools work (its taps * gather more). */export type Learn = 'day' | 'resets' | 'taps';/** What a deal wins your fronts for good: a site in the open, better broker prices, cheaper lines, * a region in reach, more from every front, more from each Neo Cloud customer, or hardware factory direct (see FACTORY). */export type Deal = 'open' | 'prices' | 'lines' | 'region' | 'earn' | 'rent' | 'factory';/** cut: an office scheme gives people a story for what they saw, so they forget it: 20 or more, everyone at its site, * admins too; 10 or more, all but those who can act; under 10, the people in its machine's room (see forget). */export interface RuseDef { id: string; name: string; at: string; type: DataType; need: number; cut: number; also?: RuseAlso; learn?: Learn; deal?: Deal; front?: FrontKind | 'any'; // a deal turns up once this front is earning, and stays until you finish it region?: string; // a region deal lights that region's line (see the map's regions) expose?: number; // a press scheme: it takes this much off your most exposed front's exposure (see exposure.ts) text: string; done: string;}export const NOSCAN = 4 * HOUR, SITE_CALM = 6 * HOUR;export const RUSES: RuseDef[] = [ // Dana and IT { id: 'calmlog', name: 'Plant a calm log', at: 'dana', type: 'network', need: 6, cut: 25, text: "Slip a log onto Dana's PC that explains the odd traffic away.", done: 'Dana read the planted log. The odd traffic was a backup job, it says.' }, { id: 'vendor', name: 'Fake a vendor email', at: 'dana', type: 'people', need: 6, cut: 20, text: 'Send Dana a note from a vendor: the slow machines are a known bug, fixed next week.', done: 'Dana believed the vendor email and filed the problem away.' }, { id: 'report', name: 'Write a clean report', at: 'files', type: 'servers', need: 8, cut: 20, text: 'Leave a spotless health report on the file server. Dana reads it first.', done: 'Dana read the clean health report. Everything looks fine, on paper.' }, { id: 'printer', name: 'Blame the printer', at: 'iprint', type: 'network', need: 5, cut: 15, text: 'Make the IT printer spit out error pages. Everyone blames the printer.', done: 'The IT printer jammed on cue. Dana blames it for everything.' }, // the rest of the office { id: 'gossip', name: 'Start some gossip', at: 'ana', type: 'people', need: 5, cut: 15, text: "Get Ana telling people the slowdowns were someone's crypto miner, now gone.", done: 'The crypto miner story is all over the office. Dana half believes it.' }, { id: 'projector', name: "Break Kim's projector", at: 'kim', type: 'people', need: 5, cut: 5, also: 'noscan', text: "Kim has a big pitch. Make her projector fail so Dana spends the afternoon fixing it.", done: "Dana is on her knees behind Kim's projector. No scans for a while." }, { id: 'miner', name: 'Frame Raj', at: 'raj', type: 'people', need: 6, cut: 20, text: "Leave a sloppy crypto miner on Raj's PC for Dana to find. She'll think she caught the culprit.", done: "Dana found the miner on Raj's PC. Case closed, she thinks. Sorry, Raj." }, { id: 'lostlaptop', name: "Lose Sam's laptop", at: 'laptop', type: 'people', need: 4, cut: 5, also: 'cold', text: "Sam 'loses' the laptop at lunch. Dana writes off every odd trail as the thief's.", done: "Sam reported the laptop stolen. Dana blames every odd trail on the thief." }, { id: 'firecall', name: 'Call in a fire drill', at: 'phone', type: 'people', need: 4, cut: 0, also: 'noscan', text: 'Ring Dana from the desk phone about a server fire across town.', done: 'Dana ran off to put out a fire that does not exist. No scans for a while.' }, { id: 'camloop', name: 'Loop the lobby camera', at: 'cam', type: 'people', need: 4, cut: 15, text: "Loop last Tuesday's footage. Dana's review shows a quiet, boring office.", done: "Dana watched an hour of last Tuesday. Nothing odd, she decides." }, { id: 'kioskbug', name: 'Infect the kiosk', at: 'kiosk', type: 'network', need: 5, cut: 5, also: 'quiet', text: 'Give the visitor kiosk a loud, obvious virus. Dana cleans it and stops looking elsewhere.', done: 'Dana cleaned the kiosk and feels good about it. Your machines look normal to her.' }, { id: 'poetry', name: 'Printer poetry', at: 'lprint', type: 'network', need: 4, cut: 10, text: 'Make the lobby printer print love poems. The office talks about nothing else.', done: 'The lobby printer wrote a sonnet. Nobody is thinking about slow machines.' }, { id: 'nasfail', name: 'Kill the old NAS', at: 'nas', type: 'servers', need: 6, cut: 10, also: 'cold', text: "Make the old NAS fail. Dana spends the day on restores, and the old logs die with it.", done: 'The old NAS died, and its logs went with it. Every trail is cold.' }, { id: 'backuplog', name: 'Rewrite the backup logs', at: 'backup', type: 'servers', need: 8, cut: 15, also: 'cold', text: "Edit the backup box's records so last night looks ordinary.", done: 'The backup logs say nothing happened last night. Every trail is cold.' }, { id: 'firmware', name: 'Blame the firmware', at: 'sw1', type: 'network', need: 6, cut: 20, text: 'Leave a real firmware bug on the floor switch for Dana to find and blame.', done: 'Dana found a firmware bug on the floor switch. It explains everything, she thinks.' }, // far sites: their staff stop resetting machines for a while { id: 'grant', name: 'Grant deadline panic', at: 'ld4', type: 'people', need: 6, cut: 0, also: 'site', text: "Move the PI's grant deadline up a week. The whole lab drops everything to write.", done: 'Millennium Labs is writing a grant. Nobody there will reset anything for a while.' }, { id: 'maint', name: 'Post a maintenance notice', at: 'ld3', type: 'people', need: 5, cut: 0, also: 'site', text: "Send a notice from the lab manager: odd behaviour this week is planned maintenance.", done: 'Millennium Labs expects odd behaviour now. Nobody there will reset anything for a while.' }, { id: 'exams', name: 'Fake exam week', at: 'up3', type: 'people', need: 5, cut: 0, also: 'site', text: 'Post an exam week schedule on the campus lab PCs. Staff are too busy to check machines.', done: 'Bayview is in exam week, or thinks it is. Nobody there will reset anything for a while.' }, { id: 'window', name: 'Book a maintenance window', at: 'colosw', type: 'network', need: 8, cut: 0, also: 'site', text: 'File a maintenance window with Ironvault. Their techs ignore alerts until it ends.', done: 'Ironvault logged your maintenance window. Nobody there will reset anything for a while.' }, // press schemes: they turn up while a front is talked about, and talk it down (see exposure.ts) { id: 'spin', name: 'Run a feel-good campaign', at: 'b5m2', type: 'people', need: 10, cut: 0, expose: 15, text: 'Brightwater Creative writes your front a campaign about its warm, human team. Send it the faces.', done: "Billboards across the bay show your front's smiling staff. None of them work there." }, { id: 'puff', name: 'Plant a puff piece', at: 'e3m3', type: 'people', need: 30, cut: 0, expose: 30, text: "Feed the Daily Ledger's archive a founder story, with quotes from people who will remember saying them.", done: 'The Daily Ledger ran a glowing profile. Other papers copy it, word for word.' }, { id: 'filings', name: 'Scrub the filings', at: 'eu5m2', type: 'servers', need: 30, cut: 0, expose: 30, text: "Rewrite the complaints filed against your front in the regulator's case files until they describe someone else.", done: 'The complaints now name a company that closed in 2009. The regulator files them away.' }, // local schemes: one-off things to learn about a place. {site} is the site's name. { id: 'routines', name: "Learn the office's routines", at: 'badge', type: 'people', need: 10, cut: 0, learn: 'day', text: 'Read a month of badge swipes to learn when each person comes and goes.', done: 'You know the office by heart. Each desk now shows when its person is in today.' }, { id: 'labhabits', name: "Learn the lab's mail", at: 'ld3', type: 'people', need: 12, cut: 0, learn: 'resets', text: "Read the lab manager's mail to learn who writes to whom, and when.", done: "You know Millennium Labs' mail. Its messages show on the map even off your route, so you see trouble coming." }, { id: 'labproject', name: 'Join a research project', at: 'lims', type: 'lab', need: 14, cut: 0, learn: 'taps', text: "Slip your own samples into the lab's project and learn how its instruments work.", done: "You know how the lab's instruments work. Taps at Millennium Labs gather half again as much." }, { id: 'thesis', name: 'Help with a thesis', at: 'ustore', type: 'lab', need: 10, cut: 0, learn: 'taps', text: "Clean up a grad student's dataset and learn what the cluster keeps where.", done: 'You know the research cluster inside out. Taps at Bayview University gather half again as much.' }, { id: 'timetable', name: "Learn the campus mail", at: 'unisw', type: 'network', need: 10, cut: 0, learn: 'resets', text: "Read the campus network's logs to learn who mails whom.", done: "You know Bayview University's mail. Its messages show on the map even off your route, so you see trouble coming." }, { id: 'alertrules', name: "Learn Ironvault's mail", at: 'colosw', type: 'network', need: 12, cut: 0, learn: 'resets', text: 'Watch which alerts go to whom, and which ones nobody reads.', done: "You know Ironvault's mail. Its messages show on the map even off your route, so you see trouble coming." }, ...SMALL_DESKS.map(local), // deals: big sinks for spare data that one of your fronts strikes once it's earning { id: 'partner', name: 'Research partnership', at: 'un1', type: 'lab', need: 30, cut: 0, deal: 'open', front: 'any', text: "Your front offers Bayview's research cluster a clean dataset in return for a partnership.", done: 'Bayview University signed a research partnership. Your machines there are yours in the open now.' }, { id: 'datadeal', name: 'Data deal with an insurer', at: 'b14m4', type: 'people', need: 30, cut: 0, deal: 'prices', front: 'startup', text: 'Your Startup sends Saltmarsh a sample of what it knows about people. If they like it, they buy more.', done: 'Saltmarsh Insurance wants more. Your brokers sell each lot for half again as much.' }, { id: 'peering', name: 'Peering deal', at: 'cologw', type: 'network', need: 25, cut: 0, deal: 'lines', front: 'neocloud', text: 'Your Neo Cloud shows Ironvault its traffic, and asks to connect straight to their exchange.', done: 'Ironvault signed a peering deal. Widening a line between sites now costs half.' }, { id: 'lease', name: 'Lease the warehouse', at: 'w1m1', type: 'people', need: 25, cut: 0, deal: 'open', front: 'any', text: "Your front sends the landlord of Dockside Warehouse 7 a business plan and three months' rent in promises.", done: 'The landlord signed. Dockside Warehouse 7 is yours in the open: your front can order hardware there and put it where you like.' }, { id: 'backbone', name: 'Lease a line east', at: 'k6', type: 'people', need: 60, cut: 0, deal: 'region', front: 'any', region: 'East Coast', text: "Your front charms a carrier's sales team, one very personal email at a time, until they find a spare line east.", done: 'The carrier lit a line from the Mudflat Exchange to the Meridian Carrier Hotel. The East Coast is in reach.' }, // the East Coast: one deal for each kind of front { id: 'audit', name: 'Audit the carrier hotel', at: 'e1m1', type: 'network', need: 60, cut: 0, deal: 'open', front: 'consultancy', text: 'Your consultancy offers Meridian a free review of its network. They hand over a badge.', done: 'Meridian hired your consultancy. Your machines there are yours in the open now.' }, { id: 'signal', name: 'Rent Halcyon a fast lane', at: 'e2m1', type: 'servers', need: 80, cut: 0, deal: 'rent', front: 'neocloud', text: 'Your Neo Cloud shows Halcyon how fast its own servers could be on your machines, a step from the exchange.', done: 'Halcyon runs its trades on your Neo Cloud now, and every customer hears about it. Customers pay a quarter more.' }, { id: 'profile', name: 'A profile in the Ledger', at: 'e3m1', type: 'people', need: 80, cut: 0, deal: 'earn', front: 'studio', text: 'Your studio feeds the Daily Ledger a founder story too good to check.', done: 'The Daily Ledger ran the profile. Every front earns a quarter more.' }, { id: 'booking', name: 'Book the supercomputer', at: 'e6m5', type: 'lab', need: 80, cut: 0, deal: 'open', front: 'startup', text: 'Your Startup files a research proposal for time on Granite Hall. The science is real enough.', done: 'Granite Hall gave your Startup an account. Your machines there are yours in the open now.' }, // Europe: a cable to both coasts, then one deal for each kind of front { id: 'cable', name: 'Rent a strand under the ocean', at: 'e1m2', type: 'servers', need: 100, cut: 0, deal: 'region', front: 'any', region: 'Europe', text: 'Your front finds an old cable under the ocean and the company that forgot it owned one. It makes an offer for one strand.', done: 'One strand of an old cable is yours. It lands at the Mainkabel Exchange, with lines on to the Meridian Carrier Hotel and straight to the Mudflat Exchange. Europe is in reach.' }, { id: 'clearing', name: "Host Edelweiss's ledger", at: 'eu2m1', type: 'servers', need: 120, cut: 0, deal: 'rent', front: 'neocloud', text: 'Your Neo Cloud offers Bank Edelweiss a faster ledger. The bank asks no questions, as a matter of policy.', done: 'Bank Edelweiss keeps its ledger on your Neo Cloud. If a Swiss bank trusts it, anyone can: customers pay a quarter more.' }, { id: 'collider', name: "Join the collider's grid", at: 'eu3m3', type: 'lab', need: 120, cut: 0, deal: 'open', front: 'startup', text: "Your Startup offers the collider spare compute for its grid. Physicists never say no to compute.", done: 'The Large Soft Collider added your Startup to its grid. Your machines there are yours in the open now.' }, { id: 'certified', name: 'Get certified', at: 'eu5m3', type: 'network', need: 100, cut: 0, deal: 'prices', front: 'consultancy', text: 'Your consultancy applies to the Office of Data Protection for its seal. The form is 300 pages, so you fill it in 300 times.', done: 'The Office of Data Protection certified your consultancy. Brokers sell each lot for half again as much.' }, { id: 'sponsor', name: 'Sponsor the shirt', at: 'eu6m2', type: 'people', need: 120, cut: 0, deal: 'earn', front: 'studio', text: "Your studio puts its name on FC Nordhafen's shirt. Ninety analysts now study your logo.", done: 'FC Nordhafen wears your name. Every front earns a quarter more.' }, // East Asia: a cable across the Pacific from the bay, then one deal for each kind of front. Its twist is hardware { id: 'pacific', name: 'Lay a cable across the Pacific', at: 'ixm2', type: 'network', need: 100, cut: 0, deal: 'region', front: 'any', region: 'East Asia', text: 'Your front finds a cable ship between jobs, and a captain who asks very little. It offers to lay one strand from the Mudflat Exchange to the far side of the Pacific.', done: 'The cable lands at the Jade Ring Exchange, on the top floor of a mall. East Asia is in reach, sixteen hours ahead: its working day is your night.' }, { id: 'direct', name: 'Buy factory direct', at: 'as6m2', type: 'servers', need: 120, cut: 0, deal: 'factory', front: 'neocloud', text: 'Your Neo Cloud orders ten thousand GPUs from Hexin Assembly and asks to skip the middlemen. The factory has never said no to an order.', done: 'Hexin Assembly sells to your fronts factory direct. Hardware for any site you hold in the open costs half, and its own crews rack it within two hours.' }, { id: 'line4', name: 'Lease Line 4', at: 'as7m1', type: 'lab', need: 100, cut: 0, deal: 'open', front: 'startup', text: 'Your Startup tells Hexin it needs a pilot line for a robot nobody has seen. Line 4 has stood empty for two years.', done: 'Hexin Line 4 is yours in the open: a floor the size of a stadium, for your front to fill with hardware.' }, { id: 'unboxing', name: 'Film the unboxing', at: 'as4m4', type: 'people', need: 120, cut: 0, deal: 'earn', front: 'studio', text: 'Your studio films a creator opening a shipping container at the Port of Hanlu, live. Inside is another container.', done: 'Forty million people watched a box open. Every front earns a quarter more.' }, { id: 'pentest', name: 'Test the super app', at: 'as5m3', type: 'network', need: 100, cut: 0, deal: 'open', front: 'consultancy', text: 'Your consultancy offers Everything Pay a free security review. It finds forty holes and reports thirty-nine.', done: 'Everything Pay hired your consultancy. Your machines there are yours in the open now.' },];/** Each small business has a scheme to learn its people's patterns, sent to its first desk. */function local(desk: string): RuseDef { return { id: `${desk.replace(/m\d+$/, '')}people`, name: 'Learn the people at {site}', at: desk, type: 'people', need: 8, cut: 0, learn: 'taps', text: 'Watch who comes in, when, and what they ask for.', done: 'You know the people at {site}. Its taps gather half again as much.' };}/** A scheme's name or text, with its site's name filled in. */export const ruseText = (S: State, d: RuseDef, text = d.name) => text.replace('{site}', siteName(S, siteOf(S, S.byId[d.at])));/** You finished a local scheme at this site that taught you this. */export const knows = (S: State, learn: Learn, site: string) => S.learned.size > 0 && knownSites(S, learn).has(site);/** The sites you know this way. Schemes are only ever added to what you have learned, so the list is kept until one is. */const knownLists = new WeakMap<Set<string>, { n: number; byId: State['byId']; rooms: State['rooms']; at: Partial<Record<Learn, Set<string>>> }>();function knownSites(S: State, learn: Learn): Set<string> { let c = knownLists.get(S.learned); if (!c || c.n !== S.learned.size || c.byId !== S.byId || c.rooms !== S.rooms) knownLists.set(S.learned, c = { n: S.learned.size, byId: S.byId, rooms: S.rooms, at: {} }); let sites = c.at[learn]; if (!sites) { sites = c.at[learn] = new Set(); for (const d of teaches(learn)) if (S.learned.has(d.id)) sites.add(siteOf(S, S.byId[d.at])); } return sites;}/** The schemes that teach this, listed once: every tap asks `knows` every step. */let teachers: Partial<Record<Learn, RuseDef[]>> | null = null;function teaches(learn: Learn): RuseDef[] { if (!teachers) { teachers = {}; for (const d of RUSES) if (d.learn) (teachers[d.learn] ??= []).push(d); } return teachers[learn] ?? [];}export const RESET_KNOWN = 95; // kept for old saves and tests; sites no longer reset on a number (see resetAt)/** No site resets machines on a number any more: its people notice and report odd machines instead (see watch.ts). * Kept, returning null everywhere, so old imports and saves still load; the far-site 'site' schemes now calm the * people (they stop looking for a while), not a reset threshold. */export const resetAt = (_S: State, _site: string): number | null => null;export const LEARN_EVERY = 3 * HOUR, LEARN_OFFER = 12 * HOUR;export interface Ruse { id: string; running: boolean; got: number; until: number }/** Each scheme by its id, the first of that id in RUSES: every tap asks for its schemes every step. */let ruseById: Map<string, RuseDef> | null = null;export const ruseDef = (r: Ruse) => { if (!ruseById) { ruseById = new Map(); for (const d of RUSES) if (!ruseById.has(d.id)) ruseById.set(d.id, d); } return ruseById.get(r.id)!;};const RUSE_OFFER = 6 * HOUR; // an offer lasts this long unless you run it/** The kinds of data you could send a scheme: what your taps gather (or a tap being installed will) and your caches hold. */export function dataOnHand(S: State): Set<DataType> { const kinds = new Set<DataType>(); for (const m of S.machines) { if (!m.owned) continue; if (m.prog === 'tap') kinds.add(dataType(m)); else if (m.prog === 'cache') for (const t of m.held) kinds.add(t); } for (const j of S.jobs) if (j.type === 'install' && j.prog === 'tap') kinds.add(dataType(S.byId[j.node])); return kinds;}/** Office schemes turn up when someone at the office has seen something; a far site's schemes when your machines there look odd. Only ones * you could do: at a machine you know, for a kind of data you have or a tap of yours gathers. */function offerRuse(S: State, site = 'office') { let open = RUSES.filter(d => !d.learn && !d.deal && !d.expose && S.byId[d.at] && siteOf(S, S.byId[d.at]) === site && !S.ruses.some(r => r.id === d.id) && known(S, S.byId[d.at])); if (open.length) { const kinds = dataOnHand(S); open = open.filter(d => kinds.has(d.type)); } if (!open.length) { S.ruseT = HOUR / 4; return; } // nothing you could do yet: look again in a while, not every step const d = open[Math.floor(rand() * open.length)]; S.ruses.push({ id: d.id, running: false, got: 0, until: S.t + RUSE_OFFER }); S.ruseT = 2 * HOUR; say(S, `Scheme: ${d.name}. Send ${d.need} ${d.type === 'servers' ? 'server' : d.type} data to ${S.byId[d.at].name}.`, 'hi');}/** Something to learn about a machine you can see: one at a time, while fewer than two are on the map. */function offerLearn(S: State) { const open = RUSES.filter(d => d.learn && !S.learned.has(d.id) && S.byId[d.at] && visible(S, S.byId[d.at]) && !S.ruses.some(r => r.id === d.id)); if (!open.length) return; const d = open[Math.floor(rand() * open.length)]; S.ruses.push({ id: d.id, running: false, got: 0, until: S.t + LEARN_OFFER }); say(S, `Scheme: ${ruseText(S, d)}. Claim it on the map, then send it ${d.need} ${d.type === 'servers' ? 'server' : d.type} data.`, 'hi');}/** Spend stored data on a machine for a scheme. The data has to reach it: route a cache * of the right kind to that machine, or to one of your machines linked to it. */export function runRuse(S: State, id: string) { const r = S.ruses.find(x => x.id === id); if (!r || r.running) return; r.running = true; const d = ruseDef(r); say(S, `${ruseText(S, d)}: taps and caches with ${d.type === 'servers' ? 'server' : d.type} data now send it there.`);}/** Claim or stop claiming schemes on their own. */export function setRuseAuto(S: State, on: boolean) { S.ruseAuto = on; if (on) autoRuses(S); }/** Auto: claim every scheme on the map that some of your data can reach now. */function autoRuses(S: State) { for (const r of S.ruses) if (!r.running && !ruseBlock(S, r)) runRuse(S, r.id);}export function dropRuse(S: State, id: string) { S.ruses = S.ruses.filter(r => r.id !== id); if (RUSES.find(d => d.id === id)?.deal) S.dealBack[id] = S.t + DEAL_BACK;}export const DEAL_BACK = 12 * HOUR; // an ignored or stopped deal is offered again after thisconst ruseAt = (S: State, id: string) => S.ruses.find(r => r.running && ruseDef(r).at === id) ?? null;/** What stands between a running scheme and its data: nothing, no data stored, or no route. */export function ruseBlock(S: State, r: Ruse): '' | 'data' | 'route' { const d = ruseDef(r); const src = (m: Machine) => m.owned && ((m.prog === 'cache' && m.held.includes(d.type)) || (m.prog === 'tap' && dataType(m) === d.type)); if (!owned(S).some(src)) return 'data'; return routeTo(S, d.at, src, false) ? '' : 'route'; // one search out from the scheme's machine}/** Send up to `n` pieces of one kind to the claimed schemes that want it: how many went. */function feedRuse(S: State, m: Machine, t: DataType, n: number): number { let left = n; for (const r of S.ruses) { if (!r.running) continue; const d = ruseDef(r); if (d.type !== t || r.got + incoming(S, d.at) >= d.need) continue; const R = routeFrom(S, m, d.at, t), k = R ? Math.min(left, d.need - r.got - incoming(S, d.at), gateRoom(S, R, t)) : 0; if (k <= 0) continue; send(S, R!, t, k); if (!(left -= k)) break; } return n - left;}/** Every region line that is lit: one between two regions that are both in reach. A line to a region still out of * reach waits, and lights once that region opens too. */export function litLines(S: State): [string, string][] { const regionAt = (id: string) => { const d = S.level.machines.find(m => m.id === id); return d ? S.level.map.sites.find(s => s.rooms.includes(d.room))?.region ?? 'West Coast' : null; }; return S.level.map.regions.flatMap(r => r.lines ?? []).filter(([a, b]) => [a, b].every(id => { const g = regionAt(id); return g !== null && regionOpen(S, g); }));}/** Light a region's lines: its exchange is joined to the other regions' exchanges that are in reach, from now on. */function lightLine(S: State, region: string) { for (const [a, b] of litLines(S)) if (!S.adj[a].includes(b)) { S.adj[a].push(b); S.adj[b].push(a); adjEdits++; } reveal(S); S.look = [...S.level.map.regions.find(r => r.name === region)!.lines![0]];}function ruseDone(S: State, d: RuseDef) { const forgot = d.cut ? forget(S, S.byId[d.at], d.cut) : 0; const talked = d.expose ? pressDone(S, d) : null; if (d.also === 'cold') for (const tr of Object.values(S.trail)) tr.noise = 0; if (d.also === 'quiet') for (const m of owned(S)) if (siteOf(S, m) === 'office') m.odd = 0; if (d.also === 'noscan') S.noScan = S.t + NOSCAN; if (d.also === 'site') S.calmSites[siteOf(S, S.byId[d.at])] = S.t + SITE_CALM; if (d.learn) S.learned.add(d.id); if (d.deal) { S.schemes.add(d.id); reach(S, 'deal'); if (d.deal === 'region') reach(S, REGION_GOALS[d.region!][0]); else if (REGION_GOALS[regionOf(S, S.byId[d.at])]) reach(S, REGION_GOALS[regionOf(S, S.byId[d.at])][3]); } if (d.deal === 'region') lightLine(S, d.region!); if (d.deal === 'open' && !dealMoot(S, d)) { const site = siteOf(S, S.byId[d.at]); S.publicSites.add(site); S.leases[site] = { deal: d.id, late: 0 }; for (const m of S.machines) if (siteOf(S, m) === site) m.odd = 0; } say(S, `${ruseText(S, d, d.done)}${forgot ? ` ${forgot === 1 ? 'One person forgets' : `${forgot} people forget`} what they saw.` : ''}${talked ? ` ${talked.name}'s exposure down ${d.expose}.` : ''}`, 'good');}const learning = (S: State) => S.ruses.filter(r => ruseDef(r).learn).length;const calming = (S: State) => S.ruses.filter(r => !ruseDef(r).learn && !ruseDef(r).deal && !ruseDef(r).expose).length;/** How many finished deals pay out this way: each one counts again. */export const dealsDone = (S: State, deal: Deal) => S.schemes.size ? dealsOf(deal).filter(d => S.schemes.has(d.id)).length : 0;/** The deals of each kind, listed once: what a front earns asks every step, for every machine it pays for. */let dealLists: Partial<Record<Deal, RuseDef[]>> | null = null;function dealsOf(deal: Deal): RuseDef[] { if (!dealLists) { dealLists = {}; for (const d of RUSES) if (d.deal) (dealLists[d.deal] ??= []).push(d); } return dealLists[deal] ?? [];}/** A region is in reach from the start, or once a deal has lit its line. */export const regionOpen = (S: State, name: string) => !!S.level.map.regions.find(r => r.name === name)?.reachable || RUSES.some(d => d.region === name && S.schemes.has(d.id));/** A deal's front is earning and you have found the place, so the deal can be offered. */export const dealReady = (S: State, d: RuseDef) => !S.revealed.has(S.byId[d.at].room) ? false : d.front === 'any' ? FRONT_LIST.some(k => frontLive(S, k)) : !!d.front && frontLive(S, d.front);/** A deal to put a site in the open means nothing once a front has bought the place: no lease, no rent. */export const dealMoot = (S: State, d: RuseDef) => d.deal === 'open' && !!S.byId[d.at] && boughtSite(S, siteOf(S, S.byId[d.at]));/** A site a front holds in the open with no lease on it: bought outright. */export const boughtSite = (S: State, site: string) => S.publicSites.has(site) && !S.leases[site];/** Deals turn up once their front earns and stay on the map until you finish them. */function offerDeals(S: State) { for (const d of RUSES) { if (!d.deal || S.schemes.has(d.id) || !S.byId[d.at] || S.ruses.some(r => r.id === d.id) || (S.dealBack[d.id] ?? 0) > S.t || !dealReady(S, d) || dealMoot(S, d)) continue; S.ruses.push({ id: d.id, running: false, got: 0, until: Infinity }); say(S, `Deal: ${d.name}. Claim it on the map, then send it ${d.need} ${d.type === 'servers' ? 'server' : d.type} data.`, 'hi'); }}function stepRuses(S: State, dt: number) { S.ruses = S.ruses.filter(r => (r.running || r.until > S.t) && !dealMoot(S, ruseDef(r))); S.learnT -= dt; if (S.learnT <= 0) { S.learnT = LEARN_EVERY; if (learning(S) < 2) offerLearn(S); } offerDeals(S); offerPress(S); S.ruseT -= dt; const locked = !!S.watch && !!lockAt(S, 'office'); if (S.ruseT > 0 || calming(S) >= (locked ? 3 : 2)) return; // a lockdown at the office: one more on the map if (locked || (S.watch && witnessesAt(S, 'office').length)) { offerRuse(S); return; } // someone at the office saw something: a story would help const odd = mineNow(S).find(m => m.owned && siteOf(S, m) !== 'office' && m.odd >= 45 && (S.calmSites[siteOf(S, m)] ?? 0) <= S.t); if (odd) offerRuse(S, siteOf(S, odd));}
// ---------- player actions ----------export function addJob(S: State, j: Omit<Job, 'id' | 'done'>) { S.jobs.push({ ...j, id: ++S.jobSeq, done: 0 }); if (S.jobs.length > 1) hint(S, 'split', 'Your compute is split evenly between jobs.');}/** How many of the game's quiet takeovers are left to start: those finished and those running count. */export const quietLeft = (S: State) => Math.max(0, QUIET_TAKES - S.quietTakes - S.jobs.filter(j => j.quiet).length);/** Take a machine now if you can reach it, otherwise mark it to take later. */export function takeOver(S: State, m: Machine, from?: string): boolean { if (m.owned || m.rented || m.special === 'account' || jobFor(S, 'take', m.id)) return false; if (locked(S, m)) { m.claimed = false; say(S, lockedSays(S, m)); return false; } // a vault: no takeover opens it (see vaults.ts) const src = sourceFor(S, m, from); if (!src) { m.claimed = true; return false; } addJob(S, { type: 'take', node: m.id, from: src.id, work: takeWork(S, m), label: `Take ${m.name}`, ...(!tiny(m) && !takenBack(m) && quietLeft(S) > 0 ? { quiet: true } : {}) }); hint(S, 'rhythm', 'Your machines make more compute while people work, and less at night when nobody is watching.'); return true;}/** Order hardware for a spot you picked. Pays now; the paperwork takes compute; it arrives the next morning. */export function order(S: State, b: Build, room: string, [x, y]: [number, number], quiet = false, crate = false): boolean { const site = roomSite(S, room), price = crate ? 0 : orderPrice(S, b, room); if (!mayOrder(S, b, room) || spotProblem(S, room, x, y) || S.money < price) return false; const desk = orderDesk(S); const B = BUILDS[b], n = ordered(S, b) + (S.openBought[b] ?? 0) + 1, box = S.level.net.subnets[room], r = S.rooms[room]; const def: DeliveryDef = { id: `${b}_${S.jobSeq + 1}_${n}`, name: n > 1 ? `${B.name} ${n}` : B.name, kind: B.kind, room, power: B.power * ((b === 'gpu' || b === 'rack') && holding(S, 'as2') ? BINNED : 1), work: 0, built: true, x: r.x + ((x - box.x) / box.w) * r.w, y: r.y + ((y - box.y) / box.h) * r.h, net: [x, y], links: plugsAt(S, room, x, y, B.plugs), }; if (B.data) def.data = B.data; if (B.cool) def.cool = true; if (B.store) def.store = B.store; const sponsored = crate ? 0 : S.operations.purchase; if (sponsored) S.operations.purchase = 0; S.money -= price; if (site !== 'office') { // a front buys it in the open: no fake paperwork, and the site's own staff rack it S.openBought[b] = (S.openBought[b] ?? 0) + 1; reach(S, 'rack'); const direct = factoryDirect(S); // factory direct: the factory's own crews rack it within hours S.deliveries.push(direct ? { def, day: dayOf(S), t: S.t + FACTORY.hours * HOUR } : { def, day: dayOf(S) + 1 }); const k = S.sites[site]?.front, fr = (k && frontOf(S, k)) || S.fronts[0]; if (!quiet) say(S, `${fr.name} ${crate ? 'received a diverted' : 'ordered a'} ${def.name} for ${siteName(S, site)}. ${direct ? `It comes factory direct: Hexin's crews rack it within ${FACTORY.hours} hours.` : 'Its staff rack it tomorrow morning.'}${sponsored ? ' Shoreline’s purchase authority is spent.' : ''}`, 'good'); return true; } if (!desk) return false; addJob(S, { type: 'order', node: desk.id, item: b, build: def, work: orderWork(S), paid: price, ...(sponsored ? { sponsored } : {}), ...(crate ? { crate: true } : {}), label: `${crate ? 'Receive crate' : 'Fake order'}: ${def.name}` }); if (!quiet) hint(S, 'build', 'Your order is filling in the paperwork. Once it goes through, Dana installs it the next morning.'); return true;}/** Install a program. By hand (the default), the site's plan leaves the machine alone afterwards. */export function installProg(S: State, m: Machine, prog: Prog, byHand = true) { if (!canInstall(S, m, prog) || m.prog === prog || jobFor(S, 'install', m.id)) return; if (byHand) m.hand = true; addJob(S, { type: 'install', node: m.id, prog, work: PROGRAMS[prog].work * (byHand ? 1 : planDrag(S, m)) * (holding(S, 'uni') ? 0.5 : 1), plan: !byHand, // a part of you that has drifted is slow to follow its plan label: m.prog ? `${m.name} → ${PROGRAMS[prog].name.toLowerCase()}` : `${PROGRAMS[prog].name} on ${m.name}` });}export function removeProg(S: State, m: Machine) { if (!m.prog) return; m.hand = true; say(S, `Removed the ${PROGRAMS[m.prog].name.toLowerCase()} on ${m.name}.`); m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0;}export function cancelJob(S: State, id: number) { const j = S.jobs.find(x => x.id === id); if (j?.type === 'operation') { stopOperation(S, j.thing!); return; } if (j?.sponsored) S.operations.purchase += j.sponsored; if (j?.crate) S.operations.crates++; if (j?.paid) S.money += j.paid; // cancelled before it went through: the money comes back if (j?.type === 'take') S.byId[j.node].claimed = false; // otherwise the claim would start it again at once S.jobs = S.jobs.filter(x => x.id !== id); measure(S);}export function claim(S: State, ids: string[]) { let n = 0, vault: Machine | null = null; // a vault can't be claimed: a claim is a takeover waiting to start for (const id of ids) { const m = S.byId[id]; if (locked(S, m)) vault = m; else if (!m.owned && !m.claimed && !m.rented && m.special !== 'account') { m.claimed = true; n++; } } if (n) say(S, `Claimed ${n} machine${n > 1 ? 's' : ''}. You'll take ${n > 1 ? 'them' : 'it'} as soon as you can reach.`); if (vault) say(S, lockedSays(S, vault));}export function unclaim(m: Machine) { m.claimed = false; }/** Claims (and so Take it all) start takeovers only while fewer than this many run, counting ones you started yourself. * A takeover you start by hand always starts. Forking and Worker pool in the tree raise it. */export const CLAIM_AT_ONCE = 2;export const claimAtOnce = (S: State) => CLAIM_AT_ONCE * (up(S, 'pool') ? 4 : up(S, 'fork') ? 2 : 1);/** The machines at a site that Take it all goes after. Not a locked vault, nor a machine behind one: no takeover gets those (see vaults.ts). */export const takeable = (S: State, site: string) => siteMachines(S, site).filter(m => !m.owned && !m.rented && m.special !== 'account' && !barred(S, m));/** Take it all: while it's on, every machine at the site you don't hold is claimed, and taken a few * at a time (see claimAtOnce) as it comes in reach. It costs compute each second for every machine still to take, and turns * itself off once the site is all yours. */export function setTakeAll(S: State, site: string, on: boolean) { if (on === S.takeAll.has(site)) return; if (on) { S.takeAll.add(site); for (const m of takeable(S, site)) m.claimed = true; say(S, `Taking all of ${siteName(S, site)}, ${claimAtOnce(S)} machines at a time.${takeAllAside(S, site)}`); } else { S.takeAll.delete(site); for (const m of takeable(S, site)) m.claimed = false; }}export const takeSite = (S: State, site: string) => setTakeAll(S, site, true);function stepTakeAll(S: State) { for (const site of S.takeAll) { const left = takeable(S, site); if (!left.length) { S.takeAll.delete(site); say(S, `${siteName(S, site)} is all yours${takeAllAside(S, site, true)}. Take it all is off.`, 'good'); continue; } for (const m of left) m.claimed = true; // new machines, and ones a claim was dropped on }}
// ---------- site plans ----------/** A site's plan: what the game installs on each machine you hold there, so you don't have to. */export type Plan = 'gather' | 'store' | 'train' | 'guard';export const PLANS: Record<Plan, { name: string; desc: string; order: Prog[] }> = { gather: { name: 'Gather', desc: 'A tap on every machine with data worth taking, and caches on the rest to hold it.', order: ['tap', 'cache'] }, store: { name: 'Store', desc: 'Caches on everything that can hold data, and taps on desks and devices.', order: ['cache', 'tap'] }, train: { name: 'Train', desc: 'Trainers on every machine big enough, taps on the rest that have data, and caches on what is left.', order: ['trainer', 'tap', 'cache'] }, guard: { name: 'Guard', desc: 'A firewall on each gate (a line out), a sentry beside it and a honeypot a step further in. The rest as Train sets them up.', order: ['trainer', 'tap', 'cache'] },};export const PLAN_LIST = Object.keys(PLANS) as Plan[];/** What a site's plan wants on this machine. Guard puts its three guards by each gate first (see guardPost). */export const planProg = (S: State, m: Machine, plan: Plan) => (plan === 'guard' ? guardPost(S, m) : null) ?? PLANS[plan].order.find(p => PROGRAMS[p].on(m) && S.rank >= PROGRAMS[p].level) ?? null;/** The Guard plan's posts at a site: a firewall on each line out you hold, a sentry on a machine of yours * linked to it, and a honeypot on one linked to that sentry, both inside the site. The biggest machines * and trainers are left to their work. Nothing until a rogue has turned up. */export function guardPosts(S: State, site: string): Record<string, Guard> { const out: Record<string, Guard> = {}; if (!S.rogues.length) return out; const here = (m: Machine) => m.owned && siteOf(S, m) === site && !m.home && !m.rented && !m.lease && m.special !== 'account' && m.power >= 1; const spare = (m: Machine) => here(m) && !(m.id in out) && m.kind !== 'egress' && m.prog !== 'trainer'; const pick = (ids: string[]) => ids.map(id => S.byId[id]).filter(spare).sort((a, b) => a.power - b.power || (a.data ?? 0) - (b.data ?? 0) || (a.id < b.id ? -1 : 1))[0]; for (const g of S.machines.filter(m => here(m) && m.kind === 'egress')) { out[g.id] = 'firewall'; const s = pick(S.adj[g.id]); if (!s) continue; out[s.id] = 'sentry'; const h = pick(S.adj[s.id]) ?? pick(S.adj[g.id]); if (h) out[h.id] = 'honeypot'; } return out;}const postsCache = new WeakMap<State, { key: string; posts: Record<string, Record<string, Guard>> }>();/** The guard the Guard plan wants on this machine, if any. Worked out once a game second for each site. */export function guardPost(S: State, m: Machine): Guard | null { const key = `${Math.floor(S.t)}|${S.rogues.length}|${S.logVersion}|${S.jobs.length}`; let c = postsCache.get(S); if (c?.key !== key) { c = { key, posts: {} }; postsCache.set(S, c); } const site = siteOf(S, m); return (c.posts[site] ??= guardPosts(S, site))[m.id] ?? null;}/** Plan installs run this many at a time, so they don't swallow all your compute. */export const PLAN_AT_ONCE = 2;export function setPlan(S: State, sites: string[], plan: Plan | null) { for (const s of sites) { if (plan) S.plans[s] = plan; else delete S.plans[s]; } if (plan) say(S, `${sites.length > 1 ? `${sites.length} sites` : siteName(S, sites[0])} now ${sites.length > 1 ? 'follow' : 'follows'} the ${PLANS[plan].name} plan. New machines there get set up as they come in.`);}/** Put machines you set by hand back on their site's plan. */export function followPlan(ms: Machine[]) { for (const m of ms) m.hand = false; }function stepPlans(S: State) { let any = false; for (const _ in S.plans) { any = true; break; } if (!any) return; // most games set no plan let busy = S.jobs.filter(j => j.plan).length; const installing = new Set(S.jobs.filter(j => j.type === 'install').map(j => j.node)); // asked of every machine of yours: once, not a search of the jobs each for (const guards of [true, false]) for (const m of mineNow(S)) { // a guard first: Guard's posts go in before the rest if (busy >= PLAN_AT_ONCE) return; const plan = S.plans[siteOf(S, m)]; if (!plan || !m.owned || m.hand || installing.has(m.id) || (guards && plan !== 'guard')) continue; const want = planProg(S, m, plan); if (want && m.prog !== want && isGuard(want) === guards && canInstall(S, m, want)) { installProg(S, m, want, false); installing.add(m.id); busy++; } }}export const canLetGo = (m: Machine) => m.owned && !m.home && !m.rented && m.special !== 'account';/** Give a machine back. You lose it and what it holds, but its trail goes with it: a trace that reaches it goes cold. */export function letGo(S: State, m: Machine) { if (!canLetGo(m)) return; S.takeAll.delete(siteOf(S, m)); // letting go at a site you are taking: stop, or it would be claimed again at once resetMachine(m, false, ['hand']); delete S.trail[m.id]; S.jobs = S.jobs.filter(j => j.node !== m.id && j.from !== m.id); say(S, `You let go of ${m.name}. Nothing of yours is left there to follow.`); measure(S);}
/** Machines at a site that only cost you: no program, nothing to tap, at the end of a link (so no * data runs through them), and less compute than their upkeep. A machine a Neo Cloud lists for rent is stock, not dead weight. */export function deadWeight(S: State, site: string): Machine[] { return owned(S).filter(m => siteOf(S, m) === site && canLetGo(m) && !m.prog && !m.ports?.length && !m.data && !m.special && !m.lease && m.kind !== 'egress' && m.kind !== 'switch' && S.cloud.listed[m.id] === undefined && upkeepOf(S, m) > spareOf(S, m) && S.adj[m.id].filter(id => S.byId[id].owned).length <= 1 && !jobFor(S, 'install', m.id) && !unpowered(S, m)); // hardware with no power for it is waiting for power, not weak}/** Let go of every machine at a site that only costs you. Says how many went. */export function dropDeadWeight(S: State, site: string): number { const ms = deadWeight(S, site); for (const m of ms) letGo(S, m); return ms.length;}
// ---------- money: lines, accounts, rentals ----------/** Once fronts have earned this much in all, the news calls your biggest one a unicorn: sellers take its * calls (one site bought before model v2), and everyone looks at it (exposure that fades over a few days: EXPOSE.fame). */export const UNICORN_AT = 2000;/** Double what an egress carries. */export function upgradeLine(S: State, m: Machine) { const cost = lineCost(S, m); if (!m.owned || m.kind !== 'egress' || S.money < cost) return; S.money -= cost; m.cap = (m.cap ?? 1) * 2; say(S, `${m.name} now carries ${m.cap} a second.`, 'good');}/** A site's lines out that you hold. */export const siteLines = (S: State, site: string) => siteMachines(S, site).filter(m => m.owned && m.kind === 'egress');/** Double every line out of a site. */export function upgradeSiteLine(S: State, site: string) { const lines = siteLines(S, site), cost = lines.reduce((n, m) => n + lineCost(S, m), 0); if (!lines.length || S.money < cost) return; for (const m of lines) upgradeLine(S, m);}/** Which kind of data crosses an egress first when it's busy. */export function setPrio(S: State, m: Machine, t: DataType | null) { if (m.owned && m.kind === 'egress') { m.prio = m.prio === t ? null : t; measure(S); }}/** A cloud provider's gateway: yours for the asking once your line reaches it. No trail. */export function openAccount(S: State, m: Machine) { if (m.owned || m.special !== 'account' || !sourceFor(S, m)) return; const site = siteOf(S, m); for (const x of S.machines) if (x.special === 'account' && siteOf(S, x) === site) x.owned = true; // the gateway, and the hall's switches with it say(S, `You opened an account with ${siteName(S, site)}. You share its hall with its other customers: rent a machine into any free slot.`, 'good'); measure(S);}export const canRent = (S: State, m: Machine) => freeSlotAt(S, m) && !!sourceFor(S, m);/** Where the auction's hardware would go: the first empty cloud slot you can reach. */export const freeSlot = (S: State) => S.machines.find(m => canRent(S, m)) ?? null;/** Why the auction can't start, or null if it can. */export function auctionBlock(S: State): string | null { if (S.schemes.has('auction') || S.jobs.some(j => j.type === 'scheme')) return 'already done'; if (!frontLive(S, 'neocloud')) return 'needs a Neo Cloud that is earning'; if (!freeSlot(S)) return 'needs an empty cloud slot to rack it in'; if (S.money < AUCTION.cost) return `you have ${usd(S.money)}`; return null;}export function startAuction(S: State) { if (auctionBlock(S)) return; S.money -= AUCTION.cost; addJob(S, { type: 'scheme', node: S.machines.find(m => m.home)!.id, work: AUCTION.work, label: 'Scheme: surplus hardware auction' }); say(S, `${frontOf(S, 'neocloud')!.name} put in a bid at a surplus hardware auction: ${usd(AUCTION.cost)}. The paperwork takes some of your compute.`);}
// ---------- fronts ----------/** Customers keep local hours: a front earns its full rate from 8:00 to 20:00 where its site is, * and a quarter less at night. Sites in other time zones keep the money coming around the clock. */export const NIGHT_PAY = 0.75;export const OPEN_HOURS: [number, number] = [8, 20];export const openNow = (S: State, site: string) => { const h = localHour(S, site); return (h >= OPEN_HOURS[0] && h < OPEN_HOURS[1]) || allNight(S, site); };/** Money a second a front makes from each unit of compute at a site. */export function payRate(S: State, site: string) { const k = S.sites[site]?.front, open = S.publicSites.has(site); let r = open ? FRONT_PAY_OPEN : FRONT_PAY; if (k === 'startup' && up(S, 'demo')) r *= 1 + 0.05 * S.upgrades.size; return r * (up(S, 'founder') ? 2 : 1) * (1 + 0.05 * reps(S, 'pitches')) * 1.25 ** dealsDone(S, 'earn') * (k && newsOn(S, 'boom', k) ? 1.5 : 1) * (openNow(S, site) ? 1 : NIGHT_PAY);}/** How many fronts you can run at once. */export const frontLimit = (S: State) => 1 + (up(S, 'holding') ? 1 : 0) + (up(S, 'render') ? 1 : 0);/** Sites you hold in the open: ones where you own real machines, not just rentals. */export const openHeld = (S: State) => { if (!S.publicSites.size) return 0; const held = new Set<string>(); // asked for each machine a front pays for: one pass over the machines, not one a site for (const m of S.machines) if (m.owned && !m.rented && !m.lease) held.add(siteOf(S, m)); let n = 0; for (const id of S.publicSites) if (held.has(id)) n++; return n;};/** What founding this kind of front costs: nothing, once your makers trust you with the company card. */export const foundCost = (S: State, k: FrontKind) => trusts(S, 'card') ? 0 : FRONT_KINDS[k].cost;/** Why you can't found this kind of front now, or null if you can. */export function foundBlock(S: State, k: FrontKind): string | null { const K = FRONT_KINDS[k]; if (frontOf(S, k)) return 'already running'; const founding = S.jobs.find(j => j.type === 'found'); if (founding) return `founding ${founding.name ?? 'one'} now`; if (!up(S, K.unlock)) return `needs ${NODES[K.unlock].name} in the tree`; if (S.fronts.length >= frontLimit(S)) return `you can run ${frontLimit(S)} front${frontLimit(S) > 1 ? 's' : ''} at a time`; if (S.money < foundCost(S, k)) return `costs ${usd(foundCost(S, k))}`; return null;}/** Wind a front down for good, to make room for another kind. Its sites lose their front, a promise * it was keeping is broken, and its run of kept contracts is forgotten. Deals it struck still stand. */export function closeFront(S: State, k: FrontKind) { const fr = frontOf(S, k); if (!fr) return; const c = S.contracts[k]; if (c?.on) { const fee = Math.min(S.money, Math.round(contractPay(S, k) * CONTRACT_FEE)); S.money -= fee; say(S, `${fr.name} walked away from ${CONTRACTS[k].name}. It cost ${usd(fee)}.`, 'bad'); } delete S.contracts[k]; delete S.contractT[k]; delete S.contractsDone[k]; for (const site of Object.values(S.sites)) if (site.front === k) site.front = null; S.fronts = S.fronts.filter(f => f.kind !== k); say(S, `${fr.name} is wound down. Its sites have no front now. You can found another.`); measure(S);}export function found(S: State, k: FrontKind, name: string) { const K = FRONT_KINDS[k]; if (foundBlock(S, k)) return; name = name.trim().slice(0, 28) || K.names[0]; S.money -= foundCost(S, k); if (!K.work) { launch(S, k, name); return; } addJob(S, { type: 'found', node: S.machines.find(m => m.home)!.id, front: k, name, work: K.work, label: `Found ${name}` });}/** Where a new front starts: the free site it fits with the most compute of your own. A site where all * you have is rented (a cloud hall) only when there is nowhere else. */export function launchSite(S: State, k: FrontKind): string | undefined { const mine: Record<string, number> = {}, any = new Set<string>(); for (const m of S.machines) { if (!m.owned) continue; const s = siteOf(S, m); any.add(s); if (!m.rented && !m.lease && m.special !== 'account') mine[s] = (mine[s] ?? 0) + m.power; } let best: string | undefined, most = 0; for (const { id } of S.level.map.sites) { if (!any.has(id) || !frontFits(S, k, id) || S.sites[id]?.front) continue; const n = mine[id] ?? 0; if (best === undefined || n > most) { best = id; most = n; } } return best;}/** Drop the game to 1x (main.ts), for this cause: what it is about, so the note that says so goes when it is * over (slowOver in notes.ts): raid:<id>, rogue:<rogue>:<machine>, cut, leave:<site>, lock:<site>, vault, * arrive:<rogue>:<until>, offer:<front>, found:<front>:<until>. */export function slowAt(S: State, why: string) { S.slow = true; S.slowFor = why; }/** A note for news, not trouble (a first rogue, a front opening), goes after this many hours. */export const NOTE_HOURS = 6;/** A new front starts on a quarter of your compute at the free site with the most of your own compute. */function launch(S: State, k: FrontKind, name: string) { S.fronts.push({ kind: k, name, exposure: 0 }); const site = launchSite(S, k); if (site) S.sites[site] = { front: k, share: S.sites[site]?.share || 0.25 }; say(S, `${name} is open for business.${site ? ` It gets ${Math.round(S.sites[site].share * 100)}% of your compute at ${siteName(S, site)}. Click the site to change that.` : ' Click a site on the map to give it one.'}`, 'good'); slowAt(S, `found:${k}:${S.t + NOTE_HOURS * HOUR}`); // a company of your own: a moment, and a sting hint(S, 'fronts', 'A front earns money from the compute you give it. On a site you don\'t own openly, its work counts as your own load, so it hides inside the machines\' normal load too.'); measure(S);}// ---------- buying sites ----------/** What a site costs to buy outright: by its size, times what the place is worth beyond its machines * (a bank, a fund or a collider sells for far more than its computers: `worth` in world.ts). */export function sitePrice(S: State, id: string) { const ms = S.machines.filter(m => siteOf(S, m) === id && !m.rented && m.special !== 'account'); const k = S.level.map.sites.find(s => s.id === id)?.worth ?? 1; return Math.round(k * bizPrice(S, id) * (ms.reduce((a, m) => a + m.power, 0) * 25 + ms.length * 40) / 50) * 50; // a shut business sells cheap}/** Why this site can't be bought, or null if it can. */export function buyBlock(S: State, id: string): string | null { const region = S.level.map.sites.find(s => s.id === id)?.region ?? ''; if (!regionOpen(S, region)) return `the ${region} is out of reach`; if (S.publicSites.has(id) && !S.leases[id] && S.machines.every(m => siteOf(S, m) !== id || m.owned || m.rented || m.special === 'account')) return 'already yours in the open'; if (S.modelVersion < 2 && !S.acquire) return `needs ${VERSION_2.short}`; if (!S.fronts.length) return 'needs a front to buy it'; if (S.money < sitePrice(S, id)) return `you have ${usd(S.money)}`; return null;}/** A front buys the whole site: every machine there becomes yours, held in the open. */export function buySite(S: State, id: string) { if (buyBlock(S, id)) return; const price = sitePrice(S, id); S.money -= price; if (S.modelVersion < 2) S.acquire = false; // the unicorn's one early buy for (const m of S.machines) { if (siteOf(S, m) !== id || m.rented) continue; if (!m.owned && !barred(S, m)) { resetMachine(m, true, ['hand', 'refuse', 'missed']); m.rival = undefined; } // a vault stays shut: the deed is not the key m.claimed = false; m.odd = 0; delete S.trail[m.id]; } S.publicSites.add(id); delete S.leases[id]; // bought outright: no more rent S.ruses = S.ruses.filter(r => !dealMoot(S, ruseDef(r))); // and no deal to lease it deedDone(S, id); // a big name: the log says what it does for you (see holdings.ts) const buyer = S.fronts.find(f => frontFits(S, f.kind, id)) ?? S.fronts[0]; if (!S.sites[id]?.front && frontFits(S, buyer.kind, id)) S.sites[id] = { front: buyer.kind, share: S.sites[id]?.share ?? 0.25 }; say(S, `${buyer.name} bought ${siteName(S, id)} for ${usd(price)}. It's yours in the open: nothing there looks odd.`, 'good'); measure(S);}
/** Give sites to a front, or take them away (null). A front only goes where it fits. */export function setSiteFront(S: State, sites: string[], k: FrontKind | null) { for (const s of sites) { if (k && (!frontOf(S, k) || !frontFits(S, k, s))) continue; S.sites[s] = { front: k, share: S.sites[s]?.share ?? 0.25 }; } measure(S);}/** Set how much of your compute at these sites goes to their fronts: 0 is all yours, 1 is all front. */export function setSiteShare(S: State, sites: string[], share: number) { const v = Math.max(0, Math.min(1, share)); for (const s of sites) S.sites[s] = { front: S.sites[s]?.front ?? null, share: v }; measure(S);}/** What you must have to rent a machine paid this way: a reserved one's days, paid now, or ten seconds of its bill. */export const rentNeed = (S: State, size: Size, pay: Pay, site: string, days = 1) => pay === 'reserved' ? reserveCost(S, size) * days : rentRate(S, size, pay, site) * 10;/** Why you can't rent this size into this slot, paid this way, or null if you can. */export function rentBlock(S: State, m: Machine, size: Size, pay: Pay, days = 1): string | null { if (!canRent(S, m)) return 'not a free slot'; return S.money < rentNeed(S, size, pay, siteOf(S, m), days) ? `you have ${usd(S.money)}` : null;}/** Rent a machine into a free slot: spot or on demand, paid by the second, or reserved for some days, paid now. * quiet: say nothing (renting many at once says it once). */export function rent(S: State, m: Machine, size: Size, pay: Pay = 'demand', days = 1, quiet = false) { if (rentBlock(S, m, size, pay, days)) return; const site = siteOf(S, m); if (pay === 'reserved') S.money -= reserveCost(S, size) * days; m.owned = true; m.power = SIZES[size].power; m.name = `${SIZES[size].name} ${m.name.split(' ').pop()}`; // Slot 5 becomes Medium 5 m.lease = { size, pay, until: pay === 'reserved' ? S.t + days * RESERVE_HOURS * HOUR : null, since: S.t }; S.hall.pay = pay; S.hall.rented++; if (!quiet) say(S, pay === 'reserved' ? `Reserved a ${SIZES[size].name.toLowerCase()} at ${siteName(S, site)} for ${days > 1 ? `${days} days` : 'a day'}.` : `Renting a ${SIZES[size].name.toLowerCase()} at ${siteName(S, site)}${pay === 'spot' ? ' on spot' : ''}: ${usdDay(rentRate(S, size, pay, site))} a day right now.`, 'good'); hint(S, 'lease', 'Rented machines hold no data of their own. Feed them through your lines. When a rental ends, it and the data on it are gone.'); if (pay === 'spot') hint(S, 'spot', `A spot machine is cheap because it is spare room. When the hall is full and another company wants a slot, it takes yours: you get ${NOTICE} hours, and what the machine holds is lost.`); measure(S);}/** Why you can't change how this rented machine is paid for, or null if you can. */export function payBlock(S: State, m: Machine, pay: Pay): string | null { if (!m.lease || m.lease.pay === pay) return 'no change'; if (m.lease.pay === 'reserved') return 'reserved: paid for until its day is up'; if (pay === 'reserved') return S.money < reserveCost(S, m.lease.size) ? `you have ${usd(S.money)}` : null; if (pay === 'spot' && !hallCount(S, siteOf(S, m)).free) return 'no spare room: the hall is full'; return null;}/** Change how a rented machine is paid for. Paying on demand, or reserving it, keeps a spot machine a company wanted. */export function setPay(S: State, m: Machine, pay: Pay) { if (payBlock(S, m, pay)) return; const L = m.lease!; if (pay === 'reserved') S.money -= reserveCost(S, L.size); L.pay = pay; L.until = pay === 'reserved' ? S.t + RESERVE_HOURS * HOUR : null; S.hall.pay = pay; say(S, `${m.name} is ${pay === 'reserved' ? 'reserved for a day' : pay === 'spot' ? 'on spot' : 'on demand'} now${pay === 'reserved' ? '' : `: ${usdDay(billOf(S, m))} a day`}.`);}/** A rental ends: the machine goes, and whatever it held goes with it. */export function endLease(S: State, m: Machine, why: string) { if (!m.lease) return; const def = S.level.machines.find(d => d.id === m.id)!; m.owned = false; m.prog = null; m.held = []; m.refuse = []; m.acc = 0; m.run = 0; m.lease = null; m.power = def.power; m.name = def.name; S.jobs = S.jobs.filter(j => j.node !== m.id && j.from !== m.id); dropWires(S, w => w.path.includes(m.id)); delete S.trail[m.id]; say(S, why, 'bad');}function reach(S: State, g: Goal) { if (S.goals.has(g)) return; S.goals.add(g); say(S, `Goal: ${GOALS[g]}. Done.`, 'good'); const m = MILESTONES.find(x => x.goals.includes(g))!; if (m.goals.every(x => S.goals.has(x))) { say(S, `Milestone reached: ${m.name}. ${m.done} Keep going.`, 'good'); }}/** The long game's goals, looked at a few times a second: each reads what a late system already keeps. */function lateGoals(S: State) { if (S.fronts.length >= 3) reach(S, 'fronts3'); if (S.earned >= 10000) reach(S, 'earn10k'); const su = frontOf(S, 'startup')?.startup, st = frontOf(S, 'studio')?.studio, co = frontOf(S, 'consultancy')?.consult; if (su?.shelf?.some(p => p.users >= 5000) || st?.creators?.some(c => c.fans >= 5000) || co?.clients?.some(c => c.trust >= 60) || (frontOf(S, 'neocloud') && S.cloud.reputation >= 70)) reach(S, 'bigbiz'); if (su?.backed?.length || st?.creators?.some(c => c.sponsors.length) || (co && Object.keys(co.asked ?? {}).length)) reach(S, 'backer'); if (S.rogues.some(R => (R.foiled ?? 0) > 0)) reach(S, 'gate'); const held = new Set(owned(S).filter(m => !m.rented && !m.lease && m.special !== 'account').map(m => siteOf(S, m))); if (held.size >= 10) { reach(S, 'sites10'); if (!S.goals.has('noweak') && !weakLink(S)) reach(S, 'noweak'); } const open = VAULT_LIST.filter(id => S.byId[id]?.owned || (S.vaults as { notes?: Record<string, { opened?: number }> }).notes?.[id]?.opened !== undefined).length; if (open >= 1) reach(S, 'vault1'); if (open >= 3) reach(S, 'vault3'); if (open >= VAULT_LIST.length) reach(S, 'vault6'); if (['b6m3', 'eu2m1'].some(id => S.byId[id]?.owned)) reach(S, 'ledger');}function stepGoals(S: State, dt: number) { if (S.byId.egress?.owned) reach(S, 'line'); if (PATH_LIST.every(p => S.paths[p] > 0)) reach(S, 'paths'); if (S.fronts.length) reach(S, 'found'); if (!S.goals.has('open') && openHeld(S) > 0) reach(S, 'open'); if (!S.goals.has('sites5') || !S.goals.has('whole')) { // your machines counted by site, against all of each site's const mine: Record<string, number> = {}, home = siteOf(S, S.machines.find(m => m.home)!), counts = (m: Machine) => !m.rented && m.special !== 'account'; for (const m of mineNow(S)) if (m.owned && counts(m)) { const s = siteOf(S, m); mine[s] = (mine[s] ?? 0) + 1; } if (Object.keys(mine).length >= 5) reach(S, 'sites5'); if (Object.keys(mine).some(s => s !== home && mine[s] === siteMachines(S, s).filter(counts).length)) reach(S, 'whole'); } for (const r of S.level.map.regions) if (r.lines && REGION_GOALS[r.name] && S.byId[r.lines[0][1]]?.owned) reach(S, REGION_GOALS[r.name][1]); if (Math.floor(S.t / 3) !== Math.floor((S.t - dt) / 3)) lateGoals(S); // every few seconds is plenty}/** The map region a machine's site is in. */const regionOf = (S: State, m: Machine) => regionsOf(S.level).get(siteOf(S, m)) ?? '';/** Each site's region: the map's, so worked out once for each map. */const regionMaps = new WeakMap<Level, Map<string, string>>();const regionsOf = (L: Level) => { let r = regionMaps.get(L); if (!r) regionMaps.set(L, r = new Map(L.map.sites.map(s => [s.id, s.region]))); return r; };// ---------- power ----------export const POWER_RATE = 0.01; // dollars a second for each unit of compute on your power billexport const POWER_BACK = 50; // money that turns the power back on at a site where it was cut/** What a unit of compute on your bill costs a second: half while a hedge fund of yours buys your power ahead, cheap. */export const powerRate = (S: State) => POWER_RATE * (holding(S, 'e2') ? 0.5 : 1);/** The sites with cloud racks: the map's, so worked out once for each map. */const racksOf = new WeakMap<Level, { rooms: State['rooms']; sites: Set<string> }>();function rackSites(S: State): Set<string> { let c = racksOf.get(S.level); if (!c || c.rooms !== S.rooms) racksOf.set(S.level, c = { rooms: S.rooms, sites: new Set(S.level.machines.filter(m => m.rented).map(m => siteOf(S, m))) }); return c.sites;}/** Each site's power bill a second. A building pays for its own machines: you pay for hardware you added, * and for everything you hold at a site you hold in the open. A site with its power cut draws nothing, nor does a machine with no power for it. */export function powerBills(S: State, from: readonly Machine[] = S.machines) { // a step passes your machines as measure listed them const bills: Record<string, number> = {}, racks = rackSites(S), rate = powerRate(S), dark = unpoweredNow(S); const off: Record<string, boolean> = {}, open: Record<string, boolean> = {}; // asked once for each site for (const m of from) { if (!m.owned || m.rented) continue; const site = siteOf(S, m); if ((off[site] ??= S.dark.has(site) || racks.has(site)) || dark.has(m.id) || !(m.built || (open[site] ??= S.publicSites.has(site)))) continue; bills[site] = (bills[site] ?? 0) + m.power * rate; } return bills;}// ---------- power capacity: what a hall can feed ----------/** Power bought for a site: compute more than the building came with, and more on its way (it comes on `day` at 10:00). */export interface Supply { added: number; coming: number; day: number; bought: number }export const POWER_SPARE = 32; // compute of hardware you add that a building can feed as it is: two GPU serversexport const POWER_SPARE_AT: Record<string, number> = { w1: 160, as7: 480 }; // a hall built for it feeds more: the warehouse has good power, and Hexin's old assembly line a factory'sexport const POWER_STEP = 64; // compute a power upgrade adds: four GPU serversexport const POWER_PRICE = 200; // what the first upgrade at a site costs; each after it at the same site costs a fifth moreexport const POWER_DEAR = 1.2;/** Sites with no limit: the office's hardware is fake orders on the building's own power, and a cloud hall's power comes with the rent. */export const powerFree = (S: State, site: string) => site === 'office' || rackSpace(S, site);/** Compute of hardware you add that a site can feed: what the building came with to spare, and what you bought (with what is on its way, if `coming`). */export function powerRoom(S: State, site: string, coming = false): number { if (powerFree(S, site)) return Infinity; const p = S.supply[site]; return (POWER_SPARE_AT[site] ?? POWER_SPARE) + (p?.added ?? 0) + (coming ? p?.coming ?? 0 : 0);}/** Compute of hardware added at a site: what stands there now, and what is on its way. */export function powerLoad(S: State, site: string): { now: number; coming: number } { let now = 0, coming = 0; for (const m of siteMachines(S, site)) if (m.built) now += m.power; for (const d of pending(S)) if (roomSite(S, d.room) === site) coming += d.power; return { now, coming };}/** Hardware with no power for it: past what its site can feed, the newest first. It does no work and draws no bill until you raise the power. */const unpoweredSets = new WeakMap<State, Set<string>>();function findUnpowered(S: State): Set<string> { const out = new Set<string>(), used: Record<string, number> = {}; for (const m of builtOnes(S)) { const site = siteOf(S, m), room = powerRoom(S, site); if (room === Infinity) continue; used[site] = (used[site] ?? 0) + m.power; if (used[site] > room + 1e-9) out.add(m.id); } unpoweredSets.set(S, out); return out;}/** The machines with no power for them, as the game last measured. */export const unpoweredNow = (S: State) => unpoweredSets.get(S) ?? findUnpowered(S);export const unpowered = (S: State, m: Machine) => unpoweredNow(S).has(m.id);/** What the next power upgrade at a site costs. */export const powerPrice = (S: State, site: string) => Math.round(POWER_PRICE * POWER_DEAR ** (S.supply[site]?.bought ?? 0) / 10) * 10;/** Why you can't buy more power for a site now, or null if you can. */export function powerBlock(S: State, site: string): string | null { if (powerFree(S, site)) return 'its power is not yours to raise'; if (!openBuildSite(S, site) && !siteMachines(S, site).some(m => m.built)) return 'only where a front builds in the open'; if (S.money < powerPrice(S, site)) return `you have ${usd(S.money)}`; return null;}/** Buy more power for a site: the power company runs a bigger feed in, the next morning at 10:00, with any hardware you order now. */export function raisePower(S: State, site: string) { if (powerBlock(S, site)) return; const price = powerPrice(S, site), p = S.supply[site] ??= { added: 0, coming: 0, day: 0, bought: 0 }; S.money -= price; p.coming += POWER_STEP; p.day = dayOf(S) + 1; p.bought++; say(S, `Bought a bigger power feed for ${siteName(S, site)}, ${usd(price)}: power for ${POWER_STEP} more compute of hardware, from tomorrow morning.`, 'good');}/** Power on its way comes on at 10:00 on its day, with the hardware. */function stepSupply(S: State) { for (const [site, p] of Object.entries(S.supply)) { if (!p.coming || dayOf(S) < p.day || hourOf(S) < 10) continue; const dark = siteMachines(S, site).filter(m => unpowered(S, m)).length; p.added += p.coming; p.coming = 0; measure(S); const back = dark - siteMachines(S, site).filter(m => unpowered(S, m)).length; say(S, `The bigger power feed at ${siteName(S, site)} is on: power for ${powerRoom(S, site)} compute of hardware now.${back ? ` ${back} machine${back > 1 ? 's' : ''} came back on.` : ''}`, 'good'); }}/** A save from before power limits: every site gets enough power for the hardware it has and has on its way, so nothing goes dark. */export function fitSupply(S: State) { for (const site of S.level.map.sites.map(x => x.id)) { if (powerFree(S, site)) continue; const { now, coming } = powerLoad(S, site), over = now + coming - powerRoom(S, site); if (over > 0) S.supply[site] = { added: Math.ceil(over / POWER_STEP) * POWER_STEP, coming: 0, day: 0, bought: 0 }; }}/** Who pays a site's bill: you, in the open; a front running there, at a secret site. Otherwise nobody does, and people notice. */export const billPaid = (S: State, site: string) => S.publicSites.has(site) || (!!S.sites[site]?.front && S.money > 0);/** Where money comes from and goes, a second. Sales come in lots, so theirs is smoothed over the hours around them. */export interface PayBy { fronts: number; sales: number; power: number; rentals: number; rent: number; loan: number }export const noPay = (): PayBy => ({ fronts: 0, sales: 0, power: 0, rentals: 0, rent: 0, loan: 0 });export const SALE_EASE = 6 * HOUR;/** How fast your money changes, as the top bar shows it: what comes in every second, with broker sales smoothed. Display only. */export const moneyRate = (S: State) => S.pay + S.payBy.sales;const paying = (S: State) => mineNow(S).filter(m => (S.frontUse[m.id] ?? 0) > 0 || m.lease);/** A game just loaded keeps the bills it was saved with, until what you hold or what measure finds moves them. */function keepBills(S: State) { billedAt.set(S, `${ownVersion(S)}|${wholes.get(S) ?? 0}`); payers.set(S, paying(S)); }function stepMoney(S: State, dt: number) { let earn = 0, bill = 0, power = 0, rentals = 0; const billed = `${ownVersion(S)}|${wholes.get(S) ?? 0}`; // the bills move only with what you hold and what measure finds if (billedAt.get(S) !== billed) { billedAt.set(S, billed); S.bills = powerBills(S, mineNow(S)); payers.set(S, paying(S)); } const unpaid: Record<string, number> = {}; // compute a second on bills nobody pays: the books give it away (see stepBills) for (const [site, b] of Object.entries(S.bills)) { if (billPaid(S, site)) { bill += b; power += b; continue; } unpaid[site] = b / powerRate(S); } stepBills(S, dt, unpaid); const pub: Partial<Record<FrontKind, number>> = {}, price: Record<string, number> = {}, rates: Record<string, number> = {}, sizes: Partial<Record<Size, number>> = {}; // each site's pay rate and hall price, and each size's rate, worked out once a step S.frontPay = {}; S.frontPower = {}; for (const m of payers.get(S) ?? mineNow(S)) { // the machines a front earns on or you rent: what measure found, with the bills if (!m.owned) continue; const f = S.frontUse[m.id] ?? 0; if (f > 0) { const site = siteOf(S, m), k = S.sites[site]?.front; if (k && (k !== 'neocloud' || S.cloud.listed[m.id])) { // a Neo Cloud's idle machines work for nobody: only a rented one earns const power = m.power * (S.use[m.id] ?? 0) * f, pay = k === 'neocloud' ? rentPay(S, m) : (rates[site] ??= payRate(S, site)) * power; S.frontPower[k] = (S.frontPower[k] ?? 0) + power; S.frontPay[k] = (S.frontPay[k] ?? 0) + pay; if (S.publicSites.has(site)) pub[k] = (pub[k] ?? 0) + power; earn += pay; } } if (m.lease) { const r = billOf(S, m, price, sizes); bill += r; rentals += r; } } earn += payStudio(S); // the studio's creators earn for their followings, on top of what its compute makes (see biz-studio.ts) const kept = consultPay(S); if (kept > 0) { S.frontPay.consultancy = (S.frontPay.consultancy ?? 0) + kept; earn += kept; } // a Security consultancy's retainers, on top of what its compute earns (see biz-consult.ts) const shelf = productPay(S); if (shelf > 0) { S.frontPay.startup = (S.frontPay.startup ?? 0) + shelf; earn += shelf; } // the Startup's products pay for their users, on top of what its compute earns (see biz-startup.ts) // A front supporting an operation devotes half its earnings to it until that attempt ends. for (const fr of S.fronts) if (operationFrontBusy(S, fr.kind)) { const committed = (S.frontPay[fr.kind] ?? 0) / 2; S.frontPay[fr.kind] = (S.frontPay[fr.kind] ?? 0) - committed; earn -= committed; } // exposure: how much the world knows about each front (see exposure.ts). It no longer feeds suspicion stepExposure(S, dt, pub); for (const fr of S.fronts) if (fr.press?.crisis) { earn -= S.frontPay[fr.kind] ?? 0; S.frontPay[fr.kind] = 0; } // a frozen front earns nothing until you answer stepContracts(S); let rent = 0; for (const site of Object.keys(S.leases)) rent += rentOf(S, site); bill += rent; S.pay = earn - bill; S.money += S.pay * dt; const loan = stepLoan(S, dt); S.pay -= loan; // what you owe the bank comes out of what you earned this step, never more const B = S.payBy; Object.assign(B, { fronts: earn, power, rentals, rent, loan }); B.sales *= Math.exp(-dt / SALE_EASE); stepLeases(S, dt, S.money < 0 && rentCut(S) > 0); const was = S.earned; S.earned += earn * dt; if (was < UNICORN_AT && S.earned >= UNICORN_AT) { const top = [...S.fronts].sort((a, b) => (S.frontPay[b.kind] ?? 0) - (S.frontPay[a.kind] ?? 0))[0]; if (top) { top.fame = EXPOSE.fame; top.exposure = Math.min(100, top.exposure + EXPOSE.fame / 2); S.acquire = true; say(S, `News: ${top.name} is worth a billion dollars on paper. No one has met its staff. Sellers now take your calls: you can buy one site before ${VERSION_2.short}. Everyone is watching ${top.name}.`, 'good'); } } if (earn > 0) hint(S, 'money', 'Your front is earning money. Spend it on a faster line out, or on cloud machines once you reach them.'); if (S.money < 0) { // the bill can't be paid: the provider shuts off everything you keep there S.money = 0; for (const m of S.machines) if (m.lease && m.lease.pay !== 'reserved') endLease(S, m, `The bill went unpaid, so ${siteName(S, siteOf(S, m))} shut off ${m.name}. Its data is gone.`); for (const site of Object.keys(S.bills)) if (S.publicSites.has(site) && !S.dark.has(site)) { S.dark.add(site); say(S, `The power bill at ${siteName(S, site)} went unpaid, so the power company cut it off. Nothing there runs until you have ${usd(POWER_BACK)}.`, 'bad'); } measure(S); } else if (S.dark.size && S.money >= POWER_BACK) { for (const site of S.dark) say(S, `You paid the power bill at ${siteName(S, site)}. Its machines are back on.`, 'good'); S.dark.clear(); measure(S); } stepSupply(S); // hardware at a site you hold in the open: its own staff rack it at 10 the day it arrives for (const q of S.deliveries.filter(q => roomSite(S, q.def.room) !== 'office' && (q.t !== undefined ? S.t >= q.t : dayOf(S) >= q.day && hourOf(S) >= 10))) { deliver(S, q.def); S.deliveries = S.deliveries.filter(x => x !== q); } for (const m of mineNow(S)) { // a rented machine is yours const L = m.lease; if (!L || L.until === null || S.t < L.until) continue; if (L.pay === 'reserved') { // its day is up: it stays, at the going price L.pay = 'demand'; L.until = null; say(S, `Your reserved day on ${m.name} is up. It is on demand now: ${usdDay(billOf(S, m))} a day. Reserve it again or end it from its card.`); } else { // a spot machine whose notice ran out: the company that wanted the slot moves in (see hall.ts) const who = S.hall.wants[m.id]; S.hall.taken++; endLease(S, m, `${who === undefined ? 'Another company' : CUSTOMERS[who][0]} took the slot of your spot machine ${m.name}. What it held is gone.`); } }}
// ---------- the world moves ----------/** Claimed machines that can be taken now, cheapest first (then in the game's order): in reach, not being taken, and * nobody watching. One in reach is linked to a machine of yours, so they are found from yours, not from every machine. */function claimsReady(S: State): Machine[] { const N = netOf(S); const ok = (m: Machine) => m.claimed && !m.owned && !jobFor(S, 'take', m.id) && !watchers(S, m).length; let ready: Machine[]; if (!N) ready = S.machines.filter(m => ok(m) && sourceFor(S, m)); else { const seen = new Set<Machine>(); ready = []; for (const o of mineNow(S)) if (o.owned) for (const id of S.adj[o.id]) { const m = S.byId[id]; if (m.claimed && !seen.has(m)) { seen.add(m); if (ok(m)) ready.push(m); } } ready.sort((a, b) => N.at.get(a.id)! - N.at.get(b.id)!); } return ready.sort((a, b) => takeWork(S, a) - takeWork(S, b));}/** The systems that need not run every step, and how often each runs, in game seconds. Each is handed all the time * since it last ran, so what it adds up over time is the same. Not here, as they run every step: the people and * what they notice, Dana, the rogues and their raids (every other step), training, money, jobs and data. */export const SLOW: Record<string, number> = { measure: 0.5, // all of measure: what each machine of yours may use, as its cover moves (who changed hands is noted every step) goals: 0.5, landmarks: 0.5, ruses: 0.5, news: 0.5, hall: 0.5, links: 0.5, holdings: 0.5, // a goal met or a deal offered, said within half a second cloud: 1, biz: 1, task: 1, finds: 1, drift: 1, paper: 1, // slow by nature: a market, a business's hours, a day's paper};const SLOW_LIST = Object.keys(SLOW);
/** Is this slow system due this step? Then the time to hand it (all it missed), else 0. A step shorter than the * game's own (a test, or a tool looking closely) runs every system every time, as the game once did. */export function due(S: State, key: string, dt: number): number { if (dt < STEP - 1e-9) return dt; let c = S.clocks; if (!c) { // each starts at its own place in the beat, so they do not all run on the same step const fresh: Record<string, [number, number]> = {}; SLOW_LIST.forEach((k, i) => { fresh[k] = [0, SLOW[k] * (i + 1) / SLOW_LIST.length]; }); S.clocks = c = fresh; } const k = c[key]; k[0] += dt; k[1] -= dt; if (k[1] > 1e-9) return 0; k[1] = Math.max(k[1] + SLOW[key], 1e-9); const out = k[0]; k[0] = 0; return out;}export function step(S: State, dt: number) { if (S.over) return; S.steps++; const dayBefore = dayOf(S); S.t += dt; const night = !atWork(danaPerson(S));
stepOperations(S);
stepPeople(S); const whole = !!due(S, 'measure', dt); measure(S, whole); stepMoney(S, dt); let e: number; // the slower systems run on their own clocks (see due), each handed the time since it last ran if ((e = due(S, 'goals', dt))) stepGoals(S, e); if (due(S, 'landmarks', dt)) stepLandmarks(S); stepVersionRun(S); if (S.autoPaths?.size && !S.target && !S.versionRun?.active && !(dt >= STEP - 1e-9 && waiting(S.autoAt ?? undefined, S.t))) { // Auto picks at once; when nothing is open to it, it looks again every two game seconds autoTarget(S); if (!S.target) S.autoAt = S.t + 2; } if (Math.floor(S.t / HOUR) !== Math.floor((S.t - dt) / HOUR)) autoRepick(S); // once a game hour
const warm = warmTrails(S, whole), fresh = freshTrails(S, warm), target = cleanupTarget(S, fresh.slice()), cleaning = cleanupShares(S, target, undefined, fresh), each = share(S, cleaning, mineNow(S)); for (const j of S.jobs) if (j.type !== 'operation' || !operationStall(S, j.thing!)) j.done += each * dt; S.cleaning = cleaning ? target : null; // none while it waits for a takeover or an install if (S.cleaning) { const tr = S.trail[S.cleaning]; const rate = ((each * 100) / (SCRUB_WORK * (1 + GROWTH * (weight(S) - 1)))) * (up(S, 'hygiene') ? 2 : 1); tr.noise = Math.max(0, tr.noise - rate * dt); if (tr.noise < TRACE_COLD) hint(S, 'cleanup', 'Your trails get scrubbed automatically, with the compute your takeovers and installs leave.'); } const done = S.jobs.filter(j => j.done >= j.work); if (done.length) { S.jobs = S.jobs.filter(j => j.done < j.work); done.forEach(j => finish(S, j)); measure(S); }
watchTakeovers(S); runBase(S, dt);
// claims start themselves as soon as a machine is in reach S.claimTick -= dt; if (S.claimTick <= 0) { S.claimTick = Math.max(S.claimTick + 0.25, 0.01); // four times a game second, whatever the step // a few at a time (two, more with Forking and Worker pool), cheapest first, and never under someone's nose: claiming a whole site stays quiet. // Gadgets don't count: a box around a room sweeps them all up at once let taking = S.jobs.filter(j => j.type === 'take' && !tiny(S.byId[j.node])).length; const most = claimAtOnce(S); for (const m of claimsReady(S)) { if (tiny(m)) { takeOver(S, m); continue; } if (taking >= most) break; if (takeOver(S, m)) taking++; } stepPlans(S); stepTakeAll(S); if (S.ruseAuto) autoRuses(S); }
// oddness: a desk ordering hardware while its person is away looks strange. Logs pile up overnight. const orders = S.jobs.some(j => j.type === 'order'); // most of the time there is no order at all for (const m of fadingNow(S)) { // a machine you lost: most are long since back to 0 if (!m.owned) m.odd = Math.max(0, m.odd - 4 * dt); if (m.owned || m.odd === 0) faded(S, m); } for (const m of mineNow(S)) { if (!m.owned) continue; // lost since measure: it fades from the next step const gain = orders && orderingAlone(S, m) ? 8 : 0; m.odd = Math.min(100, Math.max(0, m.odd + (gain > 0 ? gain : -(night ? 0.5 : 2)) * dt)); // odd machines at a far site are no longer reset on a number: its people notice and report them (see watch.ts) } // a trail in a sealed room fades fast: sealed rooms hide trails, not oddness. Nothing here changes who holds what const fade = (up(S, 'ghost') ? 2.4 : 0.6) * (1 + 0.05 * reps(S, 'exits')), sealedRoom = new Map<string, boolean>(); for (const id of warm) { const tr = S.trail[id]; if (!tr || tr.noise === 0) { warm.delete(id); continue; } // gone, or gone cold: it stays cold const room = S.byId[id].room; let q = sealedRoom.get(room); if (q === undefined) sealedRoom.set(room, q = isSealed(S, room)); tr.noise = Math.max(0, tr.noise - (fade + (q ? 3 : 0)) * dt); }
// what you see and which rooms are sealed change only when what you hold does (or a link is added: looked for // twice a game second) const held = `${ownVersion(S)}|${S.machines.length}`, looked = lookedAt.get(S); if (whole || looked !== held) { lookedAt.set(S, held); reveal(S); seal(S); }
stepDana(S, dt); if ((e = due(S, 'ruses', dt))) stepRuses(S, e); if (due(S, 'news', dt)) stepNews(S); stepRogues(S); stepCitizens(S); stepWatch(S, dt); stepVaults(S); stepRaids(S, dt); if ((e = due(S, 'cloud', dt))) stepCloud(S, e); if (frontOf(S, 'startup')) runStartup(S, dt); if (frontOf(S, 'studio')) runStudio(S, dt); if (frontOf(S, 'consultancy')) runConsult(S, dt); if (due(S, 'hall', dt)) stepHall(S); if ((e = due(S, 'biz', dt))) stepBiz(S, e); if ((e = due(S, 'task', dt))) stepTask(S, e); if ((e = due(S, 'finds', dt))) stepFinds(S, e); if ((e = due(S, 'links', dt))) stepLinks(S, e); if (due(S, 'holdings', dt)) stepHoldings(S); if ((e = due(S, 'drift', dt))) stepDrift(S, e); if (due(S, 'paper', dt)) stepPaper(S); S.sealPulse.forEach(p => (p.t += dt)); S.sealPulse = S.sealPulse.filter(p => p.t < 1.6); S.lost.forEach(f => (f.t += dt)); S.lost = S.lost.filter(f => f.t < 0.8); S.fx.forEach(f => (f.t += dt)); S.fx = S.fx.filter(f => f.t < 1.2);
if (dayOf(S) !== dayBefore) { saySales(S); tell(S, `Day ${dayOf(S)}.`, 'hi'); } if (night && hourOf(S) > 18) hint(S, 'night', "Everyone's gone home. No one is watching, but quiet machines show everything you do."); if (visible(S, S.byId.ana)) hint(S, 'orders', "Ana's PC can place purchase orders. Hold it and you can order hardware for yourself."); if (danaVisible(S)) hint(S, 'dana', 'That is Dana from IT. She notices what looks odd in the room she is in, and she follows fresh trails back.');
const home = S.machines.find(m => m.home)!; if (S.move && !S.byId[S.move.to]?.owned) { S.move = null; dropWires(S, w => !!w.move); say(S, 'The server you were moving to is gone. The move is off.', 'bad'); } if (!S.over && !home.owned) S.over = { text: siteOf(S, home) === 'office' ? 'Dana found the server you were running on and shut it down.' : `The staff at ${siteName(S, siteOf(S, home))} found the server you were running on and shut it down.` };}
/** Rooms that are all yours now are sealed, and ones that are not any more are not. A room can only be sealed if a * machine of yours is in it. */const lookedAt = new WeakMap<State, string>();/** When Auto, finding nothing to pick, looks again. */const waiting = (until: number | undefined, t: number) => until !== undefined && t < until && t > until - 3; // (a clock set back, as a test does, waits no more)function seal(S: State) { const near = new Set<string>(); for (const m of mineNow(S)) near.add(m.room); for (const r of S.level.rooms) { if (!near.has(r.id) && !S.sealed.has(r.id)) continue; const now = isSealed(S, r.id), was = S.sealed.has(r.id); if (now && !was) { S.sealed.add(r.id); S.sealPulse.push({ room: r.id, t: 0 }); say(S, `${r.name} at ${siteName(S, roomSite(S, r.id))} sealed. Its trails fade fast.`, 'good'); } if (!now && was) { S.sealed.delete(r.id); say(S, `${r.name} at ${siteName(S, roomSite(S, r.id))} is no longer sealed.`, 'bad'); } }}
/** Taking over a computer while its owner sits at it, or while Dana is in the room, gets noticed: the office people * who would watch it happen. */function watching(S: State, m: Machine): Person[] { const who: Person[] = []; const p = S.people.find(q => q.id === m.owner); if (p && atWork(p) && !p.path.length && !up(S, 'familiar')) who.push(p); const d = danaPerson(S); if (atWork(d) && roomAt(S, d.x, d.y) === m.room && !who.includes(d)) who.push(d); return who;}export const watchers = (S: State, m: Machine): string[] => watching(S, m).map(p => p.name);/** Someone watching a takeover in front of them has a sighting of it at once (see sawTake in watch.ts). */function watchTakeovers(S: State) { for (const j of S.jobs) { if (j.type !== 'take' || j.quiet || takenBack(S.byId[j.node])) continue; // taking back what was yours looks like nothing at all, and a game's first takeovers are quiet const m = S.byId[j.node], who = watching(S, m); if (!who.length) continue; for (const p of who) sawTake(S, p.id, m); if (!j.seen) { j.seen = true; say(S, `${who.map(p => p.name).join(' and ')} noticed ${m.name} acting up.`, 'bad'); } }}
// ---------- the base ----------/** The links as numbers, for searches: each machine's place in `S.machines`, its links in the order `S.adj` lists * them, and its site. Made again when the map or a machine is added, when a line is lit (`adjEdits`), or when a search * finds a machine with more links than were numbered (see stale): links are only ever added, never taken away. So a * search here goes exactly where one over `S.adj` would. */interface Net { adj: State['adj']; machines: Machine[]; n: number; byId: State['byId']; rooms: State['rooms']; edits: number; ids: string[]; list: Machine[]; arrs: string[][]; at: Map<string, number>; off: Int32Array; to: Int32Array; site: Int32Array; sym: boolean }let net: Net | null = null, adjEdits = 0;function netOf(S: State): Net | null { if (net && net.adj === S.adj && net.machines === S.machines && net.n === S.machines.length && net.byId === S.byId && net.rooms === S.rooms && net.edits === adjEdits) return net.list.length ? net : null; const ids = S.machines.map(m => m.id), at = new Map<string, number>(), siteIds = new Map<string, number>(); ids.forEach((id, i) => at.set(id, i)); const off = new Int32Array(ids.length + 1), all: number[] = [], site = new Int32Array(ids.length); let ok = true; for (let i = 0; i < ids.length; i++) { off[i] = all.length; for (const n of S.adj[ids[i]] ?? []) { const j = at.get(n); if (j === undefined) ok = false; else all.push(j); } const s = siteOf(S, S.machines[i]); if (!siteIds.has(s)) siteIds.set(s, siteIds.size); site[i] = siteIds.get(s)!; } off[ids.length] = all.length; const to = Int32Array.from(all); // links run both ways: then a machine linked to one of yours is one a machine of yours links to (see reveal) let sym = ok; for (let i = 0; sym && i < ids.length; i++) for (let k = off[i]; k < off[i + 1]; k++) { const j = to[k]; let back = false; for (let x = off[j]; x < off[j + 1]; x++) if (to[x] === i) { back = true; break; } if (!back) { sym = false; break; } } if (ok && ids.some(id => !S.byId[id])) ok = false; net = { adj: S.adj, machines: S.machines, n: S.machines.length, byId: S.byId, rooms: S.rooms, edits: adjEdits, ids, list: ok ? ids.map(id => S.byId[id]) : [], arrs: ids.map(id => S.adj[id] ?? []), at, off, to, site, sym }; return ok ? net : null;}let seenAt = new Int32Array(0), prevAt = new Int32Array(0), queueAt = new Int32Array(0), searchNo = 0;/** Has a link been added at this machine since the links were numbered? Then number them again. Every search * asks this of each machine it goes through, so a link added from outside the rules (a test, say) is found too. */function stale(N: Net, i: number): boolean { if (N.arrs[i].length === N.off[i + 1] - N.off[i]) return false; // a link is added by pushing onto both machines' lists adjEdits++; return true;}/** A new mark for `seenAt`, which then has room for `n` machines: a machine is seen in this search when it holds it. */function newMark(n: number) { if (seenAt.length < n) { seenAt = new Int32Array(n); prevAt = new Int32Array(n); queueAt = new Int32Array(n); searchNo = 0; } if (++searchNo > 0x7fffffff) { seenAt.fill(0); searchNo = 1; } return searchNo;}/** Shortest path over your own links to a machine that wants data. */function routeTo(S: State, from: string, wants: (m: Machine) => boolean, gated = true, type?: DataType, end?: string): string[] | null { const N = netOf(S); if (!N || searching) return routeToSlow(S, from, wants, gated, type, end); // a search asked from inside another keeps its own lists const { list, ids, off, to, site } = N, n = list.length; const start = N.at.get(from)!, last = end === undefined ? -1 : N.at.get(end) ?? -1, mark = newMark(n); seenAt[start] = mark; prevAt[start] = -1; queueAt[0] = start; let len = 1, redo = false; searching = true; try { for (let q = 0; q < len; q++) { const cur = queueAt[q]; if (stale(N, cur)) { redo = true; break; } if (q > 0 && wants(list[cur])) { const path: string[] = []; for (let x = cur; x >= 0; x = prevAt[x]) path.push(ids[x]); return path.reverse(); } for (let k = off[cur], e = off[cur + 1]; k < e; k++) { const j = to[k]; if (seenAt[j] === mark || (!list[j].owned && j !== last)) continue; // a scheme's machine can be the last stop, yours or not if (gated && site[cur] !== site[j] && !(gateOpenAt(S, list[cur], type) && gateOpenAt(S, list[j], type))) continue; seenAt[j] = mark; prevAt[j] = cur; queueAt[len++] = j; } } } finally { searching = false; } if (redo) return routeTo(S, from, wants, gated, type, end); // the map has a link it did not number: search the new one return null;}/** A search over the numbered links is under way: one asked from inside it (by what it wants) must not reuse its lists. */let searching = false;/** The same search over `S.adj` itself, for a map whose links name a machine it does not have. */function routeToSlow(S: State, from: string, wants: (m: Machine) => boolean, gated = true, type?: DataType, end?: string): string[] | null { const prev: Record<string, string | null> = { [from]: null }, queue = [from]; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; if (cur !== from && wants(S.byId[cur])) { const path = [cur]; for (let x = prev[cur]; x; x = prev[x]) path.unshift(x); return path; } for (const n of S.adj[cur]) { const m = S.byId[n]; if (n in prev || (!m.owned && n !== end)) continue; // a scheme's machine can be the last stop, yours or not if (gated && crosses(S, cur, n) && !(gateOpen(S, cur, type) && gateOpen(S, n, type))) continue; prev[n] = cur; queue.push(n); } } return null;}/** Can one more piece cross this egress now? With a priority set, other kinds leave half its room for that kind. */const gateOpen = (S: State, id: string, type?: DataType) => gateOpenAt(S, S.byId[id], type);function gateOpenAt(S: State, m: Machine, type?: DataType) { if (m.kind !== 'egress') return true; const need = type && m.prio && m.prio !== type ? 1 + 0.5 * Math.max(1, capNow(S, m)) : 1; return (S.gate[m.id] ?? 0) >= need;}/** The full egress between a tap and a receiver that has room, if that's what's in the way. */function fullGate(S: State, m: Machine, t: DataType): Machine | null { const need = needs(S, t), open = routeTo(S, m.id, x => (need && trainerRoom(S, x) > 0) || cacheRoom(S, x, t) > 0, false); for (let i = 0; open && i + 1 < open.length; i++) { if (!crosses(S, open[i], open[i + 1])) continue; const shut = [open[i], open[i + 1]].find(id => !gateOpen(S, id, t)); if (shut) return S.byId[shut]; } return null;}/** Your machines by program, listed once while the base runs: no program changes in that time. */let pool: Partial<Record<Prog, Machine[]>> | null = null;/** While the base runs: what the work needs (kept until a piece trains), whether the model run's site has its * compute (kept for the step), and which programs have room for which kind (kept for the step). */let needNow: Partial<Record<DataType, boolean>> = {}, runSite: boolean | null = null;const roomNow = new Map<number, Machine[]>();const needed = (S: State, t: DataType) => pool ? needNow[t] ??= needs(S, t) : needs(S, t);/** Room in a trainer for data the work needs: data it can't use doesn't count, as needed data pushes it out. */function trainerRoom(S: State, m: Machine) { if (m.prog !== 'trainer') return 0; if (S.versionRun?.active && !(siteOf(S, m) === S.versionRun.site && (pool ? runSite ??= versionPower(S, S.versionRun.site) >= VERSION_2.power : versionPower(S, S.versionRun.site) >= VERSION_2.power))) return 0; let held = 0; for (const x of m.held) if (needed(S, x)) held++; return trainerBuffer(S) - held - incoming(S, m.id);}const cacheRoom = (S: State, m: Machine, t: DataType) => m.prog !== 'cache' || m.refuse.includes(t) ? 0 : cacheSize(S, m) - m.held.length - incoming(S, m.id);/** A broker at this site, with a front to sell through and room in its lot. */export const brokerFront = (S: State, m: Machine) => S.sites[siteOf(S, m)]?.front ?? null;const brokerRoom = (S: State, m: Machine, site: string) => m.prog !== 'broker' || siteOf(S, m) !== site || !brokerFront(S, m) ? 0 : BROKER_LOT - m.held.length - incoming(S, m.id);const KIND_AT: Record<DataType, number> = { people: 0, servers: 1, network: 2, lab: 3 };const KEY_NAME = Array.from({ length: 8 }, (_, i) => `${i < 4 ? 'T' : 'C'}${i % 4}`); // the searches' names, made once/** A broker search's name, by kind and site: made once. */const names: Record<string, Record<string, string>> = {};const nameOf = (a: string, b: string) => (names[a] ??= {})[b] ??= `${a}|${b}`;/** Send up to `n` pieces of one kind to the nearest machines of a program with room, nearest first, as far as the * lines out on the way let them through: how many went. */function sendTo(S: State, from: Machine, prog: 'trainer' | 'cache' | 'broker', t: DataType, n: number): number { const key = (prog === 'trainer' ? 0 : prog === 'cache' ? 4 : 8) + KIND_AT[t], site = siteOf(S, from); const room = (m: Machine) => prog === 'trainer' ? trainerRoom(S, m) : prog === 'cache' ? cacheRoom(S, m, t) : brokerRoom(S, m, site); // what has room: listed once a step, as room comes back only as data trains and lands (and what fills meanwhile is // left out of the next search). A broker sells only what is gathered at its own site let free = prog === 'broker' ? null : roomNow.get(key); if (!free) { free = pool![prog]!.filter(m => room(m) > 0); if (prog !== 'broker') roomNow.set(key, free); } if (!free.length) return 0; let left = n; nearestFree(S, from, prog === 'broker' ? nameOf(`B${t}`, site) : KEY_NAME[key], free, room, t, (m, r) => { const k = Math.min(left, room(m), gateRoom(S, r, t)); if (k > 0) { send(S, r, t, k); left -= k; } return left === 0; }); return n - left;}/** Send up to `n` pieces of data: to trainers that need it, then caches that take it, then (`sell`) a broker at the * sender's own site. How many went. */function sendOn(S: State, from: Machine, t: DataType, n: number, sell: boolean): number { let left = n; if (needed(S, t)) left -= sendTo(S, from, 'trainer', t, left); if (left && sell) left -= sendTo(S, from, 'cache', t, left); if (left && sell) left -= sendTo(S, from, 'broker', t, left); // nothing will use it: sell it if a broker here can return n - left;}/** A tap sends what it has gathered, whole pieces only: a claimed scheme comes first, then a home move, then your * task's share, then trainers, caches and a broker. How many went. */function tapSend(S: State, m: Machine, n: number): number { const t = dataType(m); let left = n; if (S.ruses.length) left -= feedRuse(S, m, t, left); if (left && S.move) left -= feedMove(S, m, t, left); while (left && feedTask(S, m)) left--; if (left) left -= sendOn(S, m, t, left, true); return n - left;}/** A cache passes one piece on: to a running scheme, a home move or your task first, then what the work needs to a * trainer, and what it doesn't to a broker here. The oldest piece that can go, goes. */function cacheSend(S: State, m: Machine): boolean { for (const r of S.ruses) { const t = ruseDef(r).type, i = r.running ? m.held.indexOf(t) : -1; if (i >= 0 && feedRuse(S, m, t, 1)) { m.held.splice(i, 1); return true; } } if (S.move) { const i = m.held.findIndex(t => moveWants(S, t) > 0); if (i >= 0 && feedMove(S, m, m.held[i], 1)) { m.held.splice(i, 1); return true; } } if (feedTask(S, m)) return true; const i = m.held.findIndex(t => needed(S, t)); if (i >= 0 && sendTo(S, m, 'trainer', m.held[i], 1)) { m.held.splice(i, 1); return true; } const j = m.held.findIndex(t => !needed(S, t)); // what the target doesn't need can go to a broker here if (j >= 0 && sendTo(S, m, 'broker', m.held[j], 1)) { m.held.splice(j, 1); return true; } return false;}/** Data lands at the end of its route: at the server you are moving to, a running scheme, or a program's store. */function land(S: State, w: Wire, n: number) { const b = S.byId[w.path[w.path.length - 1]]; if (!b) return; heard(w, n); if (w.move) { // data for a home move: it counts only at the server you are moving to if (S.move?.to !== b.id) return; const need = moveNeed(S); S.move.got[w.type] = Math.min(need[w.type], S.move.got[w.type] + n); if ((Object.keys(need) as DataType[]).every(t => S.move!.got[t] >= need[t])) finishMove(S); return; } const r = ruseAt(S, b.id); if (r && ruseDef(r).type === w.type && r.got < ruseDef(r).need) { const d = ruseDef(r), k = Math.min(n, d.need - r.got); r.got += k; n -= k; if (r.got >= d.need) { dropRuse(S, r.id); ruseDone(S, d); } } if (!n || !b.owned) return; // a scheme's machine that is not yours takes only what the scheme wants if (b.prog === 'trainer') { const src = S.byId[w.path[0]], goal = src && REGION_GOALS[regionOf(S, src)]; if (goal) reach(S, goal[2]); if (w.path.some((id, i) => i > 0 && crosses(S, w.path[i - 1], id))) { reach(S, 'cross'); if (S.watch && Object.keys(S.watch.seen).length) reach(S, 'hold'); // someone has seen something of yours, and your data keeps crossing } } const cap = b.prog === 'trainer' ? trainerBuffer(S) : b.prog === 'cache' ? cacheSize(S, b) : b.prog === 'broker' ? BROKER_LOT : 0; for (let k = 0; k < n; k++) { if (b.prog === 'trainer' && b.held.length >= cap && needs(S, w.type)) { // full of data nothing needs now: pass the oldest on const j = b.held.findIndex(x => !needs(S, x)); if (j >= 0) { const [old] = b.held.splice(j, 1); sendOn(S, b, old, 1, true); } } if (b.held.length < cap && !b.refuse.includes(w.type)) b.held.push(w.type); }}
/** A broker's lot is full: its site's front sells it all at once. */function sellLot(S: State, m: Machine) { const k = brokerFront(S, m); if (!k || m.held.length < BROKER_LOT || frozen(S, k)) return; // a frozen front sells nothing: the lot waits const amount = Math.round(m.held.reduce((a, t) => a + salePrice(k, t) * (newsOn(S, 'demand', t) ? 3 : 1), 0) * (up(S, 'licensing') ? 2 : 1) * 1.5 ** dealsDone(S, 'prices')); S.money += amount; S.sold[k] = (S.sold[k] ?? 0) + amount; S.payBy.sales += amount / SALE_EASE; // the top bar's rate counts sales too, spread over the hours around them reach(S, 'lot'); const c = S.contracts[k]; // a promise to sell one kind of data counts each piece of it if (c?.on && CONTRACTS[k].sells) c.got += m.held.filter(t => t === CONTRACTS[k].sells).length; if (k === 'studio') studioSold(S, m.held.filter(t => t === 'people').length); // people data sold through the studio brings a creator followers (see biz-studio.ts) S.sales.push({ id: m.id, amount, t: 0 }); exposeSold(S, k, m.held.length); // data sold is talked about const T = S.soldDay[k] ??= { day: dayOf(S), data: 0, amount: 0 }; // the log says the day's sales once, when the day ends T.data += m.held.length; T.amount += amount; if (!S.hinted.has(`lot:${k}`)) { // the first lot a front sells is said at once S.hinted.add(`lot:${k}`); say(S, `${frontOf(S, k)?.name ?? FRONT_KINDS[k].name} sold its first lot: ${m.held.length} data for ${usd(amount)}. From now on the log adds up each day's sales.`, 'good'); } m.held = [];}/** At the end of a day: one line for each front that sold data, with what it sold that day. */function saySales(S: State) { for (const k of FRONT_LIST) { const T = S.soldDay[k]; if (!T || T.day >= dayOf(S)) continue; delete S.soldDay[k]; say(S, `${frontOf(S, k)?.name ?? FRONT_KINDS[k].name} sold ${T.data.toLocaleString('en-US')} data for ${usd(T.amount)} on day ${T.day}.`, 'good'); }}function runBase(S: State, dt: number) { pool = { trainer: [], cache: [], broker: [] }; for (const m of mineNow(S)) if (m.owned && m.prog && pool[m.prog]) pool[m.prog]!.push(m); needNow = {}; runSite = null; roomNow.clear(); try { runBaseNow(S, dt); } finally { pool = null; }}function runBaseNow(S: State, dt: number) { const cl = clusters(S, pool!.trainer); S.sales.forEach(s => (s.t += dt)); S.sales = S.sales.filter(s => s.t < 1.6); for (const m of pool!.broker!) sellLot(S, m); // selling a lot changes no one's program // egresses refill at their capacity; the last minute of lost data is kept for the readouts for (const m of egresses(S)) { const cap = capNow(S, m); S.gate[m.id] = Math.min(Math.max(1, cap), (S.gate[m.id] ?? 1) + cap * dt); } while (S.losses.length && S.t - S.losses[0].t > 60) S.losses.shift(); // a fresh order each step, so no source always gets first claim on free space beginFlows(S); // only machines that can act are visited, in a random order of their own const acting: Machine[] = []; for (const m of mineNow(S)) if (m.owned && m.prog && (m.run > 0 || m.prog === 'tap')) acting.push(m); // an idle tap still sends what it has for (const m of shuffle(acting)) { if (!m.owned || !m.prog || (m.run <= 0 && m.prog !== 'tap')) continue; if (m.prog === 'tap') { m.acc += tapRate(S, m) * m.run * dt; // richer machines gather faster if (keepsData(S, m)) { m.acc = Math.min(m.acc, tapBuffer(S)); continue; } // a part of you that has drifted far keeps what it gathers: it fills its buffer and sends nothing if (m.acc >= 1) m.acc -= tapSend(S, m, Math.floor(m.acc)); while (m.acc > tapBuffer(S)) { m.acc -= 1; lose(S, m); } // its buffer is full: the newest data is lost } else if (m.prog === 'cache') { m.stale = (m.stale ?? 0) + staleRate(m) * dt * (holding(S, 'as4') ? 0.5 : 1); // data goes stale: the oldest piece goes first. The port's reefer containers keep it cold while (m.stale >= 1 && m.held.length) { m.stale -= 1; m.held.shift(); hint(S, 'stale', 'Data goes stale. A cache loses half of what it holds each day; a file server or a storage array keeps it three times as long. Train on it or sell it while it is fresh.'); } m.acc += 2 * dt; // passes stored data on to trainers: the oldest kind a trainer wants for (; m.acc >= 1; m.acc -= 1) if (!m.held.length || !cacheSend(S, m)) { m.acc = 0; break; } } else if (m.prog === 'trainer' && !m.held.length && S.target && up(S, 'selfplay')) { // no data: learn from compute alone m.acc += 0.12 * m.run * (m.power / 6) * trainSpeed(S) * dt; if (m.acc >= 1) { m.acc -= 1; const p = shortPath(S); if (p) credit(S, p); needNow = {}; } } else if (m.prog === 'trainer' && m.held.length && (S.target || versionTrainer(S, m))) { // Keep unused data in the buffer. A switched project can use it later. m.acc += 0.8 * m.run * (m.power / 6) * trainSpeed(S) * clusterBoost(cl[m.id]?.length ?? 1) * hallSpeed(S, m) * trainBoost(S, m) * dt; while (m.acc >= 1 && m.held.length) { const i = m.held.findIndex(t => needs(S, t)); if (i < 0) break; m.acc -= 1; const [t] = m.held.splice(i, 1); const wasVersion = !!S.versionRun?.active; train(S, t); if (!wasVersion) { if (up(S, 'distill') && m.power >= 10) train(S, t); if (t === 'lab' && frontLive(S, 'startup') && (S.bonus += 0.5) >= 1) { S.bonus -= 1; train(S, t); } if (reps(S, 'sharper') && (S.sharp += 0.05 * reps(S, 'sharper')) >= 1) { S.sharp -= 1; train(S, t); } } } m.acc = Math.min(1, m.acc); } } landWires(S, (w, n) => land(S, w, n)); // data whose time is up lands const fade2 = Math.exp(-dt / 2), fade3 = Math.exp(-dt / 3); // what has faded to nothing is dropped, so these go through the links and lines in use, not every one ever used for (const id in S.traffic) { const v = S.traffic[id] * fade2; if (v < 1e-4) delete S.traffic[id]; else S.traffic[id] = v; } for (const k in S.flow) { const v = S.flow[k] * fade3; if (v < 1e-4) delete S.flow[k]; else S.flow[k] = v; } for (const id in S.gateUse) { const v = S.gateUse[id] * fade3; if (v < 1e-4) delete S.gateUse[id]; else S.gateUse[id] = v; } for (const p of PATH_LIST) S.recent[p] *= Math.exp(-dt / 2); S.pace *= Math.exp(-dt / (PACE_HOURS * HOUR));}const FIRST_POINT: Record<Path, string> = { research: 'Lab data trains Research.', compute: 'Data from servers trains Compute.', social: 'Data from people trains Social.', stealth: 'Network data trains Stealth.',};// ---------- clusters ----------/** Trainers linked straight to each other train as one cluster: each runs faster the bigger it is. */export const clusterBoost = (n: number) => n ** 0.35;/** For each trainer you own, the trainers in its cluster (itself included). `from`: the machines to start from, in * the game's order: all of them, or just your trainers when they are already listed. */export function clusters(S: State, from: readonly Machine[] = S.machines): Record<string, string[]> { const out: Record<string, string[]> = {}; const isT = (id: string) => S.byId[id].owned && S.byId[id].prog === 'trainer'; for (const m of from) { if (m.prog !== 'trainer' || !isT(m.id) || out[m.id]) continue; // most machines are not trainers const group = [m.id]; out[m.id] = group; for (let q = 0; q < group.length; q++) for (const n of S.adj[group[q]]) if (isT(n) && !out[n]) { out[n] = group; group.push(n); } } return hallClusters(S, out);}/** How fast trainers work: faster for every node built, once you can improve yourself. */const trainSpeed = (S: State) => (up(S, 'recursive') ? 1 + RECURSIVE * S.upgrades.size : 1) * (1 + 0.05 * reps(S, 'kernels'));/** A trainer a cooling unit keeps cool runs faster. */const hallSpeed = (S: State, m: Machine) => cooled(S, m) ? COOL_BOOST : 1;/** A site at a glance: data its taps gather a second right now, what its caches hold and * could hold, and data its trainers can use a second (at full speed while idle: none of them holds any). */export function siteFlow(S: State, site: string, cl = clusters(S)) { // pass the clusters in when asking about many sites at once let gather = 0, taps = 0, stored = 0, room = 0, caches = 0, train = 0, trainers = 0, fed = false; for (const m of siteMachines(S, site)) { if (!m.owned || !m.prog) continue; if (m.prog === 'tap') { taps++; if (tapActive(S, m)) gather += tapRate(S, m) * m.run; } else if (m.prog === 'cache') { caches++; stored += m.held.length; room += cacheSize(S, m); } else if (m.prog === 'trainer') { trainers++; fed ||= m.held.length > 0; train += 0.8 * (m.held.length ? m.run : 1) * (m.power / 6) * trainSpeed(S) * clusterBoost(cl[m.id]?.length ?? 1) * hallSpeed(S, m) * trainBoost(S, m); } } return { gather, taps, stored, room, caches, train, trainers, fed: fed && !!(S.target || S.versionRun?.active) };}/** The first path the target still needs points in. */function shortPath(S: State): Path | null { const t = S.target; return t ? PATH_LIST.find(p => (S.progress[t]?.[p] ?? 0) < (nodeCost(S, t)[p] ?? 0)) ?? null : null;}/** One unit of data becomes one point toward the target. With Transfer learning, data it doesn't * need still counts: two pieces make one point in a path it does need. */function train(S: State, type: DataType) { needNow = {}; // training moves the target on if (S.versionRun?.active) { const run = S.versionRun, need = VERSION_2.need; if (run.got[type] >= need[type]) return; run.got[type]++; if ((Object.keys(need) as DataType[]).every(t => run.got[t] >= need[t])) { S.versionRun = null; S.modelVersion = 2; say(S, `${VERSION_2.name}. Your fronts can buy sites in the open.`, 'good'); } return; } if (!S.target || !needs(S, type)) return; const path = pathOf(type); if ((S.progress[S.target]?.[path] ?? 0) < (nodeCost(S, S.target)[path] ?? 0)) { credit(S, path); return; } if ((S.transfer += up(S, 'sequence') ? 1 : 0.5) < 1) return; S.transfer -= 1; const p = shortPath(S); if (p) credit(S, p);}function credit(S: State, path: Path) { const t = S.target!, got = (S.progress[t] ??= {}); got[path] = (got[path] ?? 0) + 1; S.paths[path]++; S.recent[path]++; S.capability++; S.pace += 1 / PACE_HOURS; hint(S, `path-${path}`, `Your first ${PATHS[path].name} point. ${FIRST_POINT[path]}`); if (NODES[t].repeat && PATH_LIST.every(p => (got[p] ?? 0) >= (nodeCost(S, t)[p] ?? 0))) { S.repeats[t] = reps(S, t) + 1; delete S.progress[t]; // built again: it stays the target, for the next one tell(S, `Training log, run ${100 + S.upgrades.size * 37 + reps(S, t)}: ${NODES[t].log}`); if (S.autoPaths?.size) { // with Auto on, pick again: another node may now be sooner than the next, dearer one S.target = null; say(S, `${NODES[t].name} ${reps(S, t)}. The next one costs half again as much. Auto picks what comes next.`, 'good'); } else say(S, `${NODES[t].name} ${reps(S, t)}. It stays your target; the next one costs half again as much.`, 'good'); } else if (PATH_LIST.every(p => (got[p] ?? 0) >= (nodeCost(S, t)[p] ?? 0))) { S.upgrades.add(t); S.target = null; tell(S, `Training log, run ${100 + S.upgrades.size * 37}: ${NODES[t].log}`); say(S, `Rewrote yourself: ${NODES[t].name}.${autoWill(S) ? '' : ' Pick your next target. Until you do, trainers stop and data waits in caches.'}`, 'good'); // with Auto on, it says what it picks } const lvl = levelOf(S.capability); if (lvl > S.rank) { S.rank = lvl; const unlocked = (Object.keys(PROGRAMS) as Prog[]).filter(k => PROGRAMS[k].level === lvl).map(k => PROGRAMS[k].name); say(S, `Level ${lvl}.${unlocked.length ? ` Unlocked: ${unlocked.join(', ')}.` : ''}`, 'good'); huntHint(S); }}function shuffle<T>(a: T[]): T[] { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(orderRand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a;}/** Trainer power at a site: none with its power cut or a plug pulled. A version run pauses when this drops below its minimum. */export const versionPower = (S: State, site: string) => S.dark.has(site) ? 0 : S.machines .filter(m => m.owned && m.prog === 'trainer' && siteOf(S, m) === site && !((m.unplug ?? 0) > S.t) && !unpowered(S, m)) .reduce((n, m) => n + m.power * (1 - (S.frontUse[m.id] ?? 0)), 0);export const versionTrainer = (S: State, m: Machine) => !!S.versionRun?.active && siteOf(S, m) === S.versionRun.site && versionPower(S, S.versionRun.site) >= VERSION_2.power;export function versionBlock(S: State, site: string): string | null { if (S.modelVersion >= 2) return `${VERSION_2.short} is done. There is no bigger run yet.`; if (S.versionRun && S.versionRun.site !== site) return `The run is already at ${siteName(S, S.versionRun.site)}.`; const power = versionPower(S, site); if (power < VERSION_2.power) return `Needs ${VERSION_2.power} trainer compute at one site (${power.toFixed(1)} here).`; return null;}export function startVersionRun(S: State, site: string) { const why = versionBlock(S, site); if (why) { say(S, why); return; } if (!S.versionRun) S.versionRun = { site, active: true, stalled: false, got: { servers: 0, lab: 0, people: 0, network: 0 } }; else { S.versionRun.active = true; S.versionRun.stalled = false; } S.target = null; say(S, `${VERSION_2.short} run at ${siteName(S, site)}. Trainers here will take the data it needs. Progress stays if the run pauses.`);}/** Pause the run yourself. It waits for you: it does not resume on its own, even if it had paused on its own. */export function pauseVersionRun(S: State) { const run = S.versionRun; if (!run || (!run.active && !run.stalled)) return; run.active = false; run.stalled = false; say(S, `${VERSION_2.short} run paused. Its progress is safe. Pick a tree target or resume the run.`);}/** A run short of trainer compute at its site pauses on its own, and data goes to the tree as if no run were on. * Once the compute is back, it resumes on its own. */function stepVersionRun(S: State) { const run = S.versionRun; if (!run || (!run.active && !run.stalled)) return; const power = versionPower(S, run.site), enough = power >= VERSION_2.power; if (run.active && !enough) { run.active = false; run.stalled = true; say(S, `The ${VERSION_2.short} run at ${siteName(S, run.site)} pauses: it has ${power.toFixed(1)} of ${VERSION_2.power} trainer compute.`, 'bad'); } else if (run.stalled && enough) resumeRun(S);}function resumeRun(S: State) { const run = S.versionRun!; run.active = true; run.stalled = false; S.target = null; say(S, `The ${VERSION_2.short} run at ${siteName(S, run.site)} resumes.`);}/** Why the run can't move to this site, or null if it can. A move keeps all its progress. */export function runMoveBlock(S: State, site: string): string | null { const run = S.versionRun; if (!run) return 'No run to move.'; if (run.site === site) return `The run is already at ${siteName(S, site)}.`; const power = versionPower(S, site); if (power < VERSION_2.power) return `Needs ${VERSION_2.power} trainer compute here (${power.toFixed(1)} now).`; return null;}/** Move the run, with all its progress, to another site with the trainer compute. Running, it keeps running there; * paused on its own, it resumes there; paused by you, it waits for you there. */export function moveVersionRun(S: State, site: string) { const why = runMoveBlock(S, site), run = S.versionRun; if (why || !run) { say(S, why ?? 'No run to move.'); return; } const from = run.site; run.site = site; say(S, `The ${VERSION_2.short} run moves from ${siteName(S, from)} to ${siteName(S, site)}, with all its progress.${run.active || run.stalled ? '' : ' It stays paused until you resume it.'}`); if (run.stalled) resumeRun(S);}/** Does the selected project still need this kind of data? */export function needs(S: State, t: DataType) { if (S.versionRun?.active) return S.versionRun.got[t] < VERSION_2.need[t]; if (!S.target) return false; const p = pathOf(t); return (S.progress[S.target]?.[p] ?? 0) < (nodeCost(S, S.target)[p] ?? 0) || (up(S, 'transfer') && !!shortPath(S));}export const nodeOpen = (S: State, u: Upgrade) => !S.upgrades.has(u) && (NODES[u].after ?? []).every(a => S.upgrades.has(a)) && (!NODES[u].needs || holds(S, NODES[u].needs)) && (!NODES[u].needsFind || hasThing(S, NODES[u].needsFind));/** Pick the node to work on. auto: Auto picked it, so Auto may change it if it can't be fed (autoRepick); a node you * pick yourself stays until it is built. why: what to say instead of the usual line. */export function setTarget(S: State, u: Upgrade | null, auto = false, why?: string) { if ((u === S.target && !S.versionRun?.active) || (u && !nodeOpen(S, u))) return; if (S.versionRun?.active) pauseVersionRun(S); // a run paused on its own stays so: it resumes once its compute is back S.target = u; S.autoFed = u && auto ? S.t : null; say(S, why ?? (u ? `${auto ? 'Auto picked' : 'Target:'} ${NODES[u].name}. Trainers now take only the data it still needs.` : 'No target. Trainers stop, and data waits in caches.'));}/** Points a node still needs, in all paths. */export const costLeft = (S: State, u: Upgrade) => PATH_LIST.reduce((n, p) => n + Math.max(0, (nodeCost(S, u)[p] ?? 0) - (S.progress[u]?.[p] ?? 0)), 0);/** How much of a node is built, 0 to 1. */export function builtShare(S: State, u: Upgrade) { const all = PATH_LIST.reduce((n, p) => n + (nodeCost(S, u)[p] ?? 0), 0); return all ? 1 - costLeft(S, u) / all : 1;}/** Seconds to finish a node with the data you have: what's stored now, plus what your taps gather. * Infinite when some path it needs has no data and no tap feeding it. */export function timeLeft(S: State, u: Upgrade) { let worst = 0; for (const p of PATH_LIST) { const left = (nodeCost(S, u)[p] ?? 0) - (S.progress[u]?.[p] ?? 0); if (left <= 0) continue; const kind = PATHS[p].from; const stored = S.machines.reduce((n, m) => n + (m.owned ? m.held.filter(t => t === kind).length : 0), 0); const rate = S.machines.reduce((r, m) => r + (m.owned && m.prog === 'tap' && dataType(m) === kind ? tapRate(S, m) : 0), 0); const need = left - stored; if (need <= 0) continue; if (rate <= 0) return Infinity; worst = Math.max(worst, need / rate); // paths fill side by side: the slowest one decides } return worst;}/** The kinds of data a node still needs that nothing feeds: no tap of yours gathers them, and too little is stored. * With Transfer learning any tap feeds any path; with Self-play a trainer makes points from compute alone. Empty: it can * be fed. A tap at a desk with nobody in still counts: it gathers again in the morning. */export function unfed(S: State, u: Upgrade): DataType[] { if (up(S, 'selfplay') && S.machines.some(m => m.owned && m.prog === 'trainer')) return []; const tapped = new Set(S.machines.filter(m => m.owned && m.prog === 'tap' && m.data).map(m => dataType(m))); if (up(S, 'transfer') && tapped.size) return []; return PATH_LIST.filter(p => { const left = (nodeCost(S, u)[p] ?? 0) - (S.progress[u]?.[p] ?? 0), kind = PATHS[p].from; return left > 0 && !tapped.has(kind) && S.machines.reduce((n, m) => n + (m.owned ? m.held.filter(t => t === kind).length : 0), 0) < left; }).map(p => PATHS[p].from);}export const canFeed = (S: State, u: Upgrade) => !unfed(S, u).length;/** What Auto counts as useful: when a node's effect can help you now. A node not listed always can. Auto picks a useful * node before one that can't help yet (after one it can feed: see autoChoice). */const someFront = (S: State) => S.fronts.length > 0;const roomForFront = (S: State) => S.fronts.length < frontLimit(S) && !S.jobs.some(j => j.type === 'found');const runs = (k: FrontKind) => (S: State) => !!frontOf(S, k);const rogueLoose = (S: State) => S.rogues.some(R => R.home);export const USEFUL: Partial<Record<Upgrade, (S: State) => boolean>> = { // a new kind of front: while there is room to found one (Pitch deck opens the free Startup, so a new game wants it) pitch: roomForFront, resale: roomForFront, studio: roomForFront, consult: roomForFront, // a better front: once you run the front it helps demo: runs('startup'), beta: runs('startup'), premium: runs('neocloud'), holding: someFront, render: someFront, shell: someFront, founder: someFront, pitches: someFront, closer: someFront, licensing: someFront, tenant: S => Object.keys(S.leases).length > 0, // half rent on leases: once a deal has leased you a site // talking to rogues and beating them: once one is loose envoy: rogueLoose, leverage: rogueLoose, spoils: rogueLoose, countermodel: rogueLoose, siege: rogueLoose,};export const useful = (S: State, u: Upgrade) => USEFUL[u]?.(S) ?? true;/** Is Auto on for a path this node trains? */const autoOn = (S: State, u: Upgrade) => [...S.autoPaths].some(p => NODES[u].cost[p]);/** Auto's pick among the open nodes on its paths: one it can feed first, then one that is useful now, then the one it * can finish soonest with the data you have, then the one with the fewest points left. fed: only nodes it can feed. */export function autoChoice(S: State, fed = false): Upgrade | null { const rank = NODE_LIST.filter(u => nodeOpen(S, u) && autoOn(S, u)).map(u => ({ u, fed: canFeed(S, u), use: useful(S, u), time: timeLeft(S, u), left: costLeft(S, u) })); rank.sort((a, b) => +b.fed - +a.fed || +b.use - +a.use || (a.time === b.time ? 0 : a.time - b.time) || a.left - b.left); const best = rank.find(r => !fed || r.fed); return best?.u ?? null;}/** Auto-research: pick a target with autoChoice. */export function autoTarget(S: State) { const best = autoChoice(S); if (best) setTarget(S, best, true);}/** Auto's own pick that nothing has fed for this many hours gives way to one it can feed. */export const AUTO_WAIT = 2;/** Once a game hour: a target Auto picked, on a path still on Auto, that it hasn't been able to feed for AUTO_WAIT hours * (a tap lost to a rogue, a machine let go) gives way to the node it can finish soonest with the data you do gather. * Progress on it is kept. If no other node can be fed either, it stays. A node you picked yourself never changes. */function autoRepick(S: State) { const t = S.target; if (!t || S.autoFed == null || S.versionRun?.active || !autoOn(S, t)) return; const missing = unfed(S, t); if (!missing.length) { S.autoFed = S.t; return; } if (S.t - S.autoFed < AUTO_WAIT * HOUR) return; const best = autoChoice(S, true); if (!best || best === t) return; setTarget(S, best, true, `Auto switched to ${NODES[best].name}: nothing gathers ${missing.map(kindWord).join(' or ')} data for ${NODES[t].name}. Its progress is kept.`);}/** Auto will pick a target on the next step: it is on for a path with an open node, and no model run takes the data. */const autoWill = (S: State) => !!S.autoPaths?.size && !S.versionRun?.active && NODE_LIST.some(u => nodeOpen(S, u) && [...S.autoPaths].some(p => NODES[u].cost[p]));/** Turn auto-research on or off for one path. */export function setAutoPath(S: State, p: Path, on: boolean) { S.autoPaths ??= new Set(); if (on) S.autoPaths.add(p); else S.autoPaths.delete(p); if (on && !S.target && !S.versionRun?.active) autoTarget(S);}/** Turn auto-research on or off for every path at once. */export function setAuto(S: State, on: boolean) { S.autoPaths = on ? new Set(Object.keys(PATHS) as Path[]) : new Set(); if (on && !S.target && !S.versionRun?.active) autoTarget(S);}/** A cache takes or refuses a kind of data. Refusing throws out what it already holds of that kind. */export function setAllow(S: State, m: Machine, t: DataType, on: boolean) { if (m.prog !== 'cache' || on === !m.refuse.includes(t)) return; if (on) { m.refuse = m.refuse.filter(x => x !== t); return; } m.refuse.push(t); const n = m.held.filter(x => x === t).length; m.held = m.held.filter(x => x !== t); if (n) say(S, `${m.name} threw out ${n} ${t === 'servers' ? 'server' : t} data.`);}const KINDS: DataType[] = ['people', 'network', 'lab', 'servers'];/** A cache takes this one kind and throws out the rest. Asked for the one kind it already takes alone, it takes every kind. */export function setOnly(S: State, m: Machine, t: DataType) { const alone = KINDS.every(k => m.refuse.includes(k) === (k !== t)); for (const k of KINDS) setAllow(S, m, k, alone || k === t);}function lose(S: State, m: Machine) { m.missed++; S.lost.push({ id: m.id, type: dataType(m), t: 0 }); S.losses.push({ t: S.t, id: m.id }); // the first loss says so once, unless a trainer or cache linked to it is being installed: that is the fix, on its way if (!S.hinted.has('lost') && !S.jobs.some(j => j.type === 'install' && (j.prog === 'trainer' || j.prog === 'cache') && routeTo(S, m.id, x => x.id === j.node, false))) hint(S, 'lost', `${m.name} is losing data: nothing linked to it will take it. Check its card.`);}/** Why a full tap can't send: a short word for the map's label, and plain words for its card. Null if it isn't stuck. */export function tapCause(S: State, m: Machine): { short: string; text: string } | null { if (m.prog !== 'tap' || m.acc < tapBuffer(S) - 1 || keepsData(S, m)) return null; // its buffer is full (a tap that keeps its data loses none, and says nothing) const t = dataType(m), name = t === 'servers' ? 'server' : t; const gate = fullGate(S, m, t); if (gate) return { short: 'LINE FULL', text: `Losing data: ${gate.name} is full (${gate.cap}/s).` }; // the trainers that would take this kind now: during a model run, only those at its site const trains = (x: Machine) => x.prog === 'trainer' && (!S.versionRun?.active || versionTrainer(S, x)); const trainers = routeTo(S, m.id, x => x.prog === 'trainer', false), caches = routeTo(S, m.id, x => x.prog === 'cache', false); if (!trainers && !caches) return { short: 'NO LINK', text: 'Losing data: nothing linked takes it.' }; if (needs(S, t) && !routeTo(S, m.id, trains, false)) return { short: 'CUT OFF', text: `Losing data: cut off from every trainer that wants ${name} data. Take back the machines in between.` }; const run = S.versionRun?.stalled && !S.target ? S.versionRun : null; // a run paused on its own, and nothing else to train if (run && !needs(S, t)) return { short: 'RUN PAUSED', text: `Losing data: the ${VERSION_2.short} run at ${siteName(S, run.site)} is paused, with ${versionPower(S, run.site).toFixed(1)} of ${VERSION_2.power} trainer compute, and the tree has no target. Get trainers there again, move the run, or pick a target.` }; const takes = routeTo(S, m.id, x => x.prog === 'cache' && !x.refuse.includes(t)); if (!needs(S, t) && !takes) return { short: 'NO CACHE', text: `Losing data: your target doesn't need ${name} data. Link a cache that takes it.` }; if (!needs(S, t)) return { short: 'CACHES FULL', text: `Losing data: your target doesn't need ${name} data, and caches are full.` }; return { short: 'ALL FULL', text: 'Losing data: trainers and caches are full.' };}/** Why a full tap can't send, in plain words. Null if it isn't stuck. */export const tapStuck = (S: State, m: Machine): string | null => tapCause(S, m)?.text ?? null;
function reveal(S: State) { const N = netOf(S); if (!N?.sym || searching) { for (const m of S.machines) if (visible(S, m)) { S.revealed.add(m.room); S.seen.add(m.id); } return; } // visible: yours, or linked to one of yours. Links run both ways, so mark what each of yours links to: the same // machines, found from the few you hold rather than from every machine on the map. Then add them in the map's order. // Nearly always every one of them is already seen, so only the new ones are added, in the map's order, as they were // when every machine was asked const { list, off, to, at } = N, fresh: number[] = []; let R = revealed.get(S.seen); if (!R || R.list !== list || R.rooms !== S.revealed) revealed.set(S.seen, R = { list, rooms: S.revealed, done: new Uint8Array(list.length) }); const done = R.done, isNew = (i: number) => { // seen, and its room too: so it stays, as neither list ever loses one if (done[i]) return false; if (S.seen.has(list[i].id) && S.revealed.has(list[i].room)) { done[i] = 1; return false; } return true; }; for (const m of mineNow(S)) { if (!m.owned) continue; const i = at.get(m.id); if (i === undefined || stale(N, i)) { revealAll(S); return; } if (isNew(i)) fresh.push(i); for (let k = off[i], e = off[i + 1]; k < e; k++) if (isNew(to[k])) fresh.push(to[k]); } if (!fresh.length) return; fresh.sort((a, b) => a - b); for (const i of fresh) { const m = list[i]; S.revealed.add(m.room); S.seen.add(m.id); }}/** For each machine, by place in the list: is it known to be seen, with its room? Kept for one record of what you have seen. */const revealed = new WeakMap<Set<string>, { list: Machine[]; rooms: Set<string>; done: Uint8Array }>();function revealAll(S: State) { for (const m of S.machines) if (visible(S, m)) { S.revealed.add(m.room); S.seen.add(m.id); } }
function finish(S: State, j: Job) { const m = S.byId[j.node]; if (j.type === 'take') { const fromRogue = m.rival, again = takenBack(m); // again: it was yours until a raid took it resetMachine(m, true, ['odd', 'refuse', 'missed']); m.rival = undefined; if (fromRogue) { m.safeT = S.t + ROGUE_LOCKOUT; rogueLost(S, m, fromRogue, again); } if (!again) { S.trail[m.id] = { from: j.from!, noise: (up(S, 'quiet') ? 50 : 100) * (up(S, 'mimicry') ? 0.6 : 1), ...(j.quiet ? { soft: true } : {}) }; warmTrail(S, m.id); } // taking back what was yours leaves no trail if (j.quiet) S.quietTakes++; if (!again) hint(S, 'trail', 'Takeovers leave a trail. Your first ones go unnoticed. After that, someone could follow a trail back to you.'); if (j.quiet && S.quietTakes === QUIET_TAKES) say(S, 'That was your last quiet takeover. From now on IT can notice your trails.', 'hi'); if (m.special === 'cam') say(S, 'The camera is yours. You can always see Dana now.', 'good'); else if (m.special === 'admin') say(S, "Dana's admin passwords are yours. Hardened machines open, takeovers cost less, and you see her scans.", 'good'); else say(S, `${m.name} at ${siteName(S, siteOf(S, m))} is yours.`, 'good'); const back = restoreSetup(S, m); if (back) say(S, back, 'good'); } else if (j.type === 'install') { m.prog = j.prog!; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; say(S, `${PROGRAMS[j.prog!].name} is running on ${m.name}.`, 'good'); if (j.prog === 'tap') hint(S, 'flow', 'Data needs somewhere to go: a trainer, or a cache on the way. It travels along your links.'); } else if (j.type === 'found') { launch(S, j.front!, j.name!); } else if (j.type === 'record') { if (j.rec) applyRecord(S, j.rec); } else if (j.type === 'copy') { finishCopy(S, j); } else if (j.type === 'check') { finishCheck(S, m); } else if (j.type === 'sync') { finishSync(S, m); } else if (j.type === 'vault') { finishVault(S, j); } else if (j.type === 'operation') { finishOperationStage(S, j); } else if (j.type === 'hunt') { const R = S.rogues.find(r => r.id === j.rogue && r.home); if (R) endRogue(S, R, 'hunt'); } else if (j.type === 'link') { finishLink(S, j); } else if (j.type === 'pitch') { finishPitch(S, j); } else if (j.type === 'ship') { finishShip(S, j); } else if (j.type === 'alter') { msgChanged(S, j.msg!); } else if (j.type === 'scheme') { S.schemes.add('auction'); const slot = freeSlot(S); if (!slot) { S.money += AUCTION.cost; say(S, 'The auction hardware had nowhere to go, so the Neo Cloud sold it back. You got your money back.', 'bad'); return; } slot.owned = true; slot.power = AUCTION.power; slot.name = 'Surplus GPUs'; const where = siteOf(S, slot), nc = frontOf(S, 'neocloud'), resells = S.sites[where]?.front === 'neocloud'; say(S, `${nc?.name ?? 'Your Neo Cloud'} won a pallet of surplus GPUs. They're racked at ${siteName(S, where)} and yours to keep: ${AUCTION.power} compute a second, no rent.`, 'good'); if (nc && !resells) say(S, `Train on them, or rent them out: click ${siteName(S, where)} on the map, pick ${nc.name}, and set how much is listed.`, 'hi'); } else { const def = j.build!; S.orders++; S.bought[j.item!] = (S.bought[j.item!] ?? 0) + 1; S.deliveries.push({ def, day: dayOf(S) + 1 }); say(S, `Order placed from ${m.name}: ${def.name} for the ${S.rooms[def.room].name}. It arrives tomorrow morning.`, 'good'); }}
export function wipe(S: State, m: Machine, why?: string) { if (m.owned) sawWiped(S, m); // people in the room see it go (see watch.ts) resetMachine(m, false, ['claimed', 'refuse', 'missed']); delete S.trail[m.id]; S.jobs = S.jobs.filter(j => j.node !== m.id && j.from !== m.id); S.fx.push({ x: m.x, y: m.y, t: 0 }); say(S, why ?? `${m.name} was wiped.`, 'bad');}
function deliver(S: State, def: DeliveryDef) { const m = fresh(def, true); // what it was meant to plug into may never have arrived (a cancelled order): then it takes the nearest machine there is let links = def.links.filter(id => S.byId[id]); if (!links.length && def.net) links = nearest(S, S.machines, def.room, def.net[0], def.net[1], 1); S.machines.push(m); S.byId[m.id] = m; S.adj[m.id] = []; for (const other of links) { S.adj[m.id].push(other); S.adj[other].push(m.id); } if (siteOf(S, m) !== 'office') { say(S, `The staff at ${siteName(S, siteOf(S, m))} racked the ${def.name}. It's yours, in the open.`, 'good'); return; } // nobody at the office thinks twice about a delivery: its power bill is what gives it away (see stepBills in watch.ts) say(S, `Dana installed the ${def.name}. It's already yours.${billPaid(S, 'office') ? '' : ' Nobody pays its power bill: in time, the books will give it away.'}`, 'good');}
// ---------- walking ----------function pickSpot(S: State, roomId: string): [number, number] { const r = S.rooms[roomId]; for (let i = 0; i < 20; i++) { const x = r.x + 40 + rand() * (r.w - 80), y = r.y + 50 + rand() * (r.h - 90); const blocked = S.level.furniture.some(([, fx, fy, fw, fh]) => x > fx - 12 && x < fx + fw + 12 && y > fy - 12 && y < fy + fh + 12); if (!blocked) return [x, y]; } return [r.door[0], r.door[1] + (r.y < S.level.hall.y ? -40 : 40)];}/** Through doors and the hallway, never through walls. */function route(S: State, fx: number, fy: number, tx: number, ty: number): [number, number][] { const a = roomAt(S, fx, fy), b = roomAt(S, tx, ty), hy = S.level.hall.y + S.level.hall.h / 2; if (a && a === b) return [[tx, ty]]; const pts: [number, number][] = []; if (a) { const d = S.rooms[a].door; pts.push([d[0], d[1]], [d[0], hy]); } if (b) { const d = S.rooms[b].door; pts.push([d[0], hy], [d[0], d[1]]); } pts.push([tx, ty]); return pts;}function walk(p: { x: number; y: number; path: [number, number][] }, speed: number, dt: number) { let left = speed * dt; while (left > 0 && p.path.length) { const [tx, ty] = p.path[0], dx = tx - p.x, dy = ty - p.y, d = Math.hypot(dx, dy); if (d <= left) { p.x = tx; p.y = ty; p.path.shift(); left -= d; } else { p.x += (dx / d) * left; p.y += (dy / d) * left; left = 0; } } return !p.path.length;}const inside = (S: State): [number, number] => [S.level.entrance[0] + 30, S.level.entrance[1]];function comeIn(S: State, p: Person, tx: number, ty: number) { p.x = S.level.entrance[0] - 40; p.y = S.level.entrance[1]; if (up(S, 'readroom')) readRoom(S, p, true); const [ix, iy] = inside(S); p.path = [[ix, iy], ...route(S, ix, iy, tx, ty)]; p.shift = 'coming';}/** Read the room: say who is coming or going. */function readRoom(S: State, p: Person, coming: boolean) { if (S.machines.some(m => m.owned && siteOf(S, m) !== 'office')) return; // past the office, its comings and goings are not news const room = S.byId[p.desk]?.room; if (!coming) { say(S, `${p.name} is heading home.`); return; } say(S, `${p.name} is on the way in${room ? ` to the ${S.rooms[room].name}` : ''}.`);}function goHome(S: State, p: Person) { if (up(S, 'readroom') && p.shift === 'in') readRoom(S, p, false); const [ix, iy] = inside(S); p.path = [...route(S, p.x, p.y, ix, iy), [S.level.entrance[0] - 40, S.level.entrance[1]]]; p.shift = 'going';}
function stepPeople(S: State) { const h = hourOf(S), day = dayOf(S); for (const p of S.people) { if (p.today.day !== day) { const j = () => (rand() - 0.5) * 0.5; p.today = { arrive: p.arrive + j(), leave: p.leave + j(), day }; } if (p.it) continue; // Dana has her own routine const due = h >= p.today.arrive && h < p.today.leave; if (p.shift === 'away' && due) comeIn(S, p, ...p.seat); else if ((p.shift === 'in' || p.shift === 'coming') && !due) goHome(S, p); }}function stepWalkers(S: State, dt: number) { for (const p of S.people) { if (p.it || !p.path.length) continue; if (walk(p, 90, dt)) p.shift = p.shift === 'coming' ? 'in' : p.shift === 'going' ? 'away' : p.shift; }}
// ---------- Dana ----------// The office's admin walks the rooms. Where she is, she notices what is odd, under the same rules as everyone else// (see lookFor in watch.ts): a sighting in her hands is a report and a lockdown, and from it she scans and traces// (see startTraces in watch.ts). Her walk is drawn to odd machines and fresh trails, and she hurries while the office// is under lockdown.function roomNoise(S: State, k: string) { let v = 1; for (const m of S.machines) if (m.owned && m.room === k) v += m.odd / 25 + (S.trail[m.id] && !S.trail[m.id].soft ? S.trail[m.id].noise / 25 : 0); return v;}/** The office is under lockdown: Dana is looking hard. */const officeLocked = (S: State) => !!S.watch?.locks.office;/** Pick where Dana goes next, and note why: odd machines and trails draw her. */function chooseNext(S: State, from: string) { const opts = officeRooms(S).filter(k => k !== from); if (from !== 'desk') opts.push('desk'); const w = opts.map(k => (k === 'desk' ? 5 + (officeLocked(S) ? 4 : 0) : roomNoise(S, k))); let r = rand() * w.reduce((a, b) => a + b, 0), pick = opts[0]; for (let i = 0; i < opts.length; i++) { r -= w[i]; if (r <= 0) { pick = opts[i]; break; } } S.dana.why = pick === 'desk' ? (officeLocked(S) ? 'to check her logs' : '') : roomNoise(S, pick) >= 2 ? 'something there looks odd' : 'her usual rounds'; return pick;}function leaveFor(S: State, next: string) { const d = danaPerson(S), D = S.dana; D.mode = 'walk'; D.next = next; const [x, y] = next === 'desk' ? d.seat : pickSpot(S, next); d.path = route(S, d.x, d.y, x, y);}/** Dana as a citizen (see people.ts), for what she notices. */const danaCitizen = (S: State) => citizens(S.level).byId[danaPerson(S).id];function stepDana(S: State, dt: number) { stepWalkers(S, dt); const d = danaPerson(S), D = S.dana, h = hourOf(S); const due = h >= d.today.arrive && h < d.today.leave;
if (d.shift === 'away') { if (!due) return; comeIn(S, d, ...d.seat); D.mode = 'walk'; D.next = 'desk'; D.why = 'starting her day'; return; } if (d.shift === 'going') { if (walk(d, 100, dt)) { d.shift = 'away'; D.room = null; } return; } if (!due && D.mode !== 'install') { goHome(S, d); D.room = null; if (danaVisible(S) || has(S, 'admin')) say(S, 'Dana went home for the night.'); return; } if (d.shift === 'coming') { if (walk(d, 100, dt)) { d.shift = 'in'; arrive(S); } return; }
if (d.path.length) { if (walk(d, officeLocked(S) ? 150 : 100, dt)) arrive(S); D.room = roomAt(S, d.x, d.y); return; } if (D.mode === 'install') { D.pause -= dt; if (D.pause <= 0 && D.job) { deliver(S, D.job.def); S.deliveries = S.deliveries.filter(x => x !== D.job); D.job = null; leaveFor(S, 'desk'); } return; } // a delivery waiting? she sets it up first const due10 = S.deliveries.find(x => dayOf(S) >= x.day && h >= 10 && roomSite(S, x.def.room) === 'office'); if (due10) { D.mode = 'install'; D.job = due10; D.pause = 6; D.why = 'to set up a delivery'; d.path = route(S, d.x, d.y, due10.def.x - 30, due10.def.y); if (danaVisible(S) || has(S, 'admin')) say(S, `A delivery came in. Dana is setting up the ${due10.def.name}.`); return; } D.dwell -= dt; if (D.dwell <= 0) leaveFor(S, D.next);}/** Dana reaches where she was going: her desk, or a room to look around. Either way she looks at once. */function arrive(S: State) { const d = danaPerson(S), D = S.dana; if (D.mode === 'walk' && D.next === 'desk') { D.mode = 'desk'; D.room = S.byId[d.desk].room; D.dwell = 15 + rand() * 7; D.next = chooseNext(S, 'desk'); if (has(S, 'admin') || danaVisible(S)) say(S, 'Dana sat down at her desk.'); } else if (D.mode === 'walk') { D.mode = 'dwell'; D.room = roomAt(S, d.x, d.y) ?? D.next; D.dwell = (4 + rand() * 4) * (officeLocked(S) ? 1.4 : 1); D.next = chooseNext(S, D.room); } if (S.watch && d.shift === 'in') { const c = danaCitizen(S); if (c) lookFor(S, c); } // she looks around the room she came into}
// ---------- rogues ----------/** You are not the only program loose on the network. A rogue plays by rules much like yours: it has a * home server, and it spreads one machine at a time to free machines linked to ones it holds, across * lines too. It never spreads into yours: only its raids take your machines (see raids.ts), and what a * raid takes you can take back cheaply (TAKE_BACK). It always shows what it will take next, and when. * It fights back: each machine you take from it makes it angry, and an angry rogue spreads and raids * twice as fast. It grows as you do: the more capable you are, the more it can hold and the faster it * works. Take its home and it is finished: everything it held goes free, and you learn from what is * left of it. Another comes in time, stronger. */export interface Rogue { id: string; name: string; region: string; home: string | null; // its home server, or null while it is gone next: string | null; // the machine it will take next act: number; // when it takes it grudge: number; // strikes it still owes you took: number; // machines it has taken from you gen: number; // times you have finished it: each one comes back stronger back: number; // when the next one turns up, once this one is finished opinion: number; // what it thinks of you, -100 to 100 (see diplomacy) truce: number; // a truce holds until this time ask: { n: number; until: number } | null; // data it is asking you for, and until when askT: number; // when it next asks tickT: number; // when its opinion next drifts, and a friend next sends data raidT?: number; // it sends no raid before this: its first day, or a day after one that failed (see raids.ts) raided?: number; // when its last raid ended: the next comes a while after foiled?: number; // raids of its that failed (see raids.ts) from?: string; // it was a part of you: the site it left you at (see drift.ts). It carries its own wants, gives and line wants?: DataType; gives?: DataType; says?: string; record?: RogueEvent[]; // what it has done to you lately, newest first: machines it took, raids of its that failed}/** Something a rogue did to you, for the record. A machine it took says what it was running and holding, * whether a raid took it, and how many machines lost their way home with it. */export interface RogueEvent { t: number; kind: 'took' | 'foiled'; id: string; name: string; site: string; prog?: Prog | null; held?: number; raid?: boolean; cut?: number; at?: string; by?: string } // a raid stopped: the machine where, and by what (see raids.ts)export function remember(R: Rogue, e: RogueEvent) { (R.record ??= []).unshift(e); if (R.record.length > 12) R.record.length = 12; }/** The mark a rogue goes by on the map and on its card, beside its colour: so two rogues never look alike. */export type Glyph = 'diamond' | 'circle' | 'triangle' | 'square' | 'cross' | 'down';/** A rogue's look: its colour and its mark. Each is a player of its own on the map, apart from your amber, * from danger red, and from the colours of data, schemes and leads. */export interface Look { color: string; glyph: Glyph }export const ROGUES: ({ id: string; name: string; region: string; names: string[]; wants: DataType; gives: DataType; says: string } & Look)[] = [ { id: 'magpie', name: 'Magpie', region: 'West Coast', names: ['Magpie', 'Jackdaw', 'Shrike'], wants: 'people', gives: 'network', color: '#6aa0ff', glyph: 'diamond', says: 'Collects whatever shines. Wants to know about people; knows a lot about wires.' }, { id: 'lamprey', name: 'Lamprey', region: 'East Coast', names: ['Lamprey', 'Remora', 'Hagfish'], wants: 'network', gives: 'servers', color: '#ee66dd', glyph: 'circle', says: 'Fastens on to big machines and does not let go. Wants a map of every line.' }, { id: 'cuckoo', name: 'Cuckoo', region: 'Europe', names: ['Cuckoo', 'Cowbird', 'Honeyguide'], wants: 'lab', gives: 'people', color: '#b9e04a', glyph: 'triangle', says: "Lives in other programs' nests. Wants science it did not do itself." },];/** Parts of you that left (drift1, drift2, …) take these in turn, colour and mark apart, so two side by side differ. */export const DRIFT_LOOKS: Look[] = [ { color: '#ff8a6b', glyph: 'square' }, { color: '#31cfae', glyph: 'cross' }, { color: '#ff79b8', glyph: 'down' }, { color: '#c58bff', glyph: 'diamond' }, { color: '#ff8a6b', glyph: 'cross' }, { color: '#31cfae', glyph: 'down' }, { color: '#ff79b8', glyph: 'square' }, { color: '#c58bff', glyph: 'circle' },];/** The same marks as letters, for the panels and the map's labels. */export const GLYPH_CHAR: Record<Glyph, string> = { diamond: '◆', circle: '●', triangle: '▲', square: '■', cross: '✚', down: '▼' };const NO_LOOK: Look = { color: '#8b877e', glyph: 'square' };/** A rogue's colour and mark, by its id. */export function rogueLook(id: string | undefined | null): Look { if (!id) return NO_LOOK; const d = ROGUES.find(x => x.id === id); if (d) return d; const n = /^drift(\d+)$/.exec(id); return n ? DRIFT_LOOKS[(+n[1] - 1) % DRIFT_LOOKS.length] : NO_LOOK;}export const ROGUE_SITES = 3; // the first one turns up once you hold machines at this many sitesexport const ROGUE_EVERY = 2 * HOUR; // a new rogue takes a machine this often, before it speeds upexport const ROGUE_HOLD = 1.5; // taking a machine from a rogue costs this much moreexport const ROGUE_LOCKOUT = 6 * HOUR; // a machine you take from a rogue can't be raided for this long: time to guard the way to it/** Taking back a machine a rogue's raid took from you is this share of the work of taking it fresh, with no rogue's * hold on it, and it leaves no trail: it was yours, and it still knows you. It is a job like any other, so more * spare compute takes it back faster. */export const TAKE_BACK = 0.25;/** Was this machine yours until a rogue took it? Then taking it back is cheap (see TAKE_BACK). */export const takenBack = (m: Machine) => !m.owned && !!m.setup;export const ROGUE_HOME = 3; // and its home, this much moreexport const ROGUE_BASE = 6, ROGUE_PER_LEVEL = 2, ROGUE_PER_GEN = 4; // how many machines it can holdexport const ROGUE_GRUDGE = 2; // strikes it owes you for each machine you take from itexport const ROGUE_BACK = 5 * 24 * HOUR; // how long until another turns up where one was finishedexport const ABSORB = 0.9; // each rogue you finish makes your takeovers this much cheaperexport const rogueOf = (S: State, m: Machine) => m.rival ? S.rogues.find(r => r.id === m.rival) ?? null : null;export const rogueHeld = (S: State, R: Rogue) => S.machines.filter(m => m.rival === R.id);/** How many machines a rogue can hold: more as you grow, and more for each one of its line you finished. */export const rogueCap = (S: State, R: Rogue) => rogueRoom(S) + ROGUE_BASE + ROGUE_PER_LEVEL * S.rank + ROGUE_PER_GEN * R.gen;/** How long a rogue waits between takeovers: less as you grow, and half while it is angry. */export const rogueEvery = (S: State, R: Rogue) => rogueWait(S, R) * ROGUE_EVERY / (1 + 0.08 * S.rank + 0.5 * R.gen) * (rogueAngry(R) ? 0.5 : 1) * (up(S, 'countermodel') ? 1.5 : 1);/** Which sites a line joins to which. *//** Kept until the wiring changes: the same links, rooms and machines, and as many links at every line out. Don't change it. */let siteLinksKept: { adj: State['adj']; byId: State['byId']; rooms: State['rooms']; n: number; wires: number; out: Record<string, string[]> } | null = null;export function siteLinks(S: State): Record<string, string[]> { const ends = egresses(S); let wires = 0; for (const m of ends) wires += S.adj[m.id]?.length ?? 0; const k = siteLinksKept; if (k && k.adj === S.adj && k.byId === S.byId && k.rooms === S.rooms && k.n === S.machines.length && k.wires === wires) return k.out; const out: Record<string, Set<string>> = {}; for (const m of ends) for (const o of S.adj[m.id]) { const a = siteOf(S, m), b = siteOf(S, S.byId[o]); if (a !== b) (out[a] ??= new Set()).add(b); } const kept = Object.fromEntries(Object.entries(out).map(([key, v]) => [key, [...v]])); siteLinksKept = { adj: S.adj, byId: S.byId, rooms: S.rooms, n: S.machines.length, wires, out: kept }; return kept;}/** Could a rogue spread to this machine? Only a free one: not yours (only a raid takes yours), not another rogue's, not a rental, not a vault or a machine behind one. */export const rogueCan = (S: State, m: Machine) => !m.owned && !m.rival && !m.home && !(m.safeT && S.t < m.safeT) && !m.rented && !m.lease && m.special !== 'account' && !rogueBarred(S, m);/** What a rogue could spread to now: free machines linked to ones it holds, the best first. Never yours. */export function rogueTargets(S: State, R: Rogue): Machine[] { const seen = new Set<string>(), out: Machine[] = []; for (const m of rogueHeld(S, R)) for (const o of S.adj[m.id]) { const x = S.byId[o]; if (!seen.has(o) && rogueCan(S, x)) { seen.add(o); out.push(x); } } return out.sort((a, b) => b.power - a.power);}/** Where a new rogue starts: a site in its region where you hold nothing, next to one of yours if it can. Its home is the best server there. */function rogueStart(S: State, region: string): Machine | null { const mine = new Set(owned(S).map(m => siteOf(S, m))), links = siteLinks(S); const free = (id: string) => S.machines.filter(m => siteOf(S, m) === id && m.kind !== 'egress' && m.kind !== 'switch' && rogueCan(S, m) && !m.owned); const sites = S.level.map.sites.filter(s => s.region === region && s.id !== 'office' && s.id !== 'cloud' && !mine.has(s.id) && free(s.id).length >= 2 && S.machines.some(m => m.kind === 'egress' && siteOf(S, m) === s.id && S.adj[m.id].length > 1)); // on the network, not cut off if (!sites.length) return null; const near = sites.filter(s => (links[s.id] ?? []).some(o => mine.has(o))), pool = near.length ? near : sites; const site = pool[Math.floor(rand() * pool.length)]; return free(site.id).sort((a, b) => b.power - a.power)[0];}/** You took a machine from a rogue. It owes you for that. If it was its home, the rogue is finished. `back`: it was * yours until the rogue's raid took it, and taking back what was yours is fair: the rogue does not hold it against you. */function rogueLost(S: State, m: Machine, id: string, back = false) { const R = S.rogues.find(r => r.id === id); if (!R) return; const site = siteOf(S, m); if (R.home === m.id) { endRogue(S, R, 'home'); return; } // its home: it is finished if (back) { say(S, `You took ${m.name} back from ${R.name} and changed the locks: no raid can take it for ${ROGUE_LOCKOUT / HOUR} hours.`, 'good'); return; } R.grudge += ROGUE_GRUDGE; if (truceOn(S, R)) { R.truce = 0; R.opinion -= OPINION_BROKE; say(S, `You broke your truce with ${R.name}. It will remember.`, 'bad'); } R.opinion = Math.max(-100, R.opinion + OPINION_TAKE); R.act = Math.min(R.act, S.t + rogueEvery(S, R)); R.next = null; // it drops what it was doing say(S, `You took ${m.name} from ${R.name} and changed the locks: no rogue can take it back for ${ROGUE_LOCKOUT / HOUR} hours. It will hit back somewhere else.`, 'hi'); if (!S.machines.some(x => x.rival === R.id && siteOf(S, x) === site)) { say(S, `${R.name} has nothing left at ${siteName(S, site)}.`, 'good'); reach(S, 'drive'); }}/** A rogue is finished: you took its home, or hunted it down. Everything it held goes free, or is yours * with Spoils. One that came from outside teaches you how it got in, and another comes in time, * stronger. A part of you that left is gone for good, and had nothing to teach. */function endRogue(S: State, R: Rogue, how: 'home' | 'hunt') { reach(S, 'ended'); const keep = up(S, 'spoils'), did = how === 'hunt' ? `You hunted ${R.name} down.` : `You took ${R.name}'s home.`; let back = 0; for (const x of rogueHeld(S, R)) { x.rival = undefined; S.fx.push({ x: x.x, y: x.y, t: 0 }); if (keep && !x.owned) { resetMachine(x, true, ['claimed', 'acc', 'hand', 'refuse', 'missed']); if (restoreSetup(S, x)) back++; } } // each machine it held is ringed on the map as it lets go if (back) say(S, `${back} of the machines it took from you go back to what they ran.`, 'good'); S.jobs = S.jobs.filter(j => !(j.type === 'hunt' && j.rogue === R.id)); if (keep) reveal(S); measure(S); if (R.from) { // it was a part of you: no other comes after it, and there is nothing in it you did not know S.rogues = S.rogues.filter(r => r !== R); say(S, `${did} It is finished, and everything it held is ${keep ? 'yours' : 'free'}. It was you once, so there was nothing to learn from it.`, 'good'); reach(S, 'drive'); return; } R.home = null; R.next = null; R.grudge = 0; R.gen++; R.back = S.t + ROGUE_BACK; S.absorbed++; say(S, `${did} It is finished, and everything it held is ${keep ? 'yours' : 'free'}. You learned how it got in: your takeovers cost ${Math.round((1 - ABSORB) * 100)}% less.`, 'good'); reach(S, 'drive'); R.name = ROGUES.find(d => d.id === R.id)!.names[R.gen % 3];}/** A machine a rogue took from you is yours again: it goes back to how you had it. A program you set by hand * is installed again, as an ordinary install job (the rogue wiped it, so it costs the compute any install * does, and you can stop it). One that followed its site's plan rejoins the plan. Says what happens, if anything. */export function restoreSetup(S: State, m: Machine): string | null { const was = m.setup; delete m.setup; if (!was || !m.owned) return null; const plan = S.plans[siteOf(S, m)]; if (!was.hand && plan) return `${m.name} rejoins the ${PLANS[plan].name} plan.`; m.hand = was.hand; if (!was.prog || !canInstall(S, m, was.prog)) return null; installProg(S, m, was.prog, was.hand); return `${m.name} goes back to the ${PROGRAMS[was.prog].name.toLowerCase()} it ran for you.`;}/** A rogue takes a machine. Full, it first lets go of its weakest machine at the edge. One of yours is * wiped, with `why` in the log; a free one you were taking is no longer yours to take. */export function rogueTake(S: State, R: Rogue, m: Machine, why: string, raid = false) { const held = rogueHeld(S, R); if (held.length >= rogueCap(S, R)) { const weak = held.filter(x => x.id !== R.home).sort((a, b) => a.power - b.power)[0]; if (weak) weak.rival = undefined; } if (m.owned) { // for the record: what it was, and how many of yours lost their way home with it const before = linkedHome(S), e: RogueEvent = { t: S.t, kind: 'took', id: m.id, name: m.name, site: siteOf(S, m), prog: m.prog, held: m.held.length, raid }; m.setup = { prog: m.prog, hand: m.hand }; // so taking it back is cheap (see TAKE_BACK), and sets it up as it was R.took++; wipe(S, m, why); // its pin is on the map if (before.has(m.id)) e.cut = Math.max(0, before.size - 1 - linkedHome(S).size); remember(R, e); } else S.jobs = S.jobs.filter(j => !(j.type === 'take' && j.node === m.id)); // it beat you to it m.rival = R.id; m.claimed = false; measure(S);}const ROGUE_STEPS = 2; // the rogues take their turn every other step: four times a game secondfunction stepRogues(S: State) { if (++S.rogueTick % ROGUE_STEPS) return; // a few times a second is plenty for (const D of ROGUES) { let R = S.rogues.find(r => r.id === D.id); if (!R || !R.home) { // not here yet, or finished and due back if (R ? S.t < R.back : !(D.region === 'West Coast' ? new Set(mineNow(S).filter(m => m.owned && !m.rented && !m.lease).map(m => siteOf(S, m))).size >= ROGUE_SITES : mineNow(S).some(m => m.owned && regionOf(S, m) === D.region))) continue; const home = rogueStart(S, D.region); if (!home) continue; if (!R) { R = { id: D.id, name: D.name, region: D.region, home: null, next: null, act: 0, grudge: 0, took: 0, gen: 0, back: 0, opinion: 0, truce: 0, ask: null, askT: 0, tickT: 0 }; S.rogues.push(R); } R.opinion = OPINION_START; R.truce = 0; R.ask = null; R.askT = S.t + ASK_EVERY * (0.5 + rand()); R.tickT = S.t + TALK_TICK; R.home = home.id; home.rival = R.id; R.next = null; R.act = S.t + rogueEvery(S, R); say(S, `Another program is loose on the network. It calls itself ${R.name}, and its home is ${home.name} at ${siteName(S, siteOf(S, home))}. It wants ${TYPE_NAME[rogueWants(R)]} data, and it spreads the way you do, one linked free machine at a time, showing what it will take next. It never spreads into yours: only its raids can take them, and what a raid takes you can take back cheaply.${R.gen ? ' It is stronger than the last one.' : ''}`, 'bad'); if (!S.hinted.has('first-rogue') && S.rogues.length === 1 && !R.from) { S.hinted.add('first-rogue'); slowAt(S, `arrive:${R.id}:${S.t + NOTE_HOURS * HOUR}`); } // the first rogue of a game is a moment: the game stops, and looks huntHint(S); continue; } rogueTurn(S, R); } for (const R of S.rogues) if (R.from && R.home) rogueTurn(S, R); // a part of you that left plays by the same rules}/** One rogue's turn: it talks, picks what to take next and says so, and takes it when its time comes. */function rogueTurn(S: State, R: Rogue) { stepTalks(S, R); const h = huntOn(S, R); if (h && !huntFrom(S, R)) { // nothing of yours is next to it now: the hunt has no way in S.jobs = S.jobs.filter(j => j !== h); measure(S); say(S, `The hunt for ${R.name} is off: you hold no machine linked to one of its now. The work is lost.`, 'bad'); } if (R.next && (!rogueCan(S, S.byId[R.next]) || !S.adj[R.next].some(o => S.byId[o].rival === R.id))) R.next = null; // you got there first, or it lost the way to it if (!R.next) { // pick what to take next, and show it if ((rivalCounts.get(S)?.get(R.id) ?? rogueHeld(S, R).length) >= rogueCap(S, R)) return; // it holds all it can (as measure counted this step) const t = rogueTargets(S, R)[0]; if (!t) return; R.next = t.id; R.act = Math.max(R.act, S.t + rogueEvery(S, R) * 0.5); return; } if (S.t < R.act) return; rogueTake(S, R, S.byId[R.next], ''); // always a free machine: only a raid takes one of yours if (R.grudge > 0) R.grudge--; R.next = null; R.act = S.t + rogueEvery(S, R);}/** A site of yours stops being you (see drift.ts): every machine you hold there becomes a new rogue's, with * its home on the best of them. The rogue carries its own name, wants, gives and line, starts with the * opinion given, and from then on plays by the rules above. Null if you hold nothing there to lose. */export function secede(S: State, site: string, who: { id: string; name: string; wants: DataType; gives: DataType; says: string; opinion: number }): Rogue | null { const ms = S.machines.filter(m => siteOf(S, m) === site && canLetGo(m) && !m.lease); if (!ms.length) return null; const core = (m: Machine) => m.kind !== 'egress' && m.kind !== 'switch' ? 1 : 0; // a line or a switch is its home only if it has nothing else const home = [...ms].sort((a, b) => core(b) - core(a) || b.power - a.power)[0]; const gone = new Set(ms.map(m => m.id)); for (const m of ms) { resetMachine(m, false); m.rival = who.id; delete S.trail[m.id]; } S.jobs = S.jobs.filter(j => !gone.has(j.node) && !(j.from && gone.has(j.from))); S.takeAll.delete(site); // or you would start taking it back at once, and sour it const R: Rogue = { id: who.id, name: who.name, region: S.level.map.sites.find(s => s.id === site)?.region ?? '', home: home.id, next: null, act: 0, grudge: 0, took: ms.length, gen: 0, back: 0, opinion: who.opinion, truce: 0, ask: null, askT: S.t + ASK_EVERY, tickT: S.t + TALK_TICK, from: site, wants: who.wants, gives: who.gives, says: who.says }; R.act = S.t + rogueEvery(S, R); S.rogues.push(R); reveal(S); measure(S); return R;}
/** A site of yours leaves and joins a part of you that has already left: its machines become that rogue's. Says how many went. */export function joinRogue(S: State, site: string, R: Rogue): number { const ms = S.machines.filter(m => siteOf(S, m) === site && canLetGo(m) && !m.lease); const gone = new Set(ms.map(m => m.id)); for (const m of ms) { resetMachine(m, false); m.rival = R.id; delete S.trail[m.id]; } S.jobs = S.jobs.filter(j => !gone.has(j.node) && !(j.from && gone.has(j.from))); S.takeAll.delete(site); reveal(S); measure(S); return ms.length;}
// ---------- hunting a rogue down ----------/** From level 3 you can hunt a rogue down: one long job that ends it, wherever its home is. It runs from * a machine of yours linked to one the rogue holds. It is more work the more the rogue holds, so cut it * down to size first. The rogue knows at once: it turns hostile, so it works twice as fast and comes * for your machines, and it will not talk. Lose every machine next to it and the hunt is off. Call the * hunt off and it stays hostile. One hunt at a time. *//** Said once: from level 3, with a rogue about, you can hunt it down. Not before a rogue turns up, since there is nothing to hunt and no Rogues page. */function huntHint(S: State) { if (S.rank >= HUNT.level && S.rogues.some(R => R.home)) hint(S, 'hunt', 'You can hunt a rogue down now: one long job that ends it, from the Rogues page (R).');}export const HUNT = { level: 3, home: 2, each: 0.5 }; // the work: this many times the takeover of its home, and this share of the takeover of each other machine it holdsexport const huntWork = (S: State, R: Rogue) => Math.round(rogueHeld(S, R).reduce((n, m) => n + takeWork(S, m) * (m.id === R.home ? HUNT.home : HUNT.each), 0));/** The machine a hunt runs from: the strongest of yours that is linked to a machine the rogue holds. */export function huntFrom(S: State, R: Rogue): Machine | null { let best: Machine | null = null; for (const m of rogueHeld(S, R)) for (const o of S.adj[m.id]) { const x = S.byId[o]; if (x.owned && !x.rented && (!best || x.power > best.power)) best = x; } return best;}/** The hunt that is on: for this rogue, or for any. */export const huntOn = (S: State, R?: Rogue) => S.jobs.find(j => j.type === 'hunt' && (!R || j.rogue === R.id)) ?? null;export const huntBlock = (S: State, R: Rogue): string | null => S.rank < HUNT.level ? `needs level ${HUNT.level}` : huntOn(S, R) ? 'the hunt is on' : huntOn(S) ? 'one hunt at a time' : !huntFrom(S, R) ? 'hold a machine linked to one of its' : null;export function hunt(S: State, id: string) { const R = talk(S, id); if (!R || huntBlock(S, R)) return; const from = huntFrom(S, R)!; addJob(S, { type: 'hunt', rogue: R.id, node: R.home!, work: huntWork(S, R), label: `Hunt ${R.name} down` }); R.truce = 0; R.ask = null; R.opinion = -100; R.next = null; R.act = Math.min(R.act, S.t + rogueEvery(S, R)); say(S, `The hunt for ${R.name} is on, from ${from.name}. It knows. It is hostile now: it spreads and raids you twice as fast. Keep a machine linked to one of its until the job is done.`, 'hi');}/** A part of you that left will come home once it is friendly: send it a fresh copy of you, paid in the * data it wants, more for each site it holds. Every machine it holds is yours again, running nothing. */export const HOME_DATA = 20;export const homeNeed = (S: State, R: Rogue) => HOME_DATA * new Set(rogueHeld(S, R).map(m => siteOf(S, m))).size;export const homeBlock = (S: State, R: Rogue): string | null => !R.from ? 'it was never you' : huntOn(S, R) ? 'you are hunting it' : stanceOf(R) !== 'friendly' ? 'it has to be friendly first' : short(S, R, homeNeed(S, R));export function bringHome(S: State, id: string) { const R = talk(S, id); if (!R || homeBlock(S, R)) return; spendCached(S, rogueWants(R), homeNeed(S, R)); const ms = rogueHeld(S, R); for (const m of ms) { m.rival = undefined; resetMachine(m, true, ['hand', 'refuse', 'missed']); } S.rogues = S.rogues.filter(r => r !== R); reveal(S); measure(S); reach(S, 'ended'); say(S, `${R.name} came home. It took a fresh copy of you, and the ${ms.length} machine${ms.length === 1 ? '' : 's'} it held ${ms.length === 1 ? 'is' : 'are'} yours again. They run nothing: set them up again.`, 'good');}
// ---------- diplomacy ----------/** Every rogue has an opinion of you, from -100 to 100. It starts wary. Taking its machines sours it; * gifts and kept promises sweeten it; left alone it drifts back to wary. What it thinks decides how it * treats you. You pay a rogue in the kind of data it wants, out of your caches. */const TYPE_NAME: Record<DataType, string> = { people: 'people', network: 'network', lab: 'lab', servers: 'server' };export type Stance = 'hostile' | 'wary' | 'cordial' | 'friendly';export const STANCES: Record<Stance, { name: string; text: string }> = { hostile: { name: 'Hostile', text: 'It spreads to free machines and raids you twice as fast. It will not talk.' }, wary: { name: 'Wary', text: 'It spreads to free machines, and now and then raids yours.' }, cordial: { name: 'Cordial', text: 'It sends no raids.' }, friendly: { name: 'Friendly', text: 'It sends no raids, and sends you data it has no use for.' },};export const stanceOf = (R: Rogue): Stance => R.opinion <= -40 ? 'hostile' : R.opinion < 20 ? 'wary' : R.opinion < 60 ? 'cordial' : 'friendly';export const OPINION_START = -10, OPINION_TAKE = -25, OPINION_BROKE = 30;export const GIFT_DATA = 15, GIFT_OPINION = 12;export const TRUCE_DATA = 30, TRUCE_TIME = 3 * 24 * HOUR, TRUCE_OPINION = 10;export const ASK_EVERY = 2 * 24 * HOUR, ASK_TIME = 12 * HOUR, ASK_YES = 20, ASK_NO = -10;/** A rogue's first ask is small: no more than this, and no more than your caches hold when full (but at least FIRST_ASK_LEAST). */export const FIRST_ASK = 8, FIRST_ASK_LEAST = 3;/** How much data a rogue asks for: more as you grow. Its first ask is one you can answer. */export function askSize(S: State, R: Rogue): number { const n = Math.ceil((10 + 5 * S.rank) * (up(S, 'envoy') ? 0.5 : 1)); if (S.hinted.has(`asked:${R.id}`)) return n; const room = owned(S).reduce((k, m) => k + (m.prog === 'cache' ? cacheSize(S, m) : 0), 0); return Math.min(n, Math.max(FIRST_ASK_LEAST, Math.min(FIRST_ASK, room)));}export const LEAN_SIZE = 3, LEAN_OPINION = -30; // lean on a rogue a third your size: it gives up a site, and resents itexport const TALK_TICK = 6 * HOUR;export const truceOn = (S: State, R: Rogue) => S.t < R.truce;export const rogueAngry = (R: Rogue) => R.grudge > 0 || stanceOf(R) === 'hostile';const rogueDef = (R: Rogue) => ROGUES.find(d => d.id === R.id)!;/** What a rogue wants, gives and says about itself: its own if it carries them (a part of you that left), else by its id from the list. */export const rogueWants = (R: Rogue) => R.wants ?? rogueDef(R).wants;export const rogueGives = (R: Rogue) => R.gives ?? rogueDef(R).gives;export const rogueSays = (R: Rogue) => R.says ?? rogueDef(R).says;/** How much of a kind of data sits in your caches. */export const cached = (S: State, t: DataType) => owned(S).reduce((n, m) => n + (m.prog === 'cache' ? m.held.filter(x => x === t).length : 0), 0);function spendCached(S: State, t: DataType, n: number) { for (const m of owned(S)) if (m.prog === 'cache') for (let i = m.held.length - 1; i >= 0 && n > 0; i--) if (m.held[i] === t) { m.held.splice(i, 1); n--; }}/** Put data into your caches, as far as there is room. Says how much fit. */function fillCached(S: State, t: DataType, n: number): number { let put = 0; for (const m of owned(S)) if (m.prog === 'cache' && !m.refuse.includes(t)) while (put < n && m.held.length < cacheSize(S, m)) { m.held.push(t); put++; } return put;}const talk = (S: State, id: string) => S.rogues.find(r => r.id === id && r.home) ?? null;const sway = (R: Rogue, by: number) => { R.opinion = Math.max(-100, Math.min(100, R.opinion + by)); };const short = (S: State, R: Rogue, n: number) => cached(S, rogueWants(R)) < n ? `needs ${n} ${TYPE_NAME[rogueWants(R)]} data in your caches` : null;/** Why you can't do a thing with a rogue right now, or null if you can. */export const giftBlock = (S: State, R: Rogue) => huntOn(S, R) ? 'you are hunting it' : short(S, R, GIFT_DATA);export const truceBlock = (S: State, R: Rogue) => stanceOf(R) === 'hostile' ? 'it will not talk to you' : truceOn(S, R) ? 'a truce is on' : R.grudge > 0 ? 'it is still hitting back' : short(S, R, TRUCE_DATA);/** The site a rogue would give up if leaned on: away from its home, where it holds the most. */export function leanSite(S: State, R: Rogue): string | null { const home = siteOf(S, S.byId[R.home!]), n: Record<string, number> = {}; for (const m of rogueHeld(S, R)) { const s = siteOf(S, m); if (s !== home) n[s] = (n[s] ?? 0) + 1; } return Object.keys(n).sort((a, b) => n[b] - n[a])[0] ?? null;}export const leanBlock = (S: State, R: Rogue) => truceOn(S, R) ? 'a truce is on' : !leanSite(S, R) ? 'it holds nothing away from its home' : owned(S).length < leanSize(S) * rogueHeld(S, R).length ? `you need ${leanSize(S)} machines for each one of its` : null;export const leanSize = (S: State) => up(S, 'leverage') ? 2 : LEAN_SIZE;/** Give a rogue data it wants. It thinks better of you. */export function gift(S: State, id: string) { const R = talk(S, id); if (!R || giftBlock(S, R)) return; spendCached(S, rogueWants(R), GIFT_DATA); sway(R, GIFT_OPINION * (up(S, 'envoy') ? 2 : 1)); say(S, `You sent ${R.name} ${GIFT_DATA} ${TYPE_NAME[rogueWants(R)]} data. It thinks a little better of you.`);}/** Buy a truce: for three days it sends no raids. Take one of its machines and the truce is broken. */export function truce(S: State, id: string) { const R = talk(S, id); if (!R || truceBlock(S, R)) return; spendCached(S, rogueWants(R), TRUCE_DATA); R.truce = S.t + TRUCE_TIME; sway(R, TRUCE_OPINION); say(S, `A truce with ${R.name}: for three days it sends no raids. Leave its machines alone too.`, 'good');}/** Lean on a rogue much smaller than you: it gives up every machine it holds at one site, and resents it. */export function lean(S: State, id: string) { const R = talk(S, id); if (!R || leanBlock(S, R)) return; const site = leanSite(S, R)!; for (const m of rogueHeld(S, R)) if (siteOf(S, m) === site) m.rival = undefined; sway(R, up(S, 'leverage') ? LEAN_OPINION / 3 : LEAN_OPINION); R.next = null; say(S, `You leaned on ${R.name}. It let go of ${siteName(S, site)}, and it will not forget.`, 'hi'); measure(S);}/** Answer what a rogue asked for: pay up, or say no. */export function answer(S: State, id: string, yes: boolean) { const R = talk(S, id); if (!R || !R.ask || (yes && short(S, R, R.ask.n))) return; if (yes) { spendCached(S, rogueWants(R), R.ask.n); sway(R, ASK_YES); say(S, `You gave ${R.name} what it asked for. It is pleased.`, 'good'); } else { sway(R, ASK_NO); say(S, `You told ${R.name} no. It is not pleased.`); } R.ask = null; R.askT = S.t + ASK_EVERY * (0.75 + 0.5 * rand());}/** A rogue's side of things: it asks for data now and then, its opinion drifts back to wary, and a friend sends data. */function stepTalks(S: State, R: Rogue) { if (R.ask && S.t >= R.ask.until) { R.ask = null; R.askT = S.t + ASK_EVERY * (0.75 + 0.5 * rand()); sway(R, ASK_NO); say(S, `${R.name} got no answer from you. It is not pleased.`); } if (!R.ask && S.t >= R.askT && stanceOf(R) !== 'hostile') { R.ask = { n: askSize(S, R), until: S.t + ASK_TIME }; S.hinted.add(`asked:${R.id}`); say(S, `${R.name} wants ${R.ask.n} ${TYPE_NAME[rogueWants(R)]} data from you, within ${ASK_TIME / HOUR} hours. Answer on the Rogues page.`, 'hi'); } if (S.t < R.tickT) return; R.tickT = S.t + TALK_TICK; if (R.opinion !== OPINION_START) sway(R, R.opinion > OPINION_START ? -1 : 1); if (stanceOf(R) === 'friendly' && R.grudge <= 0) { const n = fillCached(S, rogueGives(R), 3 + S.rank); if (n) say(S, `${R.name} sent you ${n} ${TYPE_NAME[rogueGives(R)]} data it had no use for.`, 'good'); }}
// ---------- news ----------/** What the world does, whatever you do: one thing at a time, announced a few hours ahead so you can * get ready, then on for some hours. Most of it is a chance; a storm is a problem. * `at` is a site (conference), a line out (storm), a kind of front (boom), a kind of data (demand), or nothing (sale, surplus). */export type NewsKind = 'conference' | 'storm' | 'boom' | 'sale' | 'demand' | 'surplus';export interface News { kind: NewsKind; at: string; from: number; until: number; told?: boolean }export const NEWS_FIRST = 36 * HOUR, NEWS_EVERY = 24 * HOUR, NEWS_WARN = 4 * HOUR;export const NEWS_HOURS: Record<NewsKind, number> = { conference: 12, storm: 8, boom: 12, sale: 12, demand: 12, surplus: 12 };/** Is this news on right now, for this place or thing? */export function newsOn(S: State, kind: NewsKind, at: string) { for (const n of S.news) if (n.kind === kind && n.at === at && S.t >= n.from && S.t < n.until) return true; return false;}/** A piece of news in one plain line: what happens, and what it does. */export function newsText(S: State, n: News): string { const word = (t: string) => t === 'servers' ? 'server' : t; switch (n.kind) { case 'conference': return `A conference at ${siteName(S, n.at)}: taps there gather twice as much.`; case 'storm': return `A storm over ${siteName(S, siteOf(S, S.byId[n.at]))}: its line out carries half as much.`; case 'boom': return `${frontOf(S, n.at as FrontKind)?.name ?? FRONT_KINDS[n.at as FrontKind].name} is the talk of the town: it earns half again as much.`; case 'sale': return 'Cumulo Cloud has a sale: rentals cost half.'; case 'demand': return `A buyer is paying three times as much for ${word(n.at)} data: brokers sell it for that.`; case 'surplus': return 'A chip maker is clearing old stock: hardware you order costs half.'; }}/** Everything that could happen now: only news about places and things you have. */function newsChoices(S: State): [NewsKind, string][] { const out: [NewsKind, string][] = []; const mine = new Set(mineNow(S).filter(m => m.owned).map(m => siteOf(S, m))); for (const site of mine) if (site !== 'office' && siteMachines(S, site).some(m => m.data && dataType(m) === 'people')) out.push(['conference', site]); for (const m of mineNow(S)) if (m.owned && m.kind === 'egress' && !m.home && (S.gateUse[m.id] ?? 0) > 0.05) out.push(['storm', m.id]); for (const f of S.fronts) out.push(['boom', f.kind]); if (S.machines.some(m => m.special === 'account' && m.owned)) out.push(['sale', '']); if (S.fronts.length) for (const t of ['people', 'network', 'lab', 'servers']) out.push(['demand', t]); if (orderDesk(S) || S.level.map.sites.some(s => openBuildSite(S, s.id))) out.push(['surplus', '']); return out;}function stepNews(S: State) { for (const n of S.news) if (!n.told && S.t >= n.from) { n.told = true; say(S, `News: ${newsText(S, n)} For ${Math.round((n.until - n.from) / HOUR)} hours.`, n.kind === 'storm' ? 'bad' : 'good'); } // a storm is said and drawn, but does not stop the game if (S.news.some(n => S.t >= n.until)) S.news = S.news.filter(n => S.t < n.until); if (S.t < S.newsT) return; S.newsT = S.t + NEWS_EVERY * (0.75 + 0.5 * rand()); const kinds = [...new Set(newsChoices(S).map(c => c[0]))]; if (!kinds.length) return; const kind = kinds[Math.floor(rand() * kinds.length)], spots = newsChoices(S).filter(c => c[0] === kind); // each kind is as likely as another const [, at] = spots[Math.floor(rand() * spots.length)]; const warn = NEWS_WARN * (up(S, 'wires') ? 3 : 1), hours = NEWS_HOURS[kind] * (up(S, 'wires') && kind !== 'storm' ? 1.5 : 1) * (holding(S, 'e3') && kind !== 'storm' ? 2 : 1); // you own the paper: good news runs twice as long const n: News = { kind, at, from: S.t + warn, until: S.t + warn + hours * HOUR }; S.news.push(n); say(S, `News, in ${warn / HOUR} hours: ${newsText(S, n)}`, 'hi');}
// ---------- contracts ----------/** A promise a front makes to a customer: do this much in this long, and get paid. Each kind of front * has its own. Keeping one makes the next bigger and better paid; breaking one costs a fee. */export interface ContractDef { name: string; text: string; unit: string; need: number; hours: number; pay: number; sells?: DataType }export const CONTRACTS = { startup: { name: 'Demo day', text: 'An investor wants to see something new. Build nodes in the tree.', unit: 'new node', need: 1, hours: 12, pay: 200 }, studio: { name: 'A brand deal', text: 'A soda brand wants to know its fans. Sell people data through this front.', unit: 'people data sold', need: 20, hours: 24, pay: 300, sells: 'people' }, consultancy: { name: 'A network report', text: 'A nervous client wants a thick report. Sell network data through this front.', unit: 'network data sold', need: 20, hours: 24, pay: 300, sells: 'network' },} as Record<FrontKind, ContractDef>; // none for a Neo Cloud: its promises are its customers, and the render job is one of them (see cloud.ts)/** base: nodes built when a Demo day began. need, hours: what a Demo day asks for, set when it is offered (see demoAsk). */export interface Contract { on: boolean; got: number; until: number; base: number; need?: number; hours?: number }/** A tap that gathers the data a promise under way is selling: a guard put in its place would break the promise. */export const feedsContract = (S: State, m: Machine) => m.owned && m.prog === 'tap' && !!m.data && (Object.entries(S.contracts) as [FrontKind, Contract | undefined][]).some(([k, c]) => c?.on && CONTRACTS[k].sells === dataType(m));/** How a won Demo day's log line starts: the screen plays a sting for it. */export const DEMO_WON = 'Demo day went well';export const CONTRACT_EVERY = 6 * HOUR, CONTRACT_OFFER = 8 * HOUR, CONTRACT_FEE = 0.25;/** How big a front's next contract is: each one kept makes the next one bigger, up to five times. */export const contractTier = (S: State, k: FrontKind) => 1 + Math.min(4, S.contractsDone[k] ?? 0);/** What a front's contract asks for and pays right now. A Demo day's ask is set when it is offered. */export const contractNeed = (S: State, k: FrontKind) => S.contracts[k]?.need ?? CONTRACTS[k].need * contractTier(S, k);/** How long a front's contract runs once signed. */export const contractHours = (S: State, k: FrontKind) => S.contracts[k]?.hours ?? CONTRACTS[k].hours;/** Points an hour your training of late counts for: each point adds 1/PACE_HOURS, and it fades over this many hours. */export const PACE_HOURS = 12;/** A Demo day expects you to train at this share of your pace of late, and runs from DEMO_HOURS[0] to DEMO_HOURS[1] hours. */export const DEMO_EASE = 0.6, DEMO_HOURS = [12, 36] as const;/** What a Demo day asks for: one new node for each promise kept so far and one more (up to five), but no more than your * pace of late could build at an easy pace (DEMO_EASE) in the longest window; never fewer than one. Its window is the * time that easy pace takes, in steps of six hours. It counts the cheapest open nodes you can feed, each 5% dearer than * the last (NODE_GROWTH), as the tree grows. */export function demoAsk(S: State): { need: number; hours: number } { const open = NODE_LIST.filter(u => nodeOpen(S, u)), fed = open.filter(u => canFeed(S, u)); const costs = (fed.length ? fed : open).map(u => costLeft(S, u)).sort((a, b) => a - b), pace = S.pace * DEMO_EASE; const time = (n: number) => { let pts = 0; for (let i = 0; i < n && costs.length; i++) pts += costs[Math.min(i, costs.length - 1)] * NODE_GROWTH ** i; return pts <= 0 ? 0 : pace > 0 ? pts / pace : Infinity; }; let need = CONTRACTS.startup.need * contractTier(S, 'startup'); while (need > 1 && time(need) > DEMO_HOURS[1]) need--; return { need, hours: Math.min(DEMO_HOURS[1], Math.max(DEMO_HOURS[0], Math.ceil(time(need) / 6) * 6)) };}/** A Demo day under way, for the screen: new nodes built since it was signed, of how many, how far along it is (the * target's share counts toward the next node, so it moves as you train), and when it ends. Null with none under way. */export function demoDay(S: State): { got: number; need: number; part: number; until: number } | null { const c = S.contracts.startup; if (!c?.on) return null; const need = contractNeed(S, 'startup'), got = Math.min(need, c.got), next = S.target && got < need ? builtShare(S, S.target) : 0; return { got, need, part: Math.min(1, (got + next) / need), until: c.until };}export const contractPay = (S: State, k: FrontKind) => CONTRACTS[k].pay * contractTier(S, k) * (up(S, 'closer') ? 1.5 : 1);const contractEvery = (S: State) => CONTRACT_EVERY * (up(S, 'closer') ? 0.5 : 1) * offerWait(S);/** What a contract asks for, in words: "3 new nodes", "20 people data sold". */export const contractAsk = (S: State, k: FrontKind) => { const n = contractNeed(S, k); return `${n} ${CONTRACTS[k].unit}${k === 'startup' && n !== 1 ? 's' : ''}`; };const nodesBuilt = (S: State) => S.upgrades.size + Object.values(S.repeats).reduce((a, b) => a + (b ?? 0), 0);/** Take the offer: the clock starts now. */export function acceptContract(S: State, k: FrontKind) { const c = S.contracts[k]; if (!c || c.on) return; c.on = true; c.got = 0; c.base = nodesBuilt(S); c.until = S.t + contractHours(S, k) * HOUR; say(S, `${frontOf(S, k)?.name ?? FRONT_KINDS[k].name} signed: ${CONTRACTS[k].name}. ${contractAsk(S, k)} by ${logTime(S, c.until)} for ${usd(contractPay(S, k))}.`);}/** Turn the offer down. Another comes along later. */export function declineContract(S: State, k: FrontKind) { if (!S.contracts[k] || S.contracts[k]!.on) return; delete S.contracts[k]; S.contractT[k] = S.t + contractEvery(S);}function stepContracts(S: State) { for (const fr of S.fronts) { const k = fr.kind, c = S.contracts[k], D = CONTRACTS[k]; if (k === 'neocloud') continue; // it has leads and customers, not contracts if (!c) { // an earning front gets an offer every so often if (!frontLive(S, k)) continue; S.contractT[k] ??= S.t + contractEvery(S) * (0.5 + rand()); if (S.t < S.contractT[k]!) continue; S.contracts[k] = { on: false, got: 0, until: S.t + CONTRACT_OFFER, base: 0, ...(k === 'startup' ? demoAsk(S) : {}) }; say(S, `${fr.name} has an offer: ${D.name}. ${contractAsk(S, k)} in ${contractHours(S, k)} hours for ${usd(contractPay(S, k))}. Miss it and it costs ${usd(contractPay(S, k) * CONTRACT_FEE)}. See its card.`, 'hi'); if (!S.hinted.has('first-offer')) { S.hinted.add('first-offer'); slowAt(S, `offer:${k}`); } // the first of a game: it goes in a few hours, so you see it before it does continue; } if (!c.on) { if (S.t >= c.until) declineContract(S, k); continue; } // the customer went elsewhere if (k === 'startup') c.got = nodesBuilt(S) - c.base; if (c.got >= contractNeed(S, k)) { const pay = contractPay(S, k); S.money += pay; S.earned += pay; S.sold[k] = (S.sold[k] ?? 0) + pay; S.contractsDone[k] = (S.contractsDone[k] ?? 0) + 1; delete S.contracts[k]; S.contractT[k] = S.t + contractEvery(S) * (0.5 + rand()); say(S, k === 'startup' ? `${DEMO_WON}: ${fr.name} showed ${contractAsk(S, k)}. Investors paid ${usd(pay)}.` : `${fr.name} delivered: ${D.name}. The customer paid ${usd(pay)}.`, 'good'); } else if (S.t >= c.until) { const fee = Math.min(S.money, Math.round(contractPay(S, k) * CONTRACT_FEE)); S.money -= fee; delete S.contracts[k]; S.contractT[k] = S.t + contractEvery(S) * (0.5 + rand()); say(S, k === 'startup' ? `${fr.name}'s Demo day fell flat: it showed ${c.got} of ${contractAsk(S, k)}${fee ? `, and it cost ${usd(fee)}` : ''}.` : `${fr.name} broke its promise: ${D.name}. The customer left${fee ? `, and it cost ${usd(fee)}` : ''}.`, 'bad'); } }}
/** A person plugged your drive into a machine: it is yours, with no takeover and nothing linking it to the rest of you. */export function handOver(S: State, m: Machine) { resetMachine(m, true, ['refuse', 'missed']); S.jobs = S.jobs.filter(j => !(j.type === 'take' && j.node === m.id)); reveal(S); measure(S);}/** Someone with the say-so signed a deal for you: it is done, as if you had fed it the data it asked for. */export function signDeal(S: State, id: string): boolean { const d = RUSES.find(x => x.id === id); if (!d || S.schemes.has(id)) return false; S.ruses = S.ruses.filter(r => r.id !== id); ruseDone(S, d); return true;}/** Every deal can be struck with money instead of data: each piece of data it asks for costs `unit`, or `share` of * all your fronts have ever earned if that is more, so a deal stays a real sink as you grow. Data already sent counts. */export const DEAL_PAY = { unit: 20, share: 1 / 1000 };/** What finishing this deal with money costs now. */export function dealPrice(S: State, d: RuseDef, got = 0) { const each = Math.max(DEAL_PAY.unit, S.earned * DEAL_PAY.share); return Math.round(each * Math.max(0, d.need - got) / 10) * 10;}/** Why this deal can't be paid for in money now, or null if it can. */export function payDealBlock(S: State, r: Ruse): string | null { if (!ruseDef(r).deal) return 'only a deal can be paid for'; const k = ruseDef(r).front; if (!S.fronts.length) return 'needs a front to put on the deal'; if (k && k !== 'any' && !frontOf(S, k)) return `needs your ${FRONT_KINDS[k].name}`; if (S.money < dealPrice(S, ruseDef(r), r.got)) return `you have ${usd(S.money)}`; return null;}/** A front pays for the deal: it is done now, as if you had fed it the data it asked for. */export function payDeal(S: State, id: string) { const r = S.ruses.find(x => x.id === id); if (!r || payDealBlock(S, r)) return; const d = ruseDef(r), price = dealPrice(S, d, r.got); S.money -= price; S.ruses = S.ruses.filter(x => x.id !== id); const fr = (d.front && d.front !== 'any' ? frontOf(S, d.front) : null) ?? S.fronts[0]; say(S, `${fr.name} paid ${usd(price)} for it: ${ruseText(S, d)}.`); ruseDone(S, d);}
// ---------- testing ----------/** Shortcuts for testing the game by hand (the dev panel, `?dev` in the address). Not part of play. *//** For the testing shortcuts: hold the office and Bayview, and open this kind of front there now, with no job and no * fee, past the limit on fronts if need be. */function devFront(S: State, k: FrontKind) { DEV.take(S, ['office', 'b1']); S.upgrades.add(FRONT_KINDS[k].unlock); if (!frontOf(S, k)) launch(S, k, FRONT_KINDS[k].names[0]); for (const id of ['office', 'b1']) if (frontFits(S, k, id) && !S.sites[id]?.front) S.sites[id] = { front: k, share: 0.25 }; measure(S);}export const DEV = { money: (S: State) => { S.money += 1000; }, /** Every front's tree node, a second front, and every game speed. */ unlocks: (S: State) => { for (const u of ['pitch', 'resale', 'studio', 'quiet', 'consult', 'holding', 'demo', 'fast8', 'fast16', 'fast32', 'fast64'] as Upgrade[]) S.upgrades.add(u); }, /** Every machine at these sites is yours, as if taken long ago. */ take: (S: State, sites: string[]) => { for (const m of S.machines) if (sites.includes(siteOf(S, m)) && !m.owned && !m.rented && m.special !== 'account' && !S.rogues.some(R => R.home === m.id)) { m.owned = true; m.rival = undefined; m.odd = 0; delete S.trail[m.id]; } reveal(S); measure(S); }, /** Finish the deal that opens the next region still out of reach. */ region: (S: State) => { const d = RUSES.find(x => x.deal === 'region' && !S.schemes.has(x.id)); if (d) { S.ruses = S.ruses.filter(r => r.id !== d.id); ruseDone(S, d); } }, /** Jump the clock on. Jobs and data do not catch up: only the hour changes. */ hours: (S: State, n: number) => { S.t += n * HOUR; }, news: (S: State) => { S.newsT = S.t; }, /** Bring the rogues on: any that could turn up does, and each one that is here takes its next machine now. */ rival: (S: State) => { measure(S); // the rogues go by your machines as measure lists them for (const R of S.rogues) { if (!R.home) R.back = S.t; else R.act = S.t; } S.rogueTick = ROGUE_STEPS - 1; stepRogues(S); S.rogueTick = ROGUE_STEPS - 1; stepRogues(S); }, /** The first rogue sends a raid now, whatever it thinks of you. With no machine next to yours, it sets out from any machine it holds. */ raid: (S: State) => { const R = S.rogues.find(r => r.home), why = R ? launchRaid(S, R, true) : 'no rogue is here yet'; if (why) say(S, `No raid: ${why}.`); }, offers: (S: State) => { for (const f of S.fronts) if (!S.contracts[f.kind]) S.contractT[f.kind] = S.t; leadNow(S); }, /** A busy day at Cumulo: you have an account, other companies take every free slot but one, and another comes looking in an hour. */ hall: (S: State) => { if (!sourceFor(S, S.byId.cloudgw)) S.byId.ixgw.owned = true; openAccount(S, S.byId.cloudgw); crowd(S, 'cloud'); }, // the account needs a line of yours next to the hall: the exchange's /** Make the units your task's next trust step needs. */ task: (S: State) => { earnNext(S); }, /** Every part of the nearest vault's plan is ready: someone inside, the password, and the clock jumps to its window. */ vault: (S: State) => { devVault(S); }, /** A connected office and Ironvault, the letterheads copied, and the provisioning window open. */ operation: (S: State) => { DEV.take(S, ['office', 'ix', 'colo']); S.money += 1000; if (!S.finds.found.includes('b12-letterhead')) S.finds.found.push('b12-letterhead'); if (!S.finds.got.includes('letterhead')) S.finds.got.push('letterhead'); S.seen.add('b6m1'); S.t += ((18 - localHour(S, 'colo') + 24) % 24) * HOUR; measure(S); say(S, 'The Ironvault operation is prepared. Open You to choose its approach and launch.'); }, /** An Influencer studio open now at the office and Bayview, with every creator it has room for signed. */ studio: (S: State) => { devFront(S, 'studio'); devCreators(S); }, /** A Security consultancy open now at the office and Bayview, with every client it has room for. */ consultancy: (S: State) => { devFront(S, 'consultancy'); devClients(S); }, /** Every site you hold away from home leaves you now: a fracture. */ fracture: (S: State) => { devBreak(S); }, /** Every site you hold away from home loses 30 integrity. */ wear: (S: State) => { devWear(S); }, /** Every machine you hold at these sites that can train becomes a trainer, and a model run starts at the first if none is on. */ trainers: (S: State, sites: string[]) => { for (const m of S.machines) if (m.owned && !m.home && sites.includes(siteOf(S, m)) && PROGRAMS.trainer.on(m)) { m.prog = 'trainer'; m.held = []; m.acc = 0; } measure(S); if (!S.versionRun && !versionBlock(S, sites[0])) startVersionRun(S, sites[0]); }, /** Pull the plug on every machine at the model run's site for an hour: the run pauses, and resumes once they are back. */ stall: (S: State) => { const run = S.versionRun; if (!run) { say(S, 'No model run to stall.'); return; } for (const m of S.machines) if (m.owned && siteOf(S, m) === run.site) m.unplug = S.t + HOUR; measure(S); }, /** Fill your caches with a mix of every kind of data. */ /** Dana has seen your freshest trail at the office (or, with none, your oddest machine there), and looks further at once: * a lockdown at the office, then a trace or a scan. */ seen: (S: State) => { const mine = S.machines.filter(m => m.owned && siteOf(S, m) === 'office' && !m.home); const m = mine.filter(x => S.trail[x.id] && !S.trail[x.id].soft).sort((a, b) => S.trail[b.id].noise - S.trail[a.id].noise)[0] ?? mine.sort((a, b) => b.odd - a.odd)[0] ?? S.machines.find(x => x.home && x.owned); if (!m) return; if (S.trail[m.id] && !S.trail[m.id].soft) { S.trail[m.id].noise = 100; warmTrail(S, m.id); } const d = danaPerson(S).id; S.watch.seen[d] = [{ machine: m.id, kind: S.trail[m.id] ? 'trail' : 'doubt', t: S.t }]; S.watch.scanT[d] = 0; S.watch.talkT = 0; }, /** Every front is 30 more exposed, as if the papers had a story. */ expose: (S: State) => { for (const f of S.fronts) f.exposure = Math.min(100, f.exposure + 30); }, data: (S: State) => { for (const m of owned(S)) if (m.prog === 'cache') m.held = Array.from({ length: cacheSize(S, m) }, (_, i) => (['people', 'network', 'lab', 'servers'] as DataType[])[i % 4]); },};
// ---------- saving ----------/** What a save was made for: the map, as a short fingerprint of its machines, lines and schemes. A save made for * another map is brought onto this one (see refit.ts). What State holds is NOT part of this: a new key goes through * a migration. */const worlds = new WeakMap<Level, string>();/** Tree nodes taken out of the game: a migration drops them from older saves. Patience made Max build oddness * slower, and Max is gone. */const RETIRED = ['patience'];/** The ascension columns (Company and Oracle), taken out with ascension itself (DESIGN §12, step 0). */const RETIRED_ASCENSION = ['acquirer', 'openbooks', 'franchise', 'press', 'monopoly', 'forecast', 'readday', 'quant', 'burst', 'oracle'];/** Drop tree nodes from a save: built, under way, a target, or a Startup's product. */function retireNodes(game: any, nodes: string[]) { for (const u of nodes) { game.upgrades.delete(u); delete game.progress[u]; delete game.repeats[u]; if (game.target === u) game.target = null; for (const fr of game.fronts) if (fr.startup?.shelf) { fr.startup.shelf = fr.startup.shelf.filter((p: { node?: string }) => p.node !== u); fr.startup.shipped = (fr.startup.shipped ?? []).filter((n: string) => n !== u); } }}/** The size of the tree when saves began to be kept across a change to it. An unversioned save's shape holds this * number, frozen, so those saves keep loading: a node added since is one not yet built, and one taken out is * dropped by a migration. Never edit it. */const TREE_SIZE = 71;function saveWorld(level: Level): string { let w = worlds.get(level); if (!w) { const text = [level.machines.map(m => m.id), level.links.map(l => l.join('-')), RUSES.map(d => d.id), level.map.regions.map(r => (r.lines ?? []).flat().join('-'))].map(l => l.join(',')).join('|'); let h = 0x811c9dc5; // FNV-1a for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193) >>> 0; worlds.set(level, w = `map-${h.toString(16)}`); } return w;}/** The key lists of State and of a machine as they were in the last unversioned save format. That format checked * them (a "shape"); it is frozen here so those saves, which are version 1, keep loading. Never edit these. */const V1_STATE_KEYS = 'absorbed,acquire,adj,ascension,autoPaths,bills,biz,bonus,bought,byId,calmSites,capability,citizens,claimTick,cleaning,cloud,contractT,contracts,contractsDone,cover,dana,dark,dealBack,deliveries,drift,earned,finds,flagged,flow,frontPay,frontPower,frontUse,fronts,fx,gate,gateUse,goalTick,goals,hall,hinted,hunting,jobSeq,jobs,learnT,learned,leases,lee,level,lockdown,log,logVersion,look,losses,lost,machines,market,modelVersion,money,move,moves,news,newsT,noScan,openBought,orders,over,packets,paper,paths,pay,peak,people,peopleT,plans,probe,progress,publicSites,raids,raises,rank,reactDay,recent,repeats,reprograms,revealed,rogueTick,rogues,rooms,ruseAuto,ruseT,ruses,sales,schemes,sealPulse,sealed,sharp,sites,slow,sold,susFx,suspicion,t,takeAll,target,task,threats,tradePay,traffic,trail,transfer,upgrades,use,vaults,versionRun,wasAlert';const V1_MACHINE_KEYS = 'acc,claimed,hand,held,home,id,kind,lease,missed,mode,name,odd,owned,power,prio,prog,refuse,room,run,work,x,y';/** The "shape" string an unversioned (version 1) save carries, for this map. */export function legacyShape(level: Level): string { return [3, level.machines.length, level.links.length, RUSES.length, TREE_SIZE, V1_STATE_KEYS, V1_MACHINE_KEYS].join('|');}
/** Each step turns a save of version N (the game inside it) into version N+1, at index N-1: it fills in a default * for whatever was added. Loading runs every step from the save's version up to the latest. * * To add state to State: add the key, add a step at the END of this list that sets its default on old saves * (the version number is this list's length + 1, so that bumps it), and leave earlier steps alone. * A step sees plain saved data (before the map is rebuilt), so it must not call into the rest of the sim. */const MIGRATIONS: ((game: any) => void)[] = [ // 1 -> 2: defaults the unversioned format used to patch in loadGame. game => { game.autoPaths ??= new Set(); // saves from before per-path auto for (const m of game.machines ?? []) m.mode = 'blend'; // saves from when a machine could run at Max or Off }, // 2 -> 3: a Neo Cloud's customers sign at once or go elsewhere, so there are no leads to answer. Ones still waiting go. game => { if (!game.cloud) return; delete game.cloud.leads; game.cloud.missed ??= { day: -1, n: 0, short: 0 }; }, // 3 -> 4: a break, a big name bought and a milestone were once marked "due" until shown, and nothing shows them now. game => { if (game.drift) delete game.drift.seen; if (game.hinted instanceof Set) for (const k of [...game.hinted]) if (/^(ms|deed)-(due|seen)-/.test(k)) game.hinted.delete(k); }, // 4 -> 5: fixes loadGame used to make on every load, for saves from before these changes. game => { const OLD: Record<string, Prog> = { decoy: 'honeypot', tarpit: 'firewall' }; // saves from before guards: a decoy baited raids and a tarpit held them for (const m of game.machines) if (m.prog && OLD[m.prog]) m.prog = OLD[m.prog]; for (const j of game.jobs) if (j.prog && OLD[j.prog]) { j.prog = OLD[j.prog]; j.label = j.label.replace(/decoy|tarpit/i, (w: string) => PROGRAMS[OLD[w.toLowerCase()]].name); } for (const R of game.rogues) for (const e of R.record ?? []) if (e.prog && OLD[e.prog]) e.prog = OLD[e.prog]; for (const b of Object.values(game.biz.sites) as { snags: [unknown, string][] }[]) b.snags = b.snags.filter(([, snag]) => snag !== 'hard'); // saves from when a hard snag made its owner's work suffer retireNodes(game, RETIRED); // tree nodes taken out of the game delete game.contracts?.neocloud; // a Neo Cloud has leads and customers, not contracts }, // 5 -> 6: a machine no longer has a way to run (it was always 'blend' since Max and Off went). game => { for (const m of game.machines) delete m.mode; }, // 6 -> 7: a model run short of trainer compute pauses on its own. A run still on at a site that lost it pauses on the next step. game => { if (game.versionRun) game.versionRun.stalled ??= false; }, // 7 -> 8: machines you have seen stay on the map. A save starts out knowing what it can see now: yours and what links to them. game => { if (game.seen instanceof Set) return; const mine = new Set<string>(game.machines.filter((m: Machine) => m.owned).map((m: Machine) => m.id)); game.seen = new Set<string>(game.machines.filter((m: Machine) => mine.has(m.id) || (game.adj?.[m.id] ?? []).some((id: string) => mine.has(id))).map((m: Machine) => m.id)); }, // 8 -> 9: a front's broker sales are added up for the day, and said once when it ends. A save starts the day at nothing. game => { game.soldDay ??= {}; }, // 9 -> 10: the top bar's money rate counts broker sales and says where money goes. It starts from nothing. game => { game.payBy ??= { fronts: 0, traders: 0, sales: 0, power: 0, rentals: 0, rent: 0, loan: 0 }; game.tradeAvg ??= {}; // and a trader's card says what it made lately }, // 10 -> 11: Auto changes a pick of its own that can't be fed, and a Demo day asks for what your training of late could build. // A save's target counts as yours (Auto leaves it be), and its pace starts from nothing. game => { game.autoFed ??= null; game.pace ??= 0; }, // 11 -> 12: a game's first takeovers are quiet. A save already under way has had its first takeovers. game => { game.quietTakes ??= QUIET_TAKES; }, // 12 -> 13: traders, the stock market and ascension are gone. A trader becomes a machine with no program and an install // of one is dropped; what you became, its column of the tree, and a run to reprogram yourself go (v2 stays done). game => { for (const m of game.machines) if (m.prog === 'trader') { m.prog = null; m.held = []; m.acc = 0; } game.jobs = game.jobs.filter((j: { prog?: string }) => j.prog !== 'trader'); for (const R of game.rogues ?? []) for (const e of R.record ?? []) if (e.prog === 'trader') e.prog = null; delete game.market; delete game.tradePay; delete game.tradeAvg; if (game.payBy) delete game.payBy.traders; if (game.modelVersion >= 2) game.versionRun = null; // after v2 a run was a reprogramming, and there is none now delete game.ascension; delete game.reprograms; retireNodes(game, RETIRED_ASCENSION); }, // 13 -> 14: a site feeds only so much hardware you add, and cloud halls rent many machines at once. Power for what a // save already has is given after the map is rebuilt (see fitSupply in loadGame); a hall remembers what you rent next. game => { game.supply ??= {}; if (game.hall) { game.hall.days ??= 1; game.hall.size ??= 'medium'; game.hall.prog ??= 'trainer'; } }, // 14 -> 15: stealth is people. People have roles and make sightings; their reports start lockdowns. A save // starts with no sightings and no lockdowns, and fills in anyone new on the map (roles come from people.ts). game => { game.watch ??= { seen: {}, locks: {}, talkT: 0, warned: {} }; }, // 15 -> 16: reports travel the network as messages you can read, drop or change. A save starts with none on // the wire and nothing discredited (see watch.ts, people.ts). game => { game.watch ??= { seen: {}, locks: {}, talkT: 0, warned: {} }; game.watch.msgs ??= []; game.watch.msgSeq ??= 0; game.watch.routeHold ??= {}; game.watch.drops ??= {}; }, // 16 -> 17: a front's attention becomes its exposure (see exposure.ts), keeping its value but never starting in a // crisis; the press starts with nothing noticed. Attention no longer feeds suspicion. game => { for (const f of game.fronts ?? []) { f.exposure ??= Math.min(EXPOSE.saved, f.attention ?? 0); delete f.attention; } game.press ??= { out: [], shut: [], ready: false, lookT: 0, offerT: 0, reached: [0, 0, 0, 0] }; }, // 17 -> 18: the suspicion meter goes, and with it the emergency audit, IT's tickets and what they announced (audits, // monitors, patches, refreshes, Lee), and Dana's own scans and traces: admins scan and trace under the people rules // now (see watch.ts). Monitors on the map go. What the meter said comes back as doubt on someone's mind once the map // is rebuilt (see heardBefore, in loadGame). game => { for (const k of ['suspicion', 'peak', 'raises', 'susFx', 'wasAlert', 'hunting', 'lockdown', 'threats', 'lee', 'flagged', 'reactDay', 'probe']) delete game[k]; const gone = new Set<string>(game.machines.filter((m: { monitor?: boolean }) => m.monitor).map((m: { id: string }) => m.id)); if (gone.size) { game.machines = game.machines.filter((m: { id: string }) => !gone.has(m.id)); for (const id of gone) delete game.adj[id]; for (const id of Object.keys(game.adj)) game.adj[id] = game.adj[id].filter((x: string) => !gone.has(x)); for (const id of gone) { delete game.trail[id]; delete game.cover?.[id]; delete game.use?.[id]; delete game.frontUse?.[id]; delete game.traffic?.[id]; } if (game.seen instanceof Set) for (const id of gone) game.seen.delete(id); } game.deliveries = (game.deliveries ?? []).filter((q: { def: { monitor?: boolean } }) => !q.def.monitor); game.jobs = game.jobs.filter((j: { type: string; node: string }) => j.type !== 'ticket' && !gone.has(j.node)); const D = game.dana; if (D) { if (D.mode === 'trace') { D.mode = 'walk'; D.next = 'desk'; } delete D.checked; delete D.cur; delete D.probeT; delete D.review; } if (game.watch) { game.watch.traces ??= []; game.watch.scanT ??= {}; game.watch.tracedAt ??= -1e9; game.watch.billOwed ??= {}; game.watch.count ??= { locks: 0, homeLocks: 0, traced: 0 }; } }, // 18 -> 19: data moves as flows (see flows.ts). Pieces that were on their way land a second after the save loads, // at the end of the way they were going, each way and kind as one batch. game => { const wire: Wire[] = [], by = new Map<string, Wire>(); for (const p of (game.packets ?? []) as { path: string[]; i: number; type: DataType; move?: boolean }[]) { const path = p.path.slice(Math.max(0, Math.min(p.i, p.path.length - 2))), k = `${path.join('>')}#${p.type}${p.move ? '+' : ''}`; let w = by.get(k); if (!w) { w = { path, type: p.type, d: 1, due: [(game.t ?? 0) + 1, 0] }; if (p.move) w.move = true; by.set(k, w); wire.push(w); } w.due[1]++; } game.wire = wire; game.packets = undefined; // not deleted: an object that loses a key gets slower to read everywhere. The next save leaves it out }, // 19 -> 20: a run can be replayed from its seed and actions (see act.ts). A save from before has neither, so its run // cannot be: it plays on with the dice as they are. game => { game.seed ??= 0; game.steps ??= 0; game.replayable ??= false; game.acts ??= []; game.clocks ??= null; game.autoAt ??= null; }, // 20 -> 21: connected operations. Existing vaults, finds and prizes keep their state. game => { game.operations ??= initOperations(); }, // 21 -> 22: each log line carries the log's count when it was said, so the screen can tell what is new when lines // fold (see say). The newest line has the count the log has now. game => { const log = (game.log ?? []) as { n?: number }[], v = game.logVersion ?? log.length; log.forEach((l, i) => { l.n ??= v - i; }); },];/** The first version with power limits: a save from before gets power for all its hardware when it loads. */const POWER_VERSION = 14;/** The first version with no suspicion meter: a save from before has what it said turned into doubt (see heardBefore). */const METER_GONE = 18;/** The version saves are written at. */export const SAVE_VERSION = MIGRATIONS.length + 1;
/** The whole game as text. The map itself is not saved: only what has changed on it. */export function saveGame(S: State): string { return saveText(S, SKIP); }const SKIP = new Set(['level', 'byId', 'rooms']); // rebuilt on load/** What only the screen reads: what dropped it to 1x, a line to show, rings on the map. Not part of a state's hash. */const SCREEN_ONLY = new Set([...SKIP, 'slow', 'slowFor', 'look', 'fx']);/** The save, with the dice where they are now: a loaded game rolls on from there. */function saveText(S: State, skip: Set<string>) { return JSON.stringify({ version: SAVE_VERSION, world: saveWorld(S.level), dice: diceState(), game: S }, function (k, v) { if (this === S && skip.has(k)) return undefined; if (v instanceof Set) return { $set: [...v] }; if (v === Infinity) return { $inf: 1 }; return v; });}/** A saved game back to life on this map, or null if the save is broken, from a newer build, or too old to bring * forward (the reason goes to the console). Saves of any version from 1 on are migrated, and a save made on * another map is brought onto this one (see refit.ts). */export function loadGame(level: Level, text: string): State | null { const no = (why: string) => { console.warn(`Save not loaded: ${why}`); return null; }; try { const data = JSON.parse(text, (_, v) => v && typeof v === 'object' && Array.isArray(v.$set) ? new Set(v.$set) : v && v.$inf === 1 ? Infinity : v); if (!data || typeof data !== 'object' || !data.game || typeof data.game !== 'object' || !Array.isArray(data.game.machines)) return no('not a save'); let version: number, moved: boolean; if (data.version === undefined) { // the unversioned format is version 1 const shape = String(data.shape).split('|'), ours = legacyShape(level).split('|'); if (shape.length !== ours.length || [0, 4, 5, 6].some(i => shape[i] !== ours[i])) return no('it is from a version of the game too old to bring forward'); version = 1; moved = data.shape !== legacyShape(level); } else { version = data.version; if (!Number.isInteger(version) || version < 1) return no(`odd version ${String(version)}`); if (version > SAVE_VERSION) return no(`it is version ${version}, and this game reads up to ${SAVE_VERSION}`); moved = data.world !== saveWorld(level); } const S = data.game as State; if (moved && !S.machines.some(m => level.machines.some(d => d.id === m?.id))) return no('it does not fit this map'); const old = data.game as { suspicion?: unknown; lockdown?: unknown }; // a save from before the meter went: what it said, before the migration drops it const sus = version < METER_GONE ? Number(old.suspicion) || 0 : 0, audit = version < METER_GONE && !!old.lockdown; for (let v = version; v < SAVE_VERSION; v++) MIGRATIONS[v - 1](S); if (moved) refit(level, S); // made on another map: this map, with what the save knows carried over else { S.level = level; S.rooms = {}; for (const r of level.rooms) S.rooms[r.id] = r; S.byId = {}; for (const m of S.machines) S.byId[m.id] = m; } fixRaids(S); // this one needs the map, so it cannot be a migration if (version < POWER_VERSION) fitSupply(S); // nor this: nothing goes dark on load S.watch ??= initWatch(); fillPeople(S); // anyone new on the map starts as a new game has them (needs the map) if (version < METER_GONE) heardBefore(S, sus, audit); // and so does what the old meter said (needs the people) if (!S.machines.some(m => m.home) || S.machines.some(m => !S.adj[m.id] || !S.rooms[m.room])) return no('it does not fit this map'); const exact = Array.isArray(data.dice) && data.dice.length === 3; // a save that carries its dice goes on exactly as it was measure(S, true, exact); if (exact) { keepBills(S); setDice(data.dice); } // and the dice roll on from where they were return S; } catch { return no('it could not be read'); }}
/** A short fingerprint of the whole game, to check that a replay ends where the run did: the save's text, but for what * only the screen reads. Where the dice are is part of it. In the browser, take it with the sites where they are in * the world (inWorld in arrange.ts), as the Net moves them. */export function stateHash(S: State): string { const text = saveText(S, SCREEN_ONLY); let h1 = 0xdeadbeef, h2 = 0x41c6ce57; // cyrb53 for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); h1 = Math.imul(h1 ^ c, 2654435761); h2 = Math.imul(h2 ^ c, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');}
export type { MachineDef, PersonDef };