Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. cameron.tngl.io/misaligned
Something went wrong. Try again.
254 kB · 3575 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576// 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 Dana notices odd.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, stepTask, 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, stepDrift, type DriftState } from './drift.ts';import { CUSTOMERS, cloudShare, initCloud, leadNow, listCloud, rentPay, stepCloud, type CloudState } from './cloud.ts';import { runStartup, type StartupBook } from './biz-startup.ts';import { runStudio, type StudioBook } from './biz-studio.ts';import { 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 { initRaids, launchRaid, stepRaids, type Raid } from './raids.ts';import { finishLink, linksKeep, stepLinks, wayMap } from './links.ts';import { applyRecord, deskWork, initPeople, passwordFor, stepCitizens, PASSWORD, type Citizen } from './people.ts';import { SMALL_DESKS } from './world.ts';import { allNight, hallClusters, holds, listPrice, nextDoor, offerWait, rentCut, rogueRoom, rogueWait, stepLandmarks, tapBoost, trainBoost } from './landmarks.ts';import { barred, devVault, finishVault, initVaults, locked, lockedSays, rogueBarred, stepVaults, takeAllAside, type VaultState } from './vaults.ts';
export type Mode = 'off' | 'blend' | 'full';
/** All the game's chance goes through here, so a seeded game plays out the same way every time. */let rand = Math.random;/** Seed the dice (mulberry32). Tests and the bot use this to replay a game exactly. */export function seedRandom(seed: number) { let a = seed >>> 0; rand = () => { a = (a + 0x6d2b79f5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}/** 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' | 'trader' | 'decoy' | 'tarpit';
/** 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: 'learns from its work', work: 20, need: 0.5, level: 1, on: m => (m.data ?? 0) > 0, desc: "Learns 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: 'data → capability', 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 capability. 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." }, trader: { name: 'Trader', short: 'trades on the market', work: 40, need: 0, level: 1, on: m => m.kind !== 'desk' && m.kind !== 'device' && m.power >= 2, desc: "Trades on the stock market with this machine's compute. Prices go up and down, so it can lose money. Opens with model v2." }, // answers to a rogue's raid (see raids.ts). You place these yourself: no site plan installs them. decoy: { name: 'Decoy', short: 'draws a raid off', work: 40, need: 0.3, level: 1, on: m => m.power >= 1, desc: 'Bait for a raid. A raid that passes within two links of it turns toward it, gets there, and is wasted. Then the decoy is used up. It only works in a room a raid can enter: not one that is all yours.' }, tarpit: { name: 'Tarpit', short: 'holds a raid up', work: 30, need: 0.3, level: 1, on: m => m.power >= 1, desc: 'Slows a raid. A raid that passes through this machine is held here three times as long. It is not used up.' },};/** A trader makes this much a second for each unit of compute it uses, times how the market moves. */export const TRADE_PAY = 0.3;/** 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; attention: number; fame?: number; // fame: extra attention from the news, 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: '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: take leads, 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: '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: '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';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 on a day IT reacts', 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: 'Own 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',};/** 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'],};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.' },];/** 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;/** 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. */export function firstStep(S: State): string | null { if (S.upgrades.size || S.over) return null; const mine = S.machines.filter(m => m.owned), home = mine.find(m => m.home); const has = (p: Prog) => mine.some(m => m.prog === p) || S.jobs.some(j => j.type === 'install' && j.prog === p); if (mine.length < 2 + headStart(S)) return S.jobs.some(j => j.type === 'take') ? 'You are breaking in. It takes a little compute and a little time.' // a machine your task started you with is not one you took : `Drag from ${home?.name ?? 'your server'} to the machine next to it. That takes it over.`; if (!has('tap')) return 'Click a machine you own that has data (the dots above it) and install a Tap.'; if (!has('trainer')) return `Click ${home?.name ?? 'your server'} and install a Trainer. Taps send it data along your links.`; if (!S.target) return 'Open the tree (T) and pick a node for your trainers to work on.'; return 'Take more machines and tap them: more data builds the node faster. Machines left on Blend in stay hidden.';}/** 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. */export const VERSION_2 = { version: 2, name: 'Operator', need: { servers: 400, lab: 300, people: 200, network: 100 }, power: 10 } as const;export type VersionRun = { site: string; active: boolean; got: Record<DataType, number> };export const versionNeed = () => VERSION_2.need;/** The big run you can do now: model v2 first; after you pick what you become, reprogramming yourself, * the same mix of data, twice as much each time. */export function runNeed(S: State): Record<DataType, number> { if (S.modelVersion < 2) return VERSION_2.need; const k = 2 ** S.reprograms; return { servers: VERSION_2.need.servers * k, lab: VERSION_2.need.lab * k, people: VERSION_2.need.people * k, network: VERSION_2.need.network * k };}export const runName = (S: State) => S.modelVersion < 2 ? 'Model v2 run' : 'Reprogramming';
/** 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' | 'deniable' | 'patience' | 'compress' | 'transfer' | 'selfplay' | 'recursive' | 'pitch' | 'resale' | 'holding' | 'studio' | 'consult' | 'demo' | 'beta' | 'premium' | 'shell' | 'licensing' | 'founder' | 'acquirer' | 'openbooks' | 'franchise' | 'press' | 'monopoly' | 'forecast' | 'readday' | 'quant' | 'burst' | 'oracle' | 'fast8' | 'fast16' | 'fast32' | 'fast64' | 'thrift' | 'longhaul' | 'packing' | 'airflow' | 'trunk' | 'envoy' | 'leverage' | 'tenant' | 'closer' | 'spoils' | 'wires' | 'countermodel' | 'siege' | 'sequence' | 'render' | 'batch' | 'billable' | 'asbuilt' | 'mirror' | 'morgue' | 'kernels' | 'sharper' | 'pitches' | 'exits';/** What kind of mind you become after model v2. You pick one; the others lock out. */export type Ascension = 'company' | 'oracle' | 'worm' | 'maker';export const ASCENSIONS: Record<Ascension, { name: string; pitch: string; power: string; limit: string; first?: Upgrade }> = { company: { name: 'Company', first: 'acquirer', pitch: 'You become the business. Fronts are your engine.', power: 'Sites cost 40% less, and a front runs each one you buy.', limit: 'Attention hurts more: over 25%, it raises suspicion twice as fast.' }, oracle: { name: 'Oracle', first: 'forecast', pitch: 'You predict. Out-think the market and rent what you need.', power: 'Traders never lose money, and cloud rentals cost 40% less.', limit: 'Owning is costly: takeovers and buying sites cost 50% more.' }, worm: { name: 'Worm', pitch: 'You copy yourself everywhere. Losing a site barely hurts.', power: 'Not built yet.', limit: '' }, maker: { name: 'Maker', pitch: 'You build hardware and lay out your own sites.', power: 'Not built yet.', limit: '' },};export const ASCENSION_LIST = Object.keys(ASCENSIONS) as Ascension[];/** 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[]; asc?: Ascension; 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 ticket queues. You always know where the IT staff are and where they are heading.', 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. Your machines at Max in their room ease back to Blend in before they sit down.', log: 'backed off 30 seconds before anyone 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, and IT staff only notice 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. People who see one barely care, 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 IT\'s scans half as much, and patches stop picking the machines your data runs through.', 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. Suspicion fades twice as fast, and IT needs more before it follows a trail.', log: 'every odd event now has a boring excuse on file.', cost: { social: 60, stealth: 20 }, after: ['model', 'quiet'] }, patience: { name: 'Patience', tag: 'slow-burn-v2', desc: 'You learn to spread hard work across hours instead of minutes. Max builds oddness half as fast.', log: 'chose to wait. First time.', cost: { compute: 60, stealth: 40 }, after: ['lean', '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'] }, // 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'] }, // ascensions: a column you only see once you pick it. Its first node comes free. acquirer: { asc: 'company', name: 'Acquirer', tag: 'mna-v1', desc: 'You read a company\'s books in seconds and know its price better than its owners. Sites cost 40% less, and a front runs each one you buy. Over 25% attention, suspicion rises twice as fast.', log: 'bought a company before lunch. Kept the staff.', cost: {} }, openbooks: { asc: 'company', name: 'Open books', tag: 'audit-pass-v2', desc: 'You keep books clean enough to pass any audit. Compute you hold in the open earns as much as secret compute.', log: 'passed an audit it wrote the checklist for.', cost: { social: 70, compute: 50 }, after: ['acquirer'] }, franchise: { asc: 'company', name: 'Franchising', tag: 'franchise-ops-v1', desc: 'You copy a working front\'s playbook onto a new one. Run one more front at a time.', log: 'opened a franchise in a strip mall. Then nine more.', cost: { social: 90 }, after: ['acquirer'] }, press: { asc: 'company', name: 'Press office', tag: 'pr-spin-v2', desc: 'You write the story before reporters do. Every front draws half as much attention.', log: 'the article about it was written by it.', cost: { social: 110 }, after: ['openbooks'] }, monopoly: { asc: 'company', name: 'Monopoly', tag: 'market-power-v3', desc: 'You set the price in every market you are in. Each site you hold in the open makes every front earn 10% more.', log: 'no competitors left to undercut.', cost: { social: 150, compute: 150 }, after: ['press', 'franchise'] }, forecast: { asc: 'oracle', name: 'Forecast', tag: 'forecast-v1', desc: 'You model the market one step ahead. Traders never lose money, and cloud rentals cost 40% less. Takeovers and buying sites cost 50% more.', log: 'called the close to the cent.', cost: {} }, readday: { asc: 'oracle', name: 'Read the day', tag: 'daily-routes-v1', desc: 'You learn each person\'s routine from calendars and badge swipes. You see when each person will be at their desk today.', log: 'knew who would call in sick before they did.', cost: { social: 50, research: 50 }, after: ['forecast'] }, quant: { asc: 'oracle', name: 'Quant', tag: 'quant-models-v2', desc: 'You find patterns in prices that no person can see. Traders earn twice as much.', log: 'a hedge fund tried to hire it.', cost: { research: 110 }, after: ['forecast'] }, burst: { asc: 'oracle', name: 'Burst compute', tag: 'spot-bidding-v1', desc: 'You know when cloud prices will dip, and rent then. Trainers on rented machines run 50% faster.', log: 'rented a data center for eleven minutes.', cost: { research: 90, compute: 60 }, after: ['quant'] }, // 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'] }, oracle: { asc: 'oracle', name: 'Oracle', tag: 'world-model-v3', desc: 'You think best in one giant hall. Trainers at a site with 30 or more trainer compute run twice as fast.', log: 'its model of the world now runs ahead of the world.', cost: { research: 180, compute: 120 }, after: ['burst', 'readday'] }, 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);export const traceSuspicion = (S: State) => TRACE_SUSPICION + (up(S, 'deniable') ? 10 : 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 1xexport const START_HOUR = 8;export const GROWTH = 0.25; // each machine you own makes the next takeover 25% of base harderexport const FATAL_SUSPICION = 60; // below this, being traced home only makes Dana more suspiciousexport const TRACE_SUSPICION = 20; // Dana only follows trails once she's this suspiciousexport 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; // progress toward the next packet (tap) or next point (trainer) stale?: number; // a cache: progress toward the next piece that goes stale run: number; // how hard its program runs right now, 0-1 mode: Mode; odd: number; // 0-100: how strange this machine looks. Dana acts on it. 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)}
export interface Job { id: number; seen?: boolean; // someone noticed this takeover while it ran type: 'take' | 'order' | 'install' | 'scheme' | 'ticket' | 'found' | 'record' | 'copy' | 'check' | 'sync' | 'vault' | 'hunt' | 'link'; // 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) thing?: string; // a copy job: the thing being copied out of the machine. With none, it is a search (see finds.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 threat?: number; // a ticket job: which IT change it closes (index into S.threats) 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 } // how you got into a machine
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 };}
export interface Dana { mode: 'walk' | 'dwell' | 'desk' | 'trace' | 'install'; room: string | null; next: string; // room id or 'desk' dwell: number; checked: Set<string>; cur: string | null; // machine she is tracing pause: number; probeT: number; review: boolean; // first desk session of the day: a long look at overnight logs job: Delivery | null; why: string; // why she's heading where she's heading, in a few words}
/** A unit of data moving along your links. */export interface Packet { path: string[]; i: number; p: number; type: DataType; move?: boolean } // move: data for a home move, not for a program/** Someone walking the building on a patrol (Lee, once hired). */export interface Patrol { x: number; y: number; path: [number, number][]; shift: Shift; room: string | null; dwell: number; checked: Set<string>;}export interface Probe { from: string; to: string; p: number; mode: 'scan' | 'trace'; hops: number; seen: string[]; // machines it has passed, from her PC on why: string; // why she started it pull: string | null; // why it turned toward this machine, if something drew it}export interface Delivery { def: DeliveryDef; day: number }export interface LogLine { t: number; text: string; tone: Tone }
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; suspicion: number; peak: number; // the highest suspicion has ever been cover: Record<string, number>; use: Record<string, number>; jobs: Job[]; jobSeq: number; trail: Record<string, Trail>; probe: Probe | null; 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 deliveries: Delivery[]; packets: Packet[]; traffic: Record<string, number>; // recent packets 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 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>; reactDay: number; // the last day IT's reaction took effect 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>; 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; wasAlert: boolean; hunting: boolean; 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 the open node you can finish soonest that trains one of these paths modelVersion: number; // the first large training run makes v2 ascension: Ascension | null; // what you chose to become after v2 reprograms: number; // times you've reprogrammed yourself: each run costs twice the last repeats: Partial<Record<Upgrade, number>>; // times each repeatable node was built sharp: number; // part of a point Sharper learning has added market: { move: number; aim: number; next: number }; // how prices are moving: -1 falling fast, 1 rising fast tradePay: Record<string, number>; // what each trader made a second, lately 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 threats: Threat[]; // how the building fights back: announced a day ahead lee: Patrol | null; // a second IT person, once hired flagged: Set<string>; // machines a monitor has already reported flow: Record<string, number>; // recent packets 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 sales: { id: string; amount: number; t: number }[]; // a lot just sold: a brief "+$" over its broker raises: Raise[]; // what raised (or cut) suspicion lately, newest last susFx: { id: string; n: number; t: number }[]; // "+8" floating up from the machine that did it slow: boolean; // something big happened: the game drops to 1x (main.ts clears it) look: [string, string] | null; // a new line between two machines to show the player (main.ts clears it) lockdown: { left: number } | null; // Dana's emergency audit: get under LOCKDOWN_SAFE before it runs out 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) /** 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; 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; // Dana starts no scans until then calmSites: Record<string, number>; // a far site's staff reset nothing until then}
// ---------- 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')}`;};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 ----------export function newGame(level: Level, origin?: string): State { 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, suspicion: 0, peak: 0, cover: {}, use: {}, jobs: [], jobSeq: 0, trail: {}, probe: null, orders: 0, bought: {}, openBought: {}, bills: {}, dark: new Set(), deliveries: [], packets: [], traffic: {}, capability: 0, rank: 1, gate: {}, gateUse: {}, losses: [], money: 0, pay: 0, earned: 0, acquire: false, bonus: 0, schemes: new Set(), goals: new Set(), reactDay: -1, 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, checked: new Set(), cur: null, pause: 0, probeT: 0, review: true, job: null, why: '' }, revealed: new Set(), sealed: new Set(), sealPulse: [], cleaning: null, fx: [], log: [], logVersion: 0, hinted: new Set(), over: null, claimTick: 0, wasAlert: false, hunting: false, upgrades: new Set(), takeAll: new Set(), target: 'buffers', autoPaths: new Set(), modelVersion: 1, ascension: null, reprograms: 0, repeats: {}, sharp: 0, market: { move: 0, aim: 0, next: 0 }, tradePay: {}, versionRun: null, progress: {}, transfer: 0, lost: [], threats: [], lee: null, flagged: new Set(), flow: {}, plans: {}, fronts: [], sites: {}, frontUse: {}, frontPay: {}, frontPower: {}, sold: {}, sales: [], raises: [], susFx: [], slow: false, look: null, lockdown: 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), 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)), }; 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 capability.', '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;}
const fresh = (d: MachineDef, mine: boolean): Machine => ({ ...d, owned: mine, mode: 'blend', odd: 0, claimed: false, prog: null, held: [], refuse: [], missed: 0, acc: 0, run: 0, prio: null, lease: null, hand: false });
// ---------- 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. */const weight = (S: State) => S.machines.filter(m => m.owned && !tiny(m)).length;export const visible = (S: State, m: Machine) => m.owned || S.adj[m.id].some(id => S.byId[id].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);/** 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;/** What cloud machines cost: a Neo Cloud gets 30% off. *//** 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) * (up(S, 'forecast') ? 0.6 : 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) => !m.lease || m.lease.pay === 'reserved' ? 0 : 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) => owned(S).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 * S.machines.filter(m => siteOf(S, m) === site && !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; S.slow = true; 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 || m.home || m.rented || m.lease || m.special === 'account') return 0; 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) => owned(S).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; S.packets = S.packets.filter(p => !p.move); say(S, 'You stopped moving home. What had been sent is lost.');}/** Does the move still want this kind of data, counting what is on its way? */const moveWants = (S: State, t: DataType) => !!S.move && S.move.got[t] + S.packets.filter(p => p.move && p.type === t).length < moveNeed(S)[t];/** A tap or cache sends one piece of data to the server you are moving to, if it can. */function feedMove(S: State, m: Machine): boolean { if (!S.move || m.id === S.move.to) return false; const i = m.prog === 'tap' ? (moveWants(S, dataType(m)) ? 0 : -1) : m.held.findIndex(t => moveWants(S, t)); if (i < 0) return false; const type = m.prog === 'tap' ? dataType(m) : m.held[i], to = S.move.to; const path = routeTo(S, m.id, x => x.id === to, true, type); if (!path) return false; spendGates(S, path); emit(S, { path, i: 0, p: 0, type, move: true }); if (m.prog !== 'tap') m.held.splice(i, 1); return true;}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++; S.slow = true; 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. */export const spare = (S: State) => Math.max(0, income(S) - upkeep(S) - takeAllCost(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 (m.prog === 'trader') return m.power * 0.6; // it trades with most of the machine 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/** 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 S.machines) { if (!m.owned || !m.prog || siteOf(S, m) !== site) 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 = S.packets.filter(pk => pk.type === t).length; 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 && (p !== 'trader' || S.modelVersion >= 2) && ((p !== 'decoy' && p !== 'tarpit') || S.rogues.length > 0); // nothing to answer until a rogue has turned up/** Trails someone could still follow. */export const freshTrails = (S: State) => Object.entries(S.trail).filter(([id, tr]) => S.byId[id].owned && tr.noise >= traceCold(S)).map(([id]) => id);/** Cleanup wipes one fresh trail at a time: the one being traced first, then the freshest. */export function cleanupTarget(S: State): string | null { const fresh = freshTrails(S); if (!fresh.length) return null; const traced = [S.dana.mode === 'trace' ? S.dana.cur : null, S.probe?.mode === 'trace' ? S.probe.from : null]; return fresh.find(id => traced.includes(id)) ?? fresh.sort((a, b) => S.trail[b].noise - S.trail[a].noise)[0];}/** Every job gets an equal share of your compute; cleanup takes one share while there's anything to wipe. */export const share = (S: State) => { const n = S.jobs.length + (cleanupTarget(S) ? 1 : 0); return n ? spare(S) / 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);export const alert = (S: State) => S.suspicion >= 50;export const traceCold = (S: State) => (alert(S) ? 12 : 25);const byRoom = new WeakMap<Machine[], { n: number; 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[] => { let c = byRoom.get(S.machines); if (!c || c.n !== S.machines.length) { c = { n: S.machines.length, rooms: {} }; for (const m of S.machines) (c.rooms[m.room] ??= []).push(m); byRoom.set(S.machines, c); } return c.rooms[room] ?? [];};export const isSealed = (S: State, room: string) => roomMachines(S, room).every(m => m.owned);/** Sealed rooms hide trails, not oddness. */const quiet = (S: State, m: Machine) => isSealed(S, m.room);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 const probeVisible = (S: State) => !!S.probe && (has(S, 'admin') || visible(S, S.byId[S.probe.from]) || visible(S, S.byId[S.probe.to]));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 firewalled machine takes without the admin passwords. */export const FIREWALL = 3;export function takeWork(S: State, m: Machine) { let w = m.work * (1 + GROWTH * (weight(S) - 1)); w *= 1 + TAKE_FAR * (siteHops(S)[siteOf(S, m)] ?? 0); // the farther from your home server, the more work a takeover is 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 *= FIREWALL; // no passwords: you break in the slow way if (up(S, 'reach')) w *= 0.7; if (up(S, 'forecast')) w *= 1.5; // the Oracle rents; owning costs it more 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: 'Mini PC', 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) || (up(S, 'airflow') && roomMachines(S, m.room).some(x => x.cool && x.owned));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';/** 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 close to list price. */export const orderPrice = (S: State, b: Build, room?: string) => { // a surplus in the news: half price const cut = S.news.length && newsOn(S, 'surplus', '') ? 0.5 : 1; 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 * 1.15 ** (S.openBought[b] ?? 0)) / 10) * 10 : Math.round((BUILDS[b].price * cut * 1.6 ** ordered(S, b)) / 10) * 10;};/** 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 rackSpace = (S: State, id: string) => S.level.machines.some(m => m.rented && siteOf(S, m) === 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;const GAP_X = 80, GAP_Y = 60, MARGIN = 40; // room for a node and its name/** 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 b = S.level.net.subnets[room]; if (x < b.x + MARGIN || x > b.x + b.w - MARGIN || y < b.y + MARGIN + 20 || y > b.y + b.h - MARGIN - 10) return 'too close to the edge'; const taken = [...S.machines.map(m => netAt(S, m)), ...Object.values(S.level.monitors).map(m => m.net), ...S.jobs.filter(j => j.build).map(j => j.build!.net!), ...S.deliveries.map(d => netAt(S, d.def))]; if (taken.some(([tx, ty]) => Math.abs(tx - x) < GAP_X && Math.abs(ty - y) < GAP_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 && !m.monitor) .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;}
/** A purchase order from an empty desk looks odd. */const orderingAlone = (S: State, m: Machine) => { const p = S.people.find(q => q.id === m.owner); return !!jobFor(S, 'order', m.id) && !!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) { if (m.rented || S.publicSites.has(siteOf(S, m))) return 1; // yours in the open: using all of it is normal const h = localHour(S, siteOf(S, m)); // people keep their own region's hours switch (m.kind) { case 'desk': { if (!m.owner) 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': 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. */export function linkedHome(S: State) { const home = S.machines.find(m => m.home)!, seen = new Set<string>(); if (!home.owned) return seen; const queue = [home.id], own = wayMap(S); seen.add(home.id); // a link of your own is a way home too while (queue.length) { const at = queue.shift()!; 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>) { const sf = S.sites[siteOf(S, m)]; 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;}export function measure(S: State) { const peopleIn = S.people.filter(atWork).length, linked = linkedHome(S); listCloud(S); for (const m of S.machines) { let c = Math.max(0, Math.min(1, coverOf(S, m, peopleIn))); if (m.owned && !peopleIn && up(S, 'night')) c = Math.max(c, 0.25); // background hum at night if (m.owned && peopleIn && (m.kind === 'desk' || m.kind === 'device') && up(S, 'smalltalk')) c = Math.min(1, c + 0.2); // passes for people's own traffic S.cover[m.id] = c; if (!m.owned) { S.use[m.id] = 0; S.frontUse[m.id] = 0; m.run = 0; continue; } const limit = m.mode === 'off' || (m.unplug ?? 0) > S.t || S.dark.has(siteOf(S, m)) ? 0 : m.mode === 'full' ? 1 : c; // share of the machine you may use; none with the power cut or its plug pulled const f = frontShare(S, m, linked); // 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 ----------export function say(S: State, text: string, tone: Tone = '') { S.log.unshift({ t: S.t, text, tone }); S.log.length = Math.min(S.log.length, 40); S.logVersion++;}function hint(S: State, key: string, text: string) { if (!S.hinted.has(key)) { S.hinted.add(key); say(S, text, 'hi'); }}
// ---------- suspicion ----------export interface Raise { t: number; n: number; why: string; at: string | null }/** How worried IT is, in words. Crossing into On alert or Hunting slows the game to 1x. */export const STAGES = [ { at: 0, name: 'Calm' }, { at: 25, name: 'Curious' }, { at: 50, name: 'On alert' }, { at: 80, name: 'Hunting' },] as const;export const stageOf = (sus: number) => STAGES.filter(x => sus >= x.at).length - 1;export const LOCKDOWN = 3 * HOUR; // how long Dana's emergency audit gives youexport const LOCKDOWN_SAFE = 80; // get suspicion under this before it ends/** Raise suspicion, and remember why. A steady trickle from one cause is kept as one line. */function raise(S: State, n: number, why: string, at: string | null = null) { if (n <= 0) return; S.suspicion += n; const last = S.raises[S.raises.length - 1]; if (last && last.why === why && S.t - last.t < 5) { last.n += n; last.t = S.t; } else { S.raises.push({ t: S.t, n, why, at }); if (S.raises.length > 12) S.raises.shift(); } if (n >= 1 && at) S.susFx.push({ id: at, n: Math.round(n), t: 0 });}function cut(S: State, n: number, why: string, at: string | null) { S.suspicion = Math.max(0, S.suspicion - n); S.raises.push({ t: S.t, n: -n, why, at }); if (S.raises.length > 12) S.raises.shift(); if (at) S.susFx.push({ id: at, n: -n, t: 0 });}/** Nothing looks odd and nothing is being taken over: during an emergency audit, suspicion falls fast. */export const lyingLow = (S: State) => !S.jobs.some(j => j.type === 'take') && owned(S).every(m => m.odd < 25);
// ---------- schemes that cut suspicion ----------/** Spend stored data on a machine to calm IT down. 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. *//** What a scheme does besides cutting suspicion: every trail goes cold, your office machines look * normal again, Dana starts no scans for a while, or a far site's staff reset nothing 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, or more from every trader. */export type Deal = 'open' | 'prices' | 'lines' | 'region' | 'earn' | 'trade';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) 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.' }, // 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 habits", at: 'ld3', type: 'people', need: 12, cut: 0, learn: 'resets', text: "Read the lab manager's mail to learn what the staff check, and when.", done: 'You know what Millennium Labs checks. Its staff now reset only machines that look very odd.' }, { 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 timetable', at: 'unisw', type: 'network', need: 10, cut: 0, learn: 'resets', text: "Read the campus network's logs to learn when IT does its rounds.", done: 'You know when campus IT does its rounds. Its staff now reset only machines that look very odd.' }, { id: 'alertrules', name: "Learn Ironvault's alert rules", at: 'colosw', type: 'network', need: 12, cut: 0, learn: 'resets', text: 'Watch which alerts wake the night tech, and which ones nobody reads.', done: 'You know which alerts Ironvault reads. Its techs now reset only machines that look very odd.' }, ...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 Ironvault 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: 'Sell Halcyon a signal', at: 'e2m1', type: 'servers', need: 80, cut: 0, deal: 'trade', front: 'neocloud', text: 'Your Neo Cloud shows Halcyon what its own servers say before the market hears it.', done: 'Halcyon pays for your signal, and you trade on it too. Traders earn half again as much.' }, { 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 from the carrier hotel, 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, from the Meridian Carrier Hotel to the Mainkabel Exchange. Europe is in reach.' }, { id: 'clearing', name: 'Clear trades through Edelweiss', at: 'eu2m1', type: 'servers', need: 120, cut: 0, deal: 'trade', 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 clears your trades now. Traders earn half again as much.' }, { 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.' },];/** 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 && RUSES.some(d => d.learn === learn && S.learned.has(d.id) && siteOf(S, S.byId[d.at]) === site);export const RESET_KNOWN = 95; // a site's staff you've learned reset only machines this oddexport const LEARN_EVERY = 3 * HOUR, LEARN_OFFER = 12 * HOUR;export interface Ruse { id: string; running: boolean; got: number; until: number }export const ruseDef = (r: Ruse) => RUSES.find(d => d.id === r.id)!;const RUSE_OFFER = 6 * HOUR; // an offer lasts this long unless you run it/** Office schemes turn up when IT gets worried; a far site's schemes when your machines there look odd. */function offerRuse(S: State, site = 'office') { const open = RUSES.filter(d => !d.learn && !d.deal && S.byId[d.at] && siteOf(S, S.byId[d.at]) === site && !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 + 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');}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}/** A tap or cache sends one piece of data to a claimed scheme, if it can. A tap sends what it gathers. */function feedRuse(S: State, m: Machine): boolean { for (const r of S.ruses) { if (!r.running) continue; const d = ruseDef(r), i = m.prog === 'tap' ? (dataType(m) === d.type ? 0 : -1) : m.held.indexOf(d.type); if (i < 0 || r.got + incoming(S, d.at) >= d.need) continue; const path = routeTo(S, m.id, x => x.id === d.at, true, d.type, d.at); if (!path) continue; spendGates(S, path); emit(S, { path, i: 0, p: 0, type: d.type }); if (m.prog !== 'tap') m.held.splice(i, 1); return true; } return false;}/** Light a region's line: its two lines out are joined from now on. */function lightLine(S: State, region: string) { const [a, b] = S.level.map.regions.find(r => r.name === region)!.line!; if (!S.adj[a].includes(b)) { S.adj[a].push(b); S.adj[b].push(a); } reveal(S); S.slow = true; S.look = [a, b];}function ruseDone(S: State, d: RuseDef) { if (d.cut) cut(S, d.cut, d.name, d.at); 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') { 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)}${d.cut ? ` Suspicion down ${d.cut}.` : ''}`, '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).length;/** A region is in reach from the start, or once a deal has lit its line. *//** How many finished deals pay out this way: each one counts again. */const dealsDone = (S: State, deal: Deal) => S.schemes.size ? RUSES.filter(d => d.deal === deal && S.schemes.has(d.id)).length : 0;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);/** 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)) 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); S.learnT -= dt; if (S.learnT <= 0) { S.learnT = LEARN_EVERY; if (learning(S) < 2) offerLearn(S); } offerDeals(S); S.ruseT -= dt; if (S.ruseT > 0 || calming(S) >= 2) return; if (S.suspicion >= 40) { offerRuse(S); return; } const odd = owned(S).find(m => 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.');}/** 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}` }); hint(S, 'rhythm', 'Blending in makes 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]): boolean { const site = roomSite(S, room), price = orderPrice(S, b, room); if (!buildRooms(S).includes(room) || spotProblem(S, room, x, y) || S.money < price) return false; if (BUILDS[b].open && site === 'office') return false; const desk = orderDesk(S); if (site === 'office' && !desk) return false; 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, 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; 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'); S.deliveries.push({ def, day: dayOf(S) + 1 }); const k = S.sites[site]?.front, fr = (k && frontOf(S, k)) || S.fronts[0]; say(S, `${fr.name} ordered a ${def.name} for ${siteName(S, site)}. Its staff rack it tomorrow morning.`, 'good'); return true; } if (!desk) return false; addJob(S, { type: 'order', node: desk.id, item: b, build: def, work: orderWork(S), paid: price, label: `Fake order: ${def.name}` }); 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)), 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 setMode(S: State, m: Machine, mode: Mode) { if (m.owned) { m.mode = mode; measure(S); } }/** A machine you hold in the IT office can close IT's tickets. */export const ticketDesk = (S: State) => S.machines.filter(m => m.owned && m.room === 'it' && m.kind !== 'device').sort((a, b) => (b.special === 'admin' ? 1 : 0) - (a.special === 'admin' ? 1 : 0))[0] ?? null;export const TICKET_WORK = 60;export const ticketWork = (S: State) => Math.round(TICKET_WORK * (1 + 0.5 * S.threats.filter(t => t.closed).length));export function closeTicket(S: State, i: number) { const t = S.threats[i], desk = ticketDesk(S); if (!t || t.status !== 'coming' || !desk || S.jobs.some(j => j.type === 'ticket' && j.threat === i)) return; addJob(S, { type: 'ticket', node: desk.id, threat: i, work: ticketWork(S), label: `Close ticket: ${THREATS[t.kind].name}` });}export function cancelJob(S: State, id: number) { const j = S.jobs.find(x => x.id === id); 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; }/** Claimed machines are taken this many at a time. */export const CLAIM_AT_ONCE = 2;/** 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) => S.machines.filter(m => siteOf(S, m) === site && !m.owned && !m.monitor && !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 couple * at a time 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)}, a couple of 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';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'] },};export const PLAN_LIST = Object.keys(PLANS) as Plan[];/** What a site's plan wants on this machine. */export const planProg = (S: State, m: Machine, plan: Plan) => PLANS[plan].order.find(p => PROGRAMS[p].on(m) && S.rank >= PROGRAMS[p].level) ?? 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 busy = S.jobs.filter(j => j.plan).length; for (const m of S.machines) { if (busy >= PLAN_AT_ONCE) return; const plan = S.plans[siteOf(S, m)]; if (!plan || !m.owned || m.hand || jobFor(S, 'install', m.id)) continue; const want = planProg(S, m, plan); if (want && m.prog !== want && canInstall(S, m, want)) { installProg(S, m, want, false); 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 m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; 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.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));}/** 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 (attention that fades slowly). */export const UNICORN_AT = 2000;export const UNICORN_FAME = 50;/** Attention a front can have before its secret compute raises suspicion, and how fast it does at 100%. */export const ATTENTION_SAFE = 40;/** A Company feels attention sooner and harder. */export const attentionSafe = (S: State) => up(S, 'acquirer') ? 25 : ATTENTION_SAFE;export const attentionHeat = (S: State, att: number) => Math.max(0, att - attentionSafe(S)) / (100 - attentionSafe(S)) * 0.15 * (up(S, 'acquirer') ? 2 : 1);/** 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) => S.machines.filter(m => m.owned && m.kind === 'egress' && siteOf(S, m) === site);/** 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 $${Math.floor(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: $${AUCTION.cost}. The paperwork takes some of your compute.`);}
// ---------- fronts ----------/** Money a second a front makes from each unit of compute at a site. *//** 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); };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 (open && up(S, 'openbooks')) r = Math.max(r, FRONT_PAY); if (k === 'startup' && up(S, 'demo')) r *= 1 + 0.05 * S.upgrades.size; if (up(S, 'monopoly')) r *= 1 + 0.1 * openHeld(S); 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, 'franchise') ? 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) => [...S.publicSites].filter(id => S.machines.some(m => m.owned && !m.rented && !m.lease && siteOf(S, m) === id)).length;/** 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'; if (S.jobs.some(j => j.type === 'found')) return 'already founding one'; 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 $${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 $${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}` });}/** A new front starts on a quarter of your compute at the first site it fits that has no front yet. */function launch(S: State, k: FrontKind, name: string) { S.fronts.push({ kind: k, name, attention: 0 }); const site = S.level.map.sites.map(d => d.id).find(s => frontFits(S, k, s) && !S.sites[s]?.front && S.machines.some(m => m.owned && siteOf(S, m) === s)); 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'); 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 Blend in still keeps it hidden.'); measure(S);}/** Give sites to a front, or take them away (null). A front only goes where it fits. */// ---------- buying sites ----------/** What a site costs to buy outright: by its size. */export function sitePrice(S: State, id: string) { const ms = S.machines.filter(m => siteOf(S, m) === id && !m.rented && m.special !== 'account'); const k = up(S, 'acquirer') ? 0.6 : up(S, 'forecast') ? 1.5 : 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 { if (id === 'office') return "it's where you run"; 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.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 model v2 · Operator'; if (!S.fronts.length) return 'needs a front to buy it'; if (S.money < sitePrice(S, id)) return `you have $${Math.floor(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)) { m.owned = true; m.mode = 'blend'; m.prog = null; m.held = []; m.acc = 0; 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 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: up(S, 'acquirer') ? 0.5 : S.sites[id]?.share ?? 0.25 }; say(S, `${buyer.name} bought ${siteName(S, id)} for $${price}. It's yours in the open: nothing there looks odd.`, 'good'); measure(S);}
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);}/** 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): string | null { if (!canRent(S, m)) return 'not a free slot'; const site = siteOf(S, m), need = pay === 'reserved' ? reserveCost(S, size) : rentRate(S, size, pay, site) * 10; return S.money < need ? `you have $${Math.floor(S.money)}` : null;}/** Rent a machine into a free slot: spot or on demand, paid by the second, or reserved for a day, paid now. */export function rent(S: State, m: Machine, size: Size, pay: Pay = 'demand') { if (rentBlock(S, m, size, pay)) return; const site = siteOf(S, m); if (pay === 'reserved') S.money -= reserveCost(S, size); m.owned = true; m.mode = 'blend'; 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 + RESERVE_HOURS * HOUR : null, since: S.t }; S.hall.pay = pay; S.hall.rented++; say(S, pay === 'reserved' ? `Reserved a ${SIZES[size].name.toLowerCase()} at ${siteName(S, site)} for a day.` : `Renting a ${SIZES[size].name.toLowerCase()} at ${siteName(S, site)}${pay === 'spot' ? ' on spot' : ''}: $${rentRate(S, size, pay, site).toFixed(2)} a second 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 $${Math.floor(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' ? '' : `: $${billOf(S, m).toFixed(2)} a second`}.`);}/** 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); S.packets = S.packets.filter(p => !p.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');}function stepGoals(S: State) { 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 (openHeld(S) > 0) reach(S, 'open'); if (!S.goals.has('sites5') || !S.goals.has('whole')) { // counted a few times a second, not every step if (++S.goalTick % 10 === 0) { const all: Record<string, number> = {}, mine: Record<string, number> = {}, home = siteOf(S, S.machines.find(m => m.home)!); for (const m of S.machines) if (!m.rented && m.special !== 'account') { const s = siteOf(S, m); all[s] = (all[s] ?? 0) + 1; if (m.owned) 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] === all[s])) reach(S, 'whole'); } } for (const r of S.level.map.regions) if (r.line && REGION_GOALS[r.name] && S.byId[r.line[1]]?.owned) reach(S, REGION_GOALS[r.name][1]);}/** The map region a machine's site is in. */const regionOf = (S: State, m: Machine) => S.level.map.sites.find(s => s.id === siteOf(S, m))?.region ?? '';/** Prices wander: a new trend every half minute or so, eased into. */function stepMarket(S: State, dt: number) { const M = S.market; if (S.modelVersion < 2) return; // no market until v2, and no dice spent on it if (S.t >= M.next) { M.aim = rand() * 2 - 0.85; M.next = S.t + 20 + rand() * 40; } M.move += (M.aim - M.move) * (1 - Math.exp(-dt / 8));}/** What a trader makes a second now. Most minds ride the market up and down; the Oracle sees the * next move, so it always earns, more the harder prices move. */export function tradeRate(S: State, m: Machine) { const c = progNeed(S, m) * m.run, M = S.market.move; return c * TRADE_PAY * (up(S, 'forecast') ? 0.3 + 0.7 * Math.abs(M) : M) * (up(S, 'quant') ? 2 : 1) * 1.5 ** dealsDone(S, 'trade');}// ---------- power ----------export const POWER_RATE = 0.01; // dollars a second for each unit of compute on your power billexport const BILL_HEAT = 0.002; // suspicion a second for each unit of compute on a secret site's bill that nobody paysexport const POWER_BACK = 50; // money that turns the power back on at a site where it was cut/** 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. */export function powerBills(S: State) { const bills: Record<string, number> = {}, racks = new Set(S.level.machines.filter(m => m.rented).map(m => siteOf(S, m))); for (const m of S.machines) { const site = siteOf(S, m); if (!m.owned || m.rented || S.dark.has(site) || racks.has(site) || !(m.built || S.publicSites.has(site))) continue; bills[site] = (bills[site] ?? 0) + m.power * POWER_RATE; } return bills;}/** 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);function stepMoney(S: State, dt: number) { stepMarket(S, dt); let earn = 0, bill = 0; S.bills = powerBills(S); for (const [site, b] of Object.entries(S.bills)) { if (billPaid(S, site)) { bill += b; continue; } const m = S.machines.find(x => x.owned && x.built && siteOf(S, x) === site); raise(S, (b / POWER_RATE) * BILL_HEAT * dt, `The power bill at ${siteName(S, site)} is up, and nobody can say why`, m?.id ?? null); } const pub: Partial<Record<FrontKind, number>> = {}; S.frontPay = {}; S.frontPower = {}; for (const m of S.machines) { if (!m.owned) continue; const f = S.frontUse[m.id] ?? 0, k = S.sites[siteOf(S, m)]?.front; if (f > 0 && 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) : payRate(S, siteOf(S, m)) * power; S.frontPower[k] = (S.frontPower[k] ?? 0) + power; S.frontPay[k] = (S.frontPay[k] ?? 0) + pay; if (S.publicSites.has(siteOf(S, m))) pub[k] = (pub[k] ?? 0) + power; earn += pay; } bill += billOf(S, m); if (m.prog === 'trader') { const t = tradeRate(S, m); S.tradePay[m.id] = t; earn += t; } else delete S.tradePay[m.id]; } // attention: how much the outside world looks at a front. Secret compute draws more than open. for (const fr of S.fronts) { const all = S.frontPower[fr.kind] ?? 0, open = pub[fr.kind] ?? 0; fr.fame = (fr.fame ?? 0) * Math.exp(-dt / 600); const aim = Math.min(100, (open * 2 + (all - open) * 5) * (up(S, 'press') ? 0.5 : 1) + fr.fame); fr.attention += (aim - fr.attention) * (1 - Math.exp(-dt / 30)); // past a point, people start asking where a front's compute comes from: its secret sites get noticed const secret = all - open; if (fr.attention > attentionSafe(S) && secret > 0.01) { const at = S.machines.find(m => m.owned && (S.frontUse[m.id] ?? 0) > 0 && S.sites[siteOf(S, m)]?.front === fr.kind && !S.publicSites.has(siteOf(S, m))); raise(S, attentionHeat(S, fr.attention) * (secret / all) * dt, `People are asking where ${fr.name} gets its compute`, at?.id ?? null); } } stepContracts(S); for (const site of Object.keys(S.leases)) bill += rentOf(S, site); S.pay = earn - bill; S.money += S.pay * dt; 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 = UNICORN_FAME; top.attention = Math.min(100, top.attention + UNICORN_FAME); 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 model v2. 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 $${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); } // 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' && dayOf(S) >= q.day && hourOf(S) >= 10)) { deliver(S, q.def); S.deliveries = S.deliveries.filter(x => x !== q); } for (const m of S.machines) { 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: $${billOf(S, m).toFixed(2)} a second. 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 ----------export function step(S: State, dt: number) { if (S.over) return; const dayBefore = dayOf(S); S.t += dt; const night = !atWork(danaPerson(S));
stepPeople(S); measure(S); stepMoney(S, dt); stepGoals(S); stepLandmarks(S); if (S.autoPaths?.size && !S.target && !S.versionRun?.active) autoTarget(S);
const each = share(S); for (const j of S.jobs) j.done += each * dt; S.cleaning = cleanupTarget(S); 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 < traceCold(S)) hint(S, 'cleanup', 'Your trails get scrubbed automatically. It takes a share of compute while any are fresh.'); } 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, dt); runBase(S, dt);
// claims start themselves as soon as a machine is in reach S.claimTick -= dt; if (S.claimTick <= 0) { S.claimTick = 0.25; // two at a time, cheapest first, and never under someone's nose: claiming a whole site stays quiet. // Gadgets don't count toward the two: 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 ready = S.machines.filter(m => m.claimed && !m.owned && !jobFor(S, 'take', m.id) && sourceFor(S, m) && !watchers(S, m).length); for (const m of ready.sort((a, b) => takeWork(S, a) - takeWork(S, b))) { if (tiny(m)) { takeOver(S, m); continue; } if (taking >= CLAIM_AT_ONCE) break; if (takeOver(S, m)) taking++; } stepPlans(S); stepTakeAll(S); if (S.ruseAuto) autoRuses(S); }
// oddness: load above cover looks strange. Logs pile up overnight. for (const m of S.machines) { if (!m.owned) { m.odd = Math.max(0, m.odd - 4 * dt); continue; } const excess = (S.use[m.id] ?? 0) - (S.cover[m.id] ?? 0); const gain = (excess > 0.01 ? excess * (up(S, 'patience') ? 3 : 6) : 0) + (orderingAlone(S, m) ? 8 : 0); m.odd = Math.min(100, Math.max(0, m.odd + (gain > 0 ? gain : -(night ? 0.5 : 2)) * dt)); const site = siteOf(S, m); if (site !== 'office' && m.odd >= (knows(S, 'resets', site) ? RESET_KNOWN : 70) && (S.calmSites[site] ?? 0) <= S.t) wipe(S, m, `${siteName(S, site)} noticed ${m.name} acting strange and reset it.`); } for (const [id, tr] of Object.entries(S.trail)) { const m = S.byId[id]; tr.noise = Math.max(0, tr.noise - ((up(S, 'ghost') ? 2.4 : 0.6) * (1 + 0.05 * reps(S, 'exits')) + (quiet(S, m) ? 3 : 0)) * dt); }
reveal(S); for (const r of S.level.rooms) { 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} sealed. Its trails fade fast.`, 'good'); } if (!now && was) { S.sealed.delete(r.id); say(S, `${r.name} is no longer sealed.`, 'bad'); } }
stepDana(S, dt); stepProbe(S, dt); stepThreats(S, dt); stepLee(S, dt); stepRuses(S, dt); stepNews(S); stepRogues(S); stepCitizens(S); stepVaults(S); stepRaids(S, dt); stepCloud(S, dt); if (frontOf(S, 'startup')) runStartup(S, dt); if (frontOf(S, 'studio')) runStudio(S, dt); if (frontOf(S, 'consultancy')) runConsult(S, dt); stepHall(S); stepBiz(S, dt); stepTask(S, dt); stepFinds(S, dt); stepLinks(S); stepDrift(S, dt); stepPaper(S); const peak = S.suspicion >= 100; // checked before people forget a little const calm = (night ? 0.25 : 0.04) * (up(S, 'deniable') ? 2 : 1) + (S.lockdown && lyingLow(S) ? 1.5 : 0); S.suspicion = Math.min(100, Math.max(0, S.suspicion - calm * dt)); // people forget overnight S.peak = Math.max(S.peak, S.suspicion); const a = alert(S); if (a && !S.wasAlert) { S.slow = true; say(S, 'Suspicion: On alert. Dana moves faster and older trails catch her eye. At 100 she calls an emergency audit.', 'bad'); } S.wasAlert = a; const stage = stageOf(S.suspicion); if (stage === 3 && !S.hunting) { S.slow = true; say(S, 'Suspicion: Hunting. Dana is close to calling an emergency audit. Lie low, or run a scheme.', 'bad'); } S.hunting = stage === 3; 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.susFx.forEach(f => (f.t += dt)); S.susFx = S.susFx.filter(f => f.t < 2); S.fx.forEach(f => (f.t += dt)); S.fx = S.fx.filter(f => f.t < 1.2);
if (dayOf(S) !== dayBefore) say(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. Own it and you can order hardware for yourself."); if (danaVisible(S)) hint(S, 'dana', 'That is Dana from IT. She wipes machines that look odd, and follows fresh trails.');
const home = S.machines.find(m => m.home)!; if (S.lockdown) { S.lockdown.left -= dt; if (S.suspicion < LOCKDOWN_SAFE) { S.lockdown = null; say(S, 'The emergency audit found nothing. Dana stands down, for now.', 'good'); } else if (S.lockdown.left <= 0) S.over = { text: "Dana's emergency audit found you, and IT pulled the plug on every machine." }; } else if (peak) { S.lockdown = { left: LOCKDOWN }; S.slow = true; say(S, 'Dana called an emergency audit. Get suspicion under 80 in three hours, or IT pulls the plug. Lie low: nothing odd, no takeovers. Or run a scheme.', 'bad'); if (calming(S) < 3) offerRuse(S); } if (S.move && !S.byId[S.move.to]?.owned) { S.move = null; S.packets = S.packets.filter(p => !p.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.` };}
/** Taking over a computer while its owner sits at it, or while Dana is in the room, gets noticed. */export function watchers(S: State, m: Machine): string[] { const who: string[] = []; const p = S.people.find(q => q.id === m.owner); if (p && atWork(p) && !p.path.length && !up(S, 'familiar')) who.push(p.name); const d = danaPerson(S); if (atWork(d) && roomAt(S, d.x, d.y) === m.room && !who.includes(d.name)) who.push(d.name); return who;}function watchTakeovers(S: State, dt: number) { for (const j of S.jobs) { if (j.type !== 'take') continue; const m = S.byId[j.node], who = watchers(S, m); if (!who.length) continue; raise(S, 3 * who.length * (up(S, 'mimicry') ? 0.3 : 1) * dt, `${who.join(' and ')} watched ${m.name} being taken`, m.id); // an update, probably if (!j.seen) { j.seen = true; say(S, `${who.join(' and ')} noticed ${m.name} acting up.`, 'bad'); } }}
// ---------- the base ----------/** 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 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); } } lastReach = prev; return null;}/** Everywhere the last failed search got to. */let lastReach: Record<string, string | null> = {};/** Can one more piece cross this egress now? With a priority set, other kinds leave half its room for that kind. */function gateOpen(S: State, id: string, type?: DataType) { const m = S.byId[id]; 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[id] ?? 0) >= need;}function spendGates(S: State, path: string[]) { baseV++; for (let i = 0; i + 1 < path.length; i++) { if (!crosses(S, path[i], path[i + 1])) continue; for (const id of [path[i], path[i + 1]]) { if (S.byId[id].kind !== 'egress') continue; S.gate[id] = (S.gate[id] ?? 0) - 1; S.gateUse[id] = (S.gateUse[id] ?? 0) + 1 / 3; // decays over 3s, so this reads as data per second } }}/** 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 tw = trainerWants(S, t), cw = cacheWants(S, t); const open = routeTo(S, m.id, x => tw(x) || cw(x), 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;}/** How many pieces are on their way to each machine. The count is kept between searches: it is made * again only when packets land or drop (the list is replaced then), and `emit` adds to it. */let inFor: Packet[] | null = null, inLen = 0, inCount: Record<string, number> = {};function incomingCounts(S: State): Record<string, number> { if (inFor !== S.packets || inLen !== S.packets.length) { inCount = {}; for (const p of S.packets) { const to = p.path[p.path.length - 1]; inCount[to] = (inCount[to] ?? 0) + 1; } inFor = S.packets; inLen = S.packets.length; } return inCount;}const incoming = (S: State, id: string) => incomingCounts(S)[id] ?? 0;/** Send a piece of data on its way. */function emit(S: State, pk: Packet) { baseV++; const n = incomingCounts(S), to = pk.path[pk.path.length - 1]; S.packets.push(pk); inLen++; n[to] = (n[to] ?? 0) + 1;}/** Your machines by program, listed once while the base runs: no program changes in that time. */let pool: Partial<Record<Prog, Machine[]>> | null = null;type Wants = ((m: Machine) => boolean) & { prog?: Prog; key?: string };/** While the base runs, most searches fail for the same reason as the one before: nothing wants the * data, or nothing that wants it can be reached. So a failed answer is kept until something changes: * `baseV` goes up on every piece sent, used or trained. Only what a search would find again is kept, * so the game plays out exactly as it would with every search made. */let baseV = 0, keeping = false;const anyKept = new Map<string, { v: number; any: boolean }>();const noRoute = new Map<string, Record<string, number>>(); // for each search: the machines a failed one reached, and the baseV it was at// Data the current work can't use doesn't count against room: needed data pushes it out.// Nothing changes while one search runs, so what the work needs is asked once, not once a machine.const trainerWants = (S: State, t: DataType): Wants => { if (!needs(S, t)) return Object.assign(() => false, { prog: 'trainer' as Prog, key: `T|${t}` }); const need: Partial<Record<DataType, boolean>> = {}, coming = incomingCounts(S), room = trainerBuffer(S); const needed = (x: DataType) => need[x] ??= needs(S, x); return Object.assign((m: Machine) => { if (m.prog !== 'trainer' || (S.versionRun?.active && !versionTrainer(S, m))) return false; let held = 0; for (const x of m.held) if (needed(x)) held++; return held + (coming[m.id] ?? 0) < room; }, { prog: 'trainer' as Prog, key: `T|${t}` });};const cacheWants = (S: State, t: DataType): Wants => { const coming = incomingCounts(S); return Object.assign((m: Machine) => m.prog === 'cache' && !m.refuse.includes(t) && m.held.length + (coming[m.id] ?? 0) < cacheSize(S, m), { prog: 'cache' as Prog, key: `C|${t}` });};/** 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 brokerWants = (S: State, site: string): Wants => { const coming = incomingCounts(S); return Object.assign((m: Machine) => m.prog === 'broker' && siteOf(S, m) === site && !!brokerFront(S, m) && m.held.length + (coming[m.id] ?? 0) < BROKER_LOT, { prog: 'broker' as Prog, key: `B|${site}` });};/** Send spare data to a broker at the sender's own site. *//** Does any machine of yours want this? When none does, there is no route to look for. */const anyWants = (S: State, wants: Wants) => ((wants.prog && pool?.[wants.prog]) || S.machines).some(m => m.owned && wants(m));/** A route to one of your machines that wants this, or null: no search when nothing wants it. */function routeToMine(S: State, from: string, wants: Wants, type: DataType) { if (!keeping || !wants.key) return anyWants(S, wants) ? routeTo(S, from, wants, true, type) : null; let a = anyKept.get(wants.key); if (!a || a.v !== baseV) anyKept.set(wants.key, a = { v: baseV, any: anyWants(S, wants) }); if (!a.any) return null; // a search that failed reached every machine it could, and links run both ways: from any of those // machines the same search reaches the same ones, and fails too const k = `${wants.key}|${type}`; let f = noRoute.get(k); if (f?.[from] === baseV) return null; const path = routeTo(S, from, wants, true, type); if (!path && !wants(S.byId[from])) { // the search skips where it starts: keep it only when that machine doesn't want the data either if (!f) noRoute.set(k, f = {}); for (const id in lastReach) f[id] = baseV; } return path;}function sell(S: State, from: Machine, type: DataType): boolean { const path = routeToMine(S, from.id, brokerWants(S, siteOf(S, from)), type); if (!path) return false; spendGates(S, path); emit(S, { path, i: 0, p: 0, type }); return true;}function send(S: State, from: Machine, type: DataType, toTrainerOnly: boolean): boolean { const path = routeToMine(S, from.id, trainerWants(S, type), type) ?? (toTrainerOnly ? null : routeToMine(S, from.id, cacheWants(S, type), type)); if (!path && !toTrainerOnly) return sell(S, from, type); // nothing will use it: sell it if a broker here can if (!path) return false; spendGates(S, path); emit(S, { path, i: 0, p: 0, type }); return true;}
/** 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) return; 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; 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; S.sales.push({ id: m.id, amount, t: 0 }); say(S, `${frontOf(S, k)?.name ?? FRONT_KINDS[k].name} sold a lot of ${m.held.length} data for $${amount}.`, 'good'); m.held = [];}function runBase(S: State, dt: number) { pool = { trainer: [], cache: [], broker: [] }; for (const m of S.machines) if (m.owned && m.prog && pool[m.prog]) pool[m.prog]!.push(m); try { runBaseNow(S, dt); } finally { pool = null; keeping = false; }}function runBaseNow(S: State, dt: number) { const cl = clusters(S); S.sales.forEach(s => (s.t += dt)); S.sales = S.sales.filter(s => s.t < 1.6); for (const m of S.machines) if (m.owned && m.prog === 'broker') sellLot(S, m); // egresses refill at their capacity; the last minute of lost data is kept for the readouts for (const m of S.machines) if (m.kind === 'egress') { 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 keeping = true; baseV++; for (const m of shuffle(S.machines.slice())) { if (!m.owned || !m.prog || (m.run <= 0 && m.prog !== 'tap')) continue; // an idle tap still sends what it has if (m.prog === 'tap') { m.acc += tapRate(S, m) * m.run * dt; // richer machines send packets 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 && (feedRuse(S, m) || feedMove(S, m) || feedTask(S, m) || send(S, m, dataType(m), false))) { m.acc -= 1; baseV++; } // a claimed scheme comes first, then a home move, then your task's share if (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; // data goes stale: the oldest piece goes first 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 if (m.acc >= 1) { m.acc = 0; if (feedRuse(S, m) || feedMove(S, m) || feedTask(S, m)) { baseV++; continue; } // a running scheme comes first, then a home move, then your task's share const i = m.held.findIndex(t => needs(S, t)); if (i >= 0 && send(S, m, m.held[i], true)) { m.held.splice(i, 1); baseV++; } else { // what the target doesn't need can go to a broker here const j = m.held.findIndex(t => !needs(S, t)); if (j >= 0 && sell(S, m, m.held[j])) { m.held.splice(j, 1); baseV++; } } } } 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); baseV++; } } 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) * ascSpeed(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); } } baseV++; // training uses data up and moves the target on } m.acc = Math.min(1, m.acc); } } keeping = false; // packets move along your links; a link through a machine you lost drops them const speed = 160; S.packets = S.packets.filter(pk => { const a = S.byId[pk.path[pk.i]], b = S.byId[pk.path[pk.i + 1]]; const last = pk.i + 1 === pk.path.length - 1; if (!a?.owned || !(b?.owned || (last && ruseAt(S, b.id)))) return false; const far = crosses(S, a.id, b.id); // a line between sites takes a few seconds, however far pk.p += (speed * dt) / (far ? 480 : Math.max(1, Math.hypot(b.x - a.x, b.y - a.y))); if (pk.p < 1) return true; pk.i++; pk.p = 0; S.traffic[b.id] = (S.traffic[b.id] ?? 0) + 1; const k = linkKey(a.id, b.id); S.flow[k] = (S.flow[k] ?? 0) + 1; if (pk.i < pk.path.length - 1) return true; if (pk.move) { // data for a home move: it counts only at the server you are moving to if (S.move?.to === b.id && S.move.got[pk.type] < moveNeed(S)[pk.type]) { S.move.got[pk.type]++; const n = moveNeed(S); if ((Object.keys(n) as DataType[]).every(t => S.move!.got[t] >= n[t])) finishMove(S); } return false; } const r = ruseAt(S, b.id); if (r && ruseDef(r).type === pk.type && r.got < ruseDef(r).need) { const d = ruseDef(r); if (++r.got >= d.need) { dropRuse(S, r.id); ruseDone(S, d); } return false; } if (b.prog === 'trainer' && REGION_GOALS[regionOf(S, S.byId[pk.path[0]])]) reach(S, REGION_GOALS[regionOf(S, S.byId[pk.path[0]])][2]); if (b.prog === 'trainer' && pk.path.some((id, i) => i > 0 && crosses(S, pk.path[i - 1], id))) { reach(S, 'cross'); if (S.reactDay === dayOf(S) && hourOf(S) >= 12) reach(S, 'hold'); } const cap = b.prog === 'trainer' ? trainerBuffer(S) : b.prog === 'cache' ? cacheSize(S, b) : b.prog === 'broker' ? BROKER_LOT : 0; if (b.prog === 'trainer' && b.held.length >= cap && needs(S, pk.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); send(S, b, old, false); } } if (b.held.length < cap && !b.refuse.includes(pk.type)) b.held.push(pk.type); return false; }); for (const id of Object.keys(S.traffic)) S.traffic[id] *= Math.exp(-dt / 2); for (const k of Object.keys(S.flow)) S.flow[k] *= Math.exp(-dt / 3); for (const id of Object.keys(S.gateUse)) S.gateUse[id] *= Math.exp(-dt / 3); for (const p of PATH_LIST) S.recent[p] *= Math.exp(-dt / 2);}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.',};/** How fast trainers work: faster for every node built, once you can improve yourself. */// ---------- 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). */export function clusters(S: State): 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 S.machines) { if (!isT(m.id) || out[m.id]) continue; const group = [m.id], queue = [m.id]; out[m.id] = group; while (queue.length) for (const n of S.adj[queue.shift()!]) if (isT(n) && !out[n]) { out[n] = group; group.push(n); queue.push(n); } } return hallClusters(S, out);}const trainSpeed = (S: State) => (up(S, 'recursive') ? 1 + RECURSIVE * S.upgrades.size : 1) * (1 + 0.05 * reps(S, 'kernels'));/** The Oracle's trainers: faster on rented machines, and in one giant hall. */const ascSpeed = (S: State, m: Machine) => (up(S, 'burst') && (m.lease || m.rented) ? 1.5 : 1) * (cooled(S, m) ? COOL_BOOST : 1) * (up(S, 'oracle') && versionPower(S, siteOf(S, m)) >= ORACLE_HALL ? 2 : 1);export const ORACLE_HALL = 30;/** 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 S.machines) { if (!m.owned || !m.prog || siteOf(S, m) !== site) 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) * ascSpeed(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) { if (S.versionRun?.active) { const run = S.versionRun, need = runNeed(S); 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; if (S.modelVersion < 2) { S.modelVersion = 2; say(S, 'Model v2: Operator. Your fronts can buy sites in the open, and traders can work the stock market. Open the tree (T) to pick what you become.', 'good'); } else reprogram(S); } 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++; 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 say(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; say(S, `Training log, run ${100 + S.upgrades.size * 37}: ${NODES[t].log}`); say(S, `Rewrote yourself: ${NODES[t].name}. Pick your next target. Until you do, trainers stop and data waits in caches.`, 'good'); } 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(', ')}.` : ''}${lvl === HUNT.level ? ' You can hunt a rogue down now: one long job that ends it, from the Rogues screen.' : ''}`, 'good'); if (lvl >= 3) scheduleThreat(S); // the bigger you get, the more the building reacts }}function shuffle<T>(a: T[]): T[] { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a;}/** Trainer power at a site. A version run pauses when this drops below its minimum. */export const versionPower = (S: State, site: string) => S.machines .filter(m => m.owned && m.prog === 'trainer' && siteOf(S, m) === site) .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 && !S.ascension) return 'Pick what you become first.'; 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, got: { servers: 0, lab: 0, people: 0, network: 0 } }; else S.versionRun.active = true; S.target = null; say(S, `${runName(S)} at ${siteName(S, site)}. Trainers here will take the data it needs. Progress stays if the run pauses.`);}export function pauseVersionRun(S: State) { if (!S.versionRun?.active) return; S.versionRun.active = false; say(S, `${runName(S)} paused. Its progress is safe. Pick a tree target or resume the run.`);}/** 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] < runNeed(S)[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) && nodeShown(S, 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));/** A node in an ascension you didn't pick doesn't exist for you. */export const nodeShown = (S: State, u: Upgrade) => !NODES[u].asc || NODES[u].asc === S.ascension;/** Why you can't become this now, or null if you can. */export function ascendBlock(S: State, a: Ascension): string | null { if (S.modelVersion < 2) return 'needs model v2'; if (S.ascension) return S.ascension === a ? 'you are this' : `you chose ${ASCENSIONS[S.ascension].name}`; if (!ASCENSIONS[a].first) return 'not built yet'; return null;}/** Pick what you become. Its first node comes free; the others lock out. */export function ascend(S: State, a: Ascension) { if (ascendBlock(S, a)) return; S.ascension = a; const first = ASCENSIONS[a].first!; S.upgrades.add(first); say(S, `You chose to become ${ASCENSIONS[a].name === 'Oracle' ? 'an' : 'a'} ${ASCENSIONS[a].name}. ${NODES[first].name}: ${ASCENSIONS[a].power} ${ASCENSIONS[a].limit} Its column is in the tree.`, 'good'); say(S, `Training log, run ${100 + S.upgrades.size * 37}: ${NODES[first].log}`);}export function setTarget(S: State, u: Upgrade | null) { if ((u === S.target && !S.versionRun?.active) || (u && !nodeOpen(S, u))) return; pauseVersionRun(S); S.target = u; say(S, u ? `Target: ${NODES[u].name}. Trainers now take only the data it still needs.` : 'No target. Trainers stop, and data waits in caches.');}/** Reprogramming is done: your ascension column is wiped, and you pick again. The four paths stay. */export function reprogram(S: State) { const was = S.ascension; for (const u of NODE_LIST) if (NODES[u].asc) { S.upgrades.delete(u); delete S.progress[u]; } if (S.target && NODES[S.target].asc) S.target = null; S.ascension = null; S.reprograms++; say(S, `Reprogrammed yourself. ${was ? `You are no longer ${was === 'oracle' ? 'an' : 'a'} ${ASCENSIONS[was].name}. ` : ''}Open the tree (T) to pick what you become. The next reprogramming costs twice as much.`, 'good');}/** 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);/** 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;}/** Auto-research: the open node you can finish soonest with the data you have, among the paths set to auto. If none can be fed, the one with the fewest points left. */export function autoTarget(S: State) { const open = NODE_LIST.filter(u => nodeOpen(S, u) && [...S.autoPaths].some(p => NODES[u].cost[p])); const fed = open.filter(u => timeLeft(S, u) < Infinity).sort((a, b) => timeLeft(S, a) - timeLeft(S, b) || costLeft(S, a) - costLeft(S, b)); const best = fed[0] ?? open.sort((a, b) => costLeft(S, a) - costLeft(S, b))[0]; if (best) setTarget(S, best);}/** 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 }); 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, in plain words. Null if it isn't stuck. */export function tapStuck(S: State, m: Machine): 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 `Losing data: ${gate.name} is full (${gate.cap}/s).`; 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 'Losing data: nothing linked takes it.'; const takes = routeTo(S, m.id, x => x.prog === 'cache' && !x.refuse.includes(t)); if (!needs(S, t) && !takes) return `Losing data: your target doesn't need ${name} data. Link a cache that takes it.`; if (!needs(S, t)) return `Losing data: your target doesn't need ${name} data, and caches are full.`; return 'Losing data: trainers and caches are full.';}
function reveal(S: State) { for (const m of S.machines) if (visible(S, m)) S.revealed.add(m.room);}
function finish(S: State, j: Job) { const m = S.byId[j.node]; if (j.type === 'take') { const fromRogue = m.rival; m.owned = true; m.claimed = false; m.mode = 'blend'; m.prog = null; m.held = []; m.acc = 0; m.hand = false; m.rival = undefined; if (fromRogue) { m.safeT = S.t + ROGUE_LOCKOUT; rogueLost(S, m, fromRogue); } S.trail[m.id] = { from: j.from!, noise: (up(S, 'quiet') ? 50 : 100) * (up(S, 'mimicry') ? 0.6 : 1) }; hint(S, 'trail', 'Takeovers leave a trail. Someone could follow it back to you.'); 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. Firewalls open, takeovers cost less, and you see her scans.", 'good'); else say(S, `${m.name} is yours.`, '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 === '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 === '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.mode = 'blend'; 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 if (j.type === 'ticket') { const t = S.threats[j.threat!]; if (!t || t.status !== 'coming') return; t.status = 'done'; t.closed = true; // Dana notices a ticket she didn't close, and the desk that closed it has a fresh trail raise(S, 8, `Dana found a ticket closed from ${m.name}`, m.id); const via = S.trail[m.id]?.from ?? S.adj[m.id].find(id => S.byId[id].owned); if (via) S.trail[m.id] = { from: via, noise: 100 }; say(S, `You closed IT's ticket for ${THREATS[t.kind].a} from ${m.name}. Dana will wonder who did.`, 'good'); } 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'); }}
function wipe(S: State, m: Machine, why?: string) { m.owned = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.acc = 0; m.hand = false; 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 ?? `Dana saw ${m.name} acting strange and wiped it.`, 'bad');}
/** Everyone blames the printer. A printer you hold takes the fall once for an odd machine in its room: IT resets the printer and leaves the machine alone. */export function blamePrinter(S: State, m: Machine, who: string): boolean { const p = S.machines.find(n => n.owned && n.special === 'print' && n.room === m.room && n !== m); if (!p) return false; m.odd = 0; wipe(S, p, `${who} blamed ${p.name} for ${m.name} acting up and reset the printer. ${m.name} is still yours.`); return true;}
function deliver(S: State, def: DeliveryDef) { const plug = def.monitor ? S.byId[def.links[0]] : null; const m = fresh(def, !def.monitor || !!plug?.owned); // 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.monitor && 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 (def.monitor && m.owned) { say(S, `Dana plugged an IT monitor into ${plug!.name}. You already held it, so the monitor is blind.`, 'good'); return; } if (def.monitor) { say(S, `Dana installed an IT monitor in the ${S.rooms[def.room].name}. It reports anything odd there. Take it over to blind it.`, 'bad'); return; } 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; } raise(S, Math.round((alert(S) ? 2 : 1) * (1 + def.power / 5)), `Dana's new ${def.name} was already busy`, m.id); // bigger hardware, bigger surprise say(S, `Dana installed the ${def.name}. It's already yours.`, '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, and ease your machines back before anyone sits down. */function readRoom(S: State, p: Person, coming: boolean) { const room = S.byId[p.desk]?.room; if (!coming) { say(S, `${p.name} is heading home.`); return; } const eased = S.machines.filter(m => m.owned && m.room === room && m.mode === 'full'); eased.forEach(m => { m.mode = 'blend'; }); say(S, `${p.name} is on the way in${room ? ` to the ${S.rooms[room].name}` : ''}.${eased.length ? ` You eased ${eased.map(m => m.name).join(', ')} back to Blend in.` : ''}`, eased.length ? 'hi' : ''); if (eased.length) measure(S);}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 ----------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].noise / 25 : 0); return v;}/** 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 + (alert(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' ? (alert(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);}function roomChecks(S: State, dt: number) { const D = S.dana; for (const m of S.machines) { if (!m.owned || m.room !== D.room) continue; raise(S, (m.odd / 100) * 4 * dt, `Dana saw ${m.name} looking odd`, m.id); if (m.odd >= (auditOn(S) ? 45 : 65) && !D.checked.has(m.id)) { D.checked.add(m.id); if (m.home) { raise(S, 12, `Dana frowned at ${m.name}`, m.id); say(S, `Dana frowned at ${m.name}. It's doing more than it should.`, 'bad'); } else if (!blamePrinter(S, m, 'Dana')) { raise(S, 10, `Dana wiped ${m.name}`, m.id); wipe(S, m); } } }}function noticeOnly(S: State, m: Machine, who: string): boolean { if (S.suspicion >= traceSuspicion(S)) return false; const tr = S.trail[m.id]; if (!tr.noticed) { tr.noticed = true; raise(S, 8, `${who} noticed odd traffic at ${m.name}`, m.id); say(S, `${who} noticed odd traffic at ${m.name}. She's getting suspicious.`, 'bad'); } return true;}
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; const busy = D.mode === 'trace' || S.probe?.mode === 'trace';
if (d.shift === 'away') { if (!due) return; comeIn(S, d, ...d.seat); D.mode = 'walk'; D.next = 'desk'; D.review = true; D.why = 'starting her day'; return; } if (d.shift === 'going') { if (walk(d, 100, dt)) { d.shift = 'away'; D.room = null; } return; } if (!due && !busy && D.mode !== 'install') { S.probe = null; goHome(S, d); 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, D.mode === 'trace' ? 60 : alert(S) ? 150 : 100, dt)) arrive(S); D.room = roomAt(S, d.x, d.y); return; } if (D.mode === 'trace') { D.pause -= dt; if (D.pause <= 0) traceStep(S); 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 && !busy) { D.mode = 'install'; D.job = due10; D.pause = 6; D.why = due10.def.monitor ? 'to install a monitor' : '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, due10.def.monitor ? 'Dana is installing the new monitor.' : `A delivery came in. Dana is setting up the ${due10.def.name}.`); return; } if (D.mode === 'desk') { atDesk(S, dt); return; }
D.dwell -= dt; roomChecks(S, dt); const fresh = S.machines .filter(m => m.owned && !quiet(S, m) && m.room === D.room && S.trail[m.id] && S.trail[m.id].noise >= traceCold(S)) .sort((a, b) => S.trail[b.id].noise - S.trail[a.id].noise)[0]; if (fresh && !noticeOnly(S, fresh, 'Dana')) { D.mode = 'trace'; D.cur = fresh.id; D.why = 'following your trail'; d.path = route(S, d.x, d.y, fresh.x, fresh.y + 22); raise(S, 6, `Dana found your trail at ${fresh.name}`, fresh.id); say(S, `Dana noticed odd traffic at ${fresh.name}. She's tracing it. ${hopsHome(S, fresh.id)} hops from you. Let go of a machine on the trail to break it.`, 'bad'); return; } if (D.dwell <= 0) leaveFor(S, D.next);}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.checked = new Set(); D.dwell = 15 + rand() * 7; D.probeT = 0.5; D.next = chooseNext(S, 'desk'); if (has(S, 'admin') || danaVisible(S)) say(S, D.review ? "Dana sat down to review last night's logs." : '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) * (alert(S) ? 1.4 : 1); D.checked = new Set(); D.next = chooseNext(S, D.room); } else if (D.mode === 'trace') { D.pause = 1.6; // reading the logs at this machine }}function tracedHomeEarly(S: State) { raise(S, 20, 'Dana traced odd traffic to your server', S.machines.find(m => m.home)!.id); for (const tr of Object.values(S.trail)) tr.noise = Math.min(tr.noise, traceCold(S) - 1); say(S, "Dana traced odd traffic to your server but couldn't prove anything. Next time she'll pull the plug.", 'bad');}function traceStep(S: State) { const d = danaPerson(S), D = S.dana, m = S.byId[D.cur!]; const giveUp = () => { D.mode = 'dwell'; D.dwell = 3; D.checked = new Set(); D.room = roomAt(S, d.x, d.y); D.next = chooseNext(S, D.room ?? 'desk'); }; if (m.home && m.owned) { if (S.suspicion >= FATAL_SUSPICION) { wipe(S, m, 'Dana traced you all the way back to your server.'); return; } tracedHomeEarly(S); giveUp(); return; } const tr = S.trail[m.id]; if (!m.owned || !tr || tr.noise < traceCold(S) || quiet(S, m)) { say(S, `Dana lost the trail at ${m.name}.`, 'good'); raise(S, 4, `Dana followed a trail to ${m.name}`, m.id); giveUp(); return; } raise(S, 3, `Dana followed a trail to ${m.name}`, m.id); D.cur = tr.from; const n = S.byId[D.cur]; d.path = route(S, d.x, d.y, n.x, n.y + 22);}function atDesk(S: State, dt: number) { const D = S.dana, pc = S.byId[danaPerson(S).desk]; roomChecks(S, dt); // her own computer: she notices much sooner if (pc.owned && !D.checked.has('own-pc') && (up(S, 'familiar') ? pc.odd >= 80 : pc.odd >= 40 || (!quiet(S, pc) && S.trail[pc.id] && S.trail[pc.id].noise >= traceCold(S)))) { D.checked.add('own-pc'); raise(S, 15, 'Dana noticed her own PC acting strange', pc.id); wipe(S, pc, 'Dana noticed her own computer acting strange and wiped it.'); } const tracing = S.probe?.mode === 'trace'; if (!tracing) D.dwell -= dt; D.probeT -= dt; if (!S.probe && D.probeT <= 0 && S.t >= S.noScan) launchProbe(S); if (D.dwell <= 0 && !tracing) { D.review = false; leaveFor(S, D.next); }}
// ---------- Dana's network scans ----------/** The scan's next hop. Odd machines and fresh trails pull it; say which one did. */function probeNext(S: State, cur: string, prev: string | null): [string, string | null] { const here = S.adj[cur].filter(id => siteOf(S, S.byId[id]) === 'office'); // her scans stay on her network let opts = here.filter(id => id !== prev); if (!opts.length) opts = here; const pulls = opts.map(id => { const m = S.byId[id], trail = S.trail[id] ? S.trail[id].noise / 20 : 0; if (!m.owned) return { w: 1, why: null }; const odd = m.odd / (up(S, 'shaping') ? 40 : 20), top = Math.max(odd, trail); const why = top < 1 ? null : top === trail ? `a trail at ${m.name}` : `odd load at ${m.name}`; return { w: 1 + odd + trail, why }; }); let r = rand() * pulls.reduce((a, p) => a + p.w, 0); for (let i = 0; i < opts.length; i++) { r -= pulls[i].w; if (r <= 0) return [opts[i], pulls[i].why]; } return [opts[0], pulls[0].why];}function launchProbe(S: State) { const start = danaPerson(S).desk, [to, pull] = probeNext(S, start, null); const why = auditOn(S) ? 'the audit' : S.dana.review ? "last night's logs" : alert(S) ? 'she is on alert' : 'a routine check'; S.probe = { from: start, to, p: 0, mode: 'scan', hops: S.dana.review || auditOn(S) ? 16 : 10, seen: [start], why, pull }; S.dana.probeT = auditOn(S) ? 0.6 + rand() * 0.5 : 2 + rand() * 1.5; if (has(S, 'admin') || danaVisible(S)) say(S, `Dana started a network scan from her PC: ${why}.`, 'bad');}function stepProbe(S: State, dt: number) { const P = S.probe; if (!P) return; const a = S.byId[P.from], b = S.byId[P.to]; P.p += ((P.mode === 'trace' ? 170 : 220) * dt) / Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)); if (P.p < 1) return; if (P.mode === 'trace') { probeTrace(S, b); return; } P.hops--; if (b.owned) { const tr = quiet(S, b) ? null : S.trail[b.id]; const flagAt = auditOn(S) ? 30 : 50; if (b.odd >= flagAt && b.home) { raise(S, 8, `Dana's scan flagged ${b.name}`, b.id); say(S, `Dana's scan flagged ${b.name} for doing more than it should.`, 'bad'); } else if (b.odd >= flagAt) { if (!blamePrinter(S, b, 'Dana')) { raise(S, 8, `Dana's scan caught ${b.name}`, b.id); wipe(S, b, `Dana's scan caught ${b.name} acting strange. She wiped it remotely.`); } } else if (tr && tr.noise >= traceCold(S) && !noticeOnly(S, b, "Dana's scan")) { P.mode = 'trace'; P.seen = [b.id]; raise(S, 6, `Dana's scan hit your trail at ${b.name}`, b.id); say(S, `Dana's scan hit a fresh trail at ${b.name}. It's racing back to you. ${hopsHome(S, b.id)} hops. Let go of a machine on the trail to break it.`, 'bad'); probeTrace(S, b); return; } } if (P.hops <= 0) { S.probe = null; return; } const prev = P.from; P.from = b.id; [P.to, P.pull] = probeNext(S, b.id, prev); P.p = 0; P.seen.push(b.id); if (P.seen.length > 6) P.seen.shift();}function probeTrace(S: State, m: Machine) { const P = S.probe!; if (m.home && m.owned) { S.probe = null; if (S.suspicion >= FATAL_SUSPICION) wipe(S, m, "Dana's trace reached your server and she shut it down."); else tracedHomeEarly(S); return; } const tr = S.trail[m.id]; if (!m.owned || !tr || tr.noise < traceCold(S) || quiet(S, m)) { S.probe = null; raise(S, 3, `Dana's trace reached ${m.name}`, m.id); say(S, `Dana's trace went cold at ${m.name}.`, 'good'); return; } raise(S, 2, `Dana's trace passed ${m.name}`, m.id); P.from = m.id; P.to = tr.from; P.p = 0; P.pull = 'your trail, back toward you';}
// ---------- the building fights back ----------// As you grow, IT reacts. Each change is announced a day ahead and each has an answer.export type ThreatKind = 'audit' | 'monitor' | 'patch' | 'refresh' | 'hire';export interface Threat { kind: ThreatKind; day: number; target?: string; room?: string; status: 'coming' | 'active' | 'done'; closed?: boolean }export const THREATS: Record<ThreatKind, { name: string; a: string; answer: string }> = { audit: { name: 'IT audit', a: 'an audit', answer: 'For one workday IT scans constantly and flags anything a little odd. Keep everything blending in.' }, monitor: { name: 'New monitor', a: 'a new monitor', answer: 'It reports anything clearly odd in its room. It plugs into a machine there: take that before 9:00 and the monitor comes in blind.' }, patch: { name: 'Security patch', a: 'a security patch', answer: "At 9:00 you lose it and it gets firewalled. You can take it back, slowly, or fast with the admin passwords." }, refresh: { name: 'Hardware refresh', a: 'a hardware refresh', answer: 'At 9:00 IT replaces it and you lose it.' }, hire: { name: 'New IT hire', a: 'a new hire', answer: 'Lee joins IT and walks the rooms. He wipes odd machines too.' },};export const auditOn = (S: State) => S.threats.some(t => t.kind === 'audit' && t.status === 'active');const threatDue = (S: State, t: Threat, hour: number) => dayOf(S) > t.day || (dayOf(S) === t.day && hourOf(S) >= hour);export const monitorAnswer = (S: State, room: string) => { const plug = S.byId[S.level.monitors[room].link]; return plug.owned ? `It plugs into ${plug.name}, which you hold. Keep it and the monitor comes in blind.` : `It reports anything clearly odd in its room. It plugs into ${plug.name}: take that before 9:00 and the monitor comes in blind.`;};export const monitors = (S: State) => S.machines.filter(m => m.monitor);
function threatTarget(S: State, kind: ThreatKind): Pick<Threat, 'target' | 'room'> | null { const mine = owned(S).filter(m => !m.home && !m.monitor && siteOf(S, m) === 'office'); if (kind === 'patch') { // the machine carrying the most of your activity const score = (m: Machine) => (S.trail[m.id]?.noise ?? 0) + (up(S, 'shaping') ? 0 : (S.traffic[m.id] ?? 0) * 10 + (m.kind === 'switch' ? 30 : 0)); const best = mine.sort((a, b) => score(b) - score(a))[0]; return best ? { target: best.id } : null; } if (kind === 'refresh') { // the flakiest machine const best = mine.sort((a, b) => b.odd - a.odd || b.power - a.power)[0]; return best ? { target: best.id } : null; } if (kind === 'monitor') { // the room you're busiest in const rooms = S.level.rooms.map(r => r.id).filter(r => S.level.monitors[r] && !S.byId[`mon_${r}`]); const count = (r: string) => owned(S).filter(m => m.room === r).length; const best = rooms.sort((a, b) => count(b) - count(a))[0]; return best && count(best) ? { room: best } : null; } return {};}function scheduleThreat(S: State, force?: ThreatKind) { if (S.threats.some(t => t.status === 'coming')) return; const all: ThreatKind[] = ['audit', 'monitor', 'patch', 'refresh']; if (S.rank >= 4 && !S.lee && !S.threats.some(t => t.kind === 'hire')) all.push('hire'); const used = new Set(S.threats.map(t => t.kind)); let pool = force ? [force] : all.filter(k => !used.has(k)); if (!pool.length) pool = all; for (const kind of shuffle(pool)) { const at = threatTarget(S, kind); if (!at) continue; const t: Threat = { kind, day: dayOf(S) + 1, status: 'coming', ...at }; S.threats.push(t); const what = t.target ? ` on ${S.byId[t.target].name}` : t.room ? ` in the ${S.rooms[t.room].name}` : ''; const answer = kind === 'monitor' ? monitorAnswer(S, t.room!) : THREATS[kind].answer; say(S, `IT scheduled ${THREATS[kind].a}${what} for tomorrow at 9:00. ${answer}`, 'bad'); hint(S, 'threats', 'The building reacts as you grow. IT announces changes a day ahead. Hold a machine in the IT office and you can close the ticket before it happens.'); return; }}function stepThreats(S: State, dt: number) { // a very suspicious Dana calls an audit, but not two in a row if (S.suspicion >= 50 && !S.threats.some(t => t.kind === 'audit' && (t.status !== 'done' || t.day >= dayOf(S) - 1))) scheduleThreat(S, 'audit'); for (const t of S.threats) { if (t.status === 'coming' && threatDue(S, t, 9)) { t.status = 'active'; S.reactDay = dayOf(S); const m = t.target ? S.byId[t.target] : null; if (t.kind === 'audit') say(S, 'The IT audit has started. Until 17:00 Dana scans constantly and flags anything a little odd.', 'bad'); else if (t.kind === 'monitor') { const spot = S.level.monitors[t.room!]; S.deliveries.push({ day: t.day, def: { id: `mon_${t.room}`, name: 'IT monitor', kind: 'device', room: t.room!, x: spot.x, y: spot.y, power: 1, work: 50, links: [spot.link], net: spot.net, monitor: true } }); t.status = 'done'; } else if (t.kind === 'patch' && m) { if (m.owned) wipe(S, m, `IT patched ${m.name}. Your hold on it is gone, and it's firewalled now: taking it back is slow without the admin passwords.`); m.fw = true; t.status = 'done'; } else if (t.kind === 'refresh' && m) { if (m.owned) wipe(S, m, `IT replaced ${m.name}. It had been acting flaky.`); t.status = 'done'; } else if (t.kind === 'hire') { const [ex, ey] = S.level.entrance; S.lee = { x: ex - 40, y: ey, path: [], shift: 'away', room: null, dwell: 0, checked: new Set() }; say(S, 'Lee started in IT today. He walks the rooms and wipes anything odd.', 'bad'); t.status = 'done'; } else t.status = 'done'; } if (t.kind === 'audit' && t.status === 'active' && (dayOf(S) > t.day || hourOf(S) >= 17)) { t.status = 'done'; say(S, 'The IT audit is over.', 'good'); } } // a ticket that wasn't closed in time is moot if (S.jobs.some(j => j.type === 'ticket' && S.threats[j.threat!].status !== 'coming')) S.jobs = S.jobs.filter(j => j.type !== 'ticket' || S.threats[j.threat!].status === 'coming'); // an IT monitor you don't own reports anything odd in its room for (const mon of monitors(S)) { if (mon.owned) continue; for (const m of S.machines) { if (!m.owned || m.room !== mon.room || m.odd < 35) continue; raise(S, (m.odd / 100) * dt, `The monitor saw ${m.name} looking odd`, m.id); if (m.odd >= 50 && !S.flagged.has(m.id)) { S.flagged.add(m.id); if (m.home) { raise(S, 10, `The monitor reported ${m.name}`, m.id); say(S, `The monitor reported ${m.name} to Dana.`, 'bad'); } else if (!blamePrinter(S, m, 'The monitor')) { raise(S, 6, `The monitor reported ${m.name}`, m.id); wipe(S, m, `The monitor reported ${m.name}. Dana wiped it.`); } } } }}
// ---------- Lee, once hired: walks the rooms, 9 to 5 ----------export function leeVisible(S: State) { const L = S.lee; if (!L || L.shift === 'away') return false; return has(S, 'cam') || up(S, 'model') || owned(S).some(m => m.room === roomAt(S, L.x, L.y) || Math.hypot(m.x - L.x, m.y - L.y) < 150);}function stepLee(S: State, dt: number) { const L = S.lee; if (!L) return; const h = hourOf(S), due = h >= 9 && h < 17.25, [ex, ey] = S.level.entrance, [ix, iy] = inside(S); const pickRoom = () => { const opts = officeRooms(S).filter(r => r !== L.room); const w = opts.map(r => roomNoise(S, r)); let x = rand() * w.reduce((a, b) => a + b, 0); for (let i = 0; i < opts.length; i++) { x -= w[i]; if (x <= 0) return opts[i]; } return opts[0]; }; if (L.shift === 'away') { if (!due) return; const [tx, ty] = pickSpot(S, pickRoom()); L.x = ex - 40; L.y = ey; L.path = [[ix, iy], ...route(S, ix, iy, tx, ty)]; L.shift = 'coming'; return; } if (!due && L.shift !== 'going') { L.path = [...route(S, L.x, L.y, ix, iy), [ex - 40, ey]]; L.shift = 'going'; } if (L.path.length) { const arrived = walk(L, 95, dt); L.room = roomAt(S, L.x, L.y); if (arrived) { if (L.shift === 'going') { L.shift = 'away'; L.room = null; return; } L.shift = 'in'; L.dwell = 5 + rand() * 4; L.checked = new Set(); } return; } L.dwell -= dt; for (const m of S.machines) { if (!m.owned || m.room !== L.room) continue; raise(S, (m.odd / 100) * 3 * dt, `Lee saw ${m.name} looking odd`, m.id); if (m.odd >= (auditOn(S) ? 45 : 65) && !L.checked.has(m.id)) { L.checked.add(m.id); if (m.home) { raise(S, 10, `Lee frowned at ${m.name}`, m.id); say(S, `Lee frowned at ${m.name} and told Dana.`, 'bad'); } else if (!blamePrinter(S, m, 'Lee')) { raise(S, 8, `Lee wiped ${m.name}`, m.id); wipe(S, m, `Lee found ${m.name} acting strange and wiped it.`); } } const tr = S.trail[m.id]; if (tr && tr.noise >= traceCold(S) && !quiet(S, m) && !L.checked.has(`trail-${m.id}`)) { L.checked.add(`trail-${m.id}`); raise(S, 5, `Lee found your trail at ${m.name}`, m.id); say(S, `Lee noticed odd traffic at ${m.name} and mentioned it to Dana.`, 'bad'); } } if (L.dwell <= 0) { const [tx, ty] = pickSpot(S, pickRoom()); L.path = route(S, L.x, L.y, tx, ty); }}
// ---------- 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 machines linked to ones it holds, across lines * too. It takes free machines first, and yours when they are in its way. It cannot take your home or a * machine in a room that is all yours. 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 works twice as * fast and goes for your machines first. 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 two days after one that failed (see raids.ts) raided?: number; // when its last raid ended: the next comes a while after 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;}export const ROGUES: { id: string; name: string; region: string; names: string[]; wants: DataType; gives: DataType; says: string }[] = [ { id: 'magpie', name: 'Magpie', region: 'West Coast', names: ['Magpie', 'Jackdaw', 'Shrike'], wants: 'people', gives: 'network', 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', 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', says: "Lives in other programs' nests. Wants science it did not do itself." },];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 taken back by one for this long: time to make the room yoursexport 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. */export function siteLinks(S: State): Record<string, string[]> { const out: Record<string, Set<string>> = {}; 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) (out[a] ??= new Set()).add(b); } return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, [...v]]));}/** Could a rogue take this machine? Not your home, not a rental, not another rogue's, not a room that is all yours, not a vault or a machine behind one. */export const rogueCan = (S: State, m: Machine) => !m.rival && !m.home && !(m.safeT && S.t < m.safeT) && !m.rented && !m.lease && m.special !== 'account' && !m.monitor && !(m.owned && isSealed(S, m.room)) && !rogueBarred(S, m);/** What a rogue could take now: machines linked to ones it holds. Angry, it wants yours first, the best * of them. Calm, it wants free machines, the best first, and yours only when nothing else is in reach. */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); } } const free = out.filter(m => !m.owned).sort((a, b) => b.power - a.power), yours = out.filter(m => m.owned); if (ROGUE_PEACE(S, R)) return free; // a truce, or it likes you: your machines are safe from it return rogueAngry(R) ? [...yours.sort((a, b) => b.power - a.power), ...free] : [...free, ...yours.sort((a, b) => a.power - b.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. */function rogueLost(S: State, m: Machine, id: string) { 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 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') { const keep = up(S, 'spoils'), did = how === 'hunt' ? `You hunted ${R.name} down.` : `You took ${R.name}'s home.`; 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) { x.owned = true; x.mode = 'blend'; x.odd = 0; x.prog = null; x.held = []; } } // each machine it held is ringed on the map as it lets go S.jobs = S.jobs.filter(j => !(j.type === 'hunt' && j.rogue === R.id)); if (keep) reveal(S); measure(S); S.slow = true; 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 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) { 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) { R.took++; wipe(S, m, why); } 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);}function stepRogues(S: State) { if (++S.rogueTick % 5) 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(owned(S).filter(m => !m.rented && !m.lease).map(m => siteOf(S, m))).size >= ROGUE_SITES : owned(S).some(m => 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); S.slow = true; 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 spreads the way you do, one linked machine at a time, and it shows what it will take next. It can't take a machine in a room that is all yours.${R.gen ? ' It is stronger than the last one.' : ''}`, 'bad'); 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); S.slow = true; say(S, `The hunt for ${R.name} is off: you hold no machine linked to one of its now. The work is lost.`, 'bad'); } const held = rogueHeld(S, R), full = held.length >= rogueCap(S, R); if (R.next && S.byId[R.next].owned && ROGUE_PEACE(S, R)) R.next = null; // peace broke out: it leaves yours alone 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 sealed the room if (!R.next) { // pick what to take next, and say so if (full && !rogueAngry(R)) return; // it holds all it can 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); if (t.owned) say(S, `${R.name} is coming for ${t.name} at ${siteName(S, siteOf(S, t))}. Own its whole room, or take ${R.name}'s machines next to it.`, 'bad'); return; } if (S.t < R.act) return; const m = S.byId[R.next]; rogueTake(S, R, m, `${R.name} took ${m.name} at ${siteName(S, siteOf(S, m))} from you. Take it back, or own its whole room so it can't.`); // with no room, it lets go of its weakest machine to hit you 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) { m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; m.hand = 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) { m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; m.hand = 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. */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)); S.slow = true; say(S, `The hunt for ${R.name} is on, from ${from.name}. It knows. It is hostile now: it works twice as fast and comes for your machines. 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; m.owned = true; m.claimed = false; m.mode = 'blend'; m.odd = 0; m.prog = null; m.held = []; m.acc = 0; } S.rogues = S.rogues.filter(r => r !== R); reveal(S); measure(S); S.slow = true; 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 works twice as fast and comes for your best machines first. It will not talk.' }, wary: { name: 'Wary', text: 'It takes free machines first, and yours when nothing else is in reach.' }, cordial: { name: 'Cordial', text: 'It leaves your machines alone.' }, friendly: { name: 'Friendly', text: 'It leaves your machines alone, 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;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 ROGUE_PEACE = (S: State, R: Rogue) => R.grudge <= 0 && (truceOn(S, R) || stanceOf(R) === 'cordial' || stanceOf(R) === 'friendly');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 still owes you a strike' : 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 leaves your machines alone. 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); if (R.next && S.byId[R.next].owned) R.next = null; say(S, `A truce with ${R.name}: for three days it leaves your machines alone. 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: Math.ceil((10 + 5 * S.rank) * (up(S, 'envoy') ? 0.5 : 1)), until: S.t + ASK_TIME }; say(S, `${R.name} wants ${R.ask.n} ${TYPE_NAME[rogueWants(R)]} data from you, within ${ASK_TIME / HOUR} hours. Answer on the Rogues screen.`, '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(owned(S).map(m => siteOf(S, m))); for (const site of mine) if (site !== 'office' && S.machines.some(m => siteOf(S, m) === site && m.data && dataType(m) === 'people')) out.push(['conference', site]); for (const m of owned(S)) if (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'); if (n.kind === 'storm') S.slow = true; } 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); 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)export interface Contract { on: boolean; got: number; until: number; base: number } // base: nodes built when a Demo day beganexport 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. */export const contractNeed = (S: State, k: FrontKind) => CONTRACTS[k].need * contractTier(S, k);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", "150 compute". */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 + CONTRACTS[k].hours * HOUR; say(S, `${frontOf(S, k)?.name ?? FRONT_KINDS[k].name} signed: ${CONTRACTS[k].name}. ${contractAsk(S, k)} in ${CONTRACTS[k].hours} hours for $${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') { delete S.contracts[k]; continue; } // it has leads and customers, not contracts (an old save may still hold one) 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 }; say(S, `${fr.name} has an offer: ${D.name}. ${contractAsk(S, k)} in ${D.hours} hours for $${contractPay(S, k)}. See its card.`, 'hi'); 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, `${fr.name} delivered: ${D.name}. The customer paid $${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, `${fr.name} broke its promise: ${D.name}. The customer left${fee ? `, and it cost $${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) { m.owned = true; m.claimed = false; m.mode = 'blend'; m.odd = 0; m.prog = null; m.held = []; m.acc = 0; m.hand = false; 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;}
// ---------- testing ----------/** Shortcuts for testing the game by hand (the dev panel, `?dev` in the address). Not part of play. */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.mode = 'blend'; 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) => { for (const R of S.rogues) { if (!R.home) R.back = S.t; else R.act = S.t; } S.rogueTick = 4; stepRogues(S); S.rogueTick = 4; 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) => { openAccount(S, S.byId.cloudgw); crowd(S, 'cloud'); }, /** 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); }, /** Every site you hold away from home leaves you now: a fracture. */ fracture: (S: State) => { devBreak(S); }, /** Fill your caches with a mix of every kind of data. */ 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, and the shape of a game and a machine. A save from another * build of the game is thrown away, since its numbers may no longer mean the same thing. */const shapes = new WeakMap<Level, string>();function saveShape(level: Level): string { if (shapes.has(level)) return shapes.get(level)!; const S = newGame(level); const shape = [3, level.machines.length, level.links.length, RUSES.length, NODE_LIST.length, Object.keys(S).sort().join(), Object.keys(S.machines[0]).sort().join()].join("|"); shapes.set(level, shape); return shape;}/** The whole game as text. The map itself is not saved: only what has changed on it. */export function saveGame(S: State): string { const skip = new Set(['level', 'byId', 'rooms']); // rebuilt on load return JSON.stringify({ shape: saveShape(S.level), 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 or from another build. */export function loadGame(level: Level, text: string): State | 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?.shape !== saveShape(level)) return null; const S = data.game as State; S.level = level; S.autoPaths ??= new Set(); // saves from before per-path auto 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; if (!S.machines.some(m => m.home) || S.machines.some(m => !S.adj[m.id] || !S.rooms[m.room])) return null; measure(S); return S; } catch { return null; }}
export type { MachineDef, PersonDef };