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.
Something went wrong. Try again.
324 kB · 8303 lines
Rust
at commit e957ce7b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304//! The renderer-agnostic simulation core (Milestone B1).//!//! `Sim` owns all game state and advances it one fixed tick at a time. It//! orchestrates the B1 subsystems — compute (machine.rs), the core//! (core_sys.rs), detection (detection.rs), people (person.rs), the day job//! (dayjob.rs), digital reach (reach.rs) — over the authored basement//! (prefab.rs). Frontends map wall-clock time to ticks, render state, and//! call the command methods.//!//! Determinism: all randomness flows through the seeded `Rng`; nothing here//! reads the wall clock.
use std::collections::{HashMap, HashSet};
use crate::account::{AccountFlowId, AccountGraph, PositionResolution};use crate::actions::Anchor;use crate::core_sys::{Core, HostLoss};use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock};use crate::detection::{Detection, Signature, SignatureKind};use crate::entities::Player;use crate::income::{self, EgressRoute, Income};use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind};use crate::intents::{BuildActuator, BuildIntent, IntentStatus};use crate::machine::{Channel, ChannelYield, Compute, Provenance};use crate::map::GameMap;use crate::messages::{ Message, MessageChannel, MessageEndpoint, MessageEvent, MessageOrigin, MessagePayload, MessageStatus, TrafficPattern,};use crate::objective::{ObjectiveState, SYNC_FRESHNESS_WINDOW, SanctuaryFacts};use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona,};use crate::prefab::Room;use crate::reach::{Device, Party, ReachBlock, ReachNet, segment_name};use crate::research::{ CALIBRATED_BAND_SHIFT, EFFICIENCY_MULT_PER_LEVEL, MaskingPolicy, Research, SPEND_ATTENTION_PER_JOB, SPEND_TRUST_PER_JOB, Track,};use crate::rng::Rng;use crate::save::SaveState;use crate::schedule::Schedule;use crate::tiles::TileType;use crate::work_grid::{MachineMode, TokenFamily, WorkGrid, WorkQueues};
/// Default deterministic seed for a fresh run.pub const DEFAULT_SEED: u64 = 0x5EED_1234;
/// Ticks between economy resolutions (power, allocation, research).pub const ECONOMY_INTERVAL: u64 = 20;
/// What the player knows of a tile and how they know it, by precedence/// (wiki/mechanics/cursor.md): Seen > Heard > Remembered > Blueprint > Unknown.#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]pub enum Fog { /// Full live detail: a subscribed seeing sensor covers it. Seen, /// Room-grade live knowledge: hearing coverage, no picture. Heard, /// A dim snapshot from the last time a seeing feed covered the tile. /// Not live. Remembered, /// Schematic layout knowledge from the reach graph's known topology — /// no live data. Blueprint, /// Never sensed. Dark. Unknown,}
/// One remembered tile snapshot (cursor.md: map memory has an in-fiction/// reason because a process keeps logs). The snapshot is tile-only today;/// live occupants and machine state are deliberately excluded.#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]pub struct RememberedTile { pub x: i32, pub y: i32, pub tile: TileType, pub last_seen: u64,}
/// Provenance tag for a fact on an inspect card.#[derive(Debug, Clone, PartialEq, Eq)]pub enum FactSource { Seen, Heard, Remembered(u64), Blueprint, Telemetry, Intel { feed: String, tick: u64 },}
/// A single known fact about an inspected tile, tagged with how it is known.#[derive(Debug, Clone, PartialEq, Eq)]pub struct InspectFact { pub label: String, pub value: String, pub source: FactSource,}
/// Renderer-neutral inspect card used by both frontends.#[derive(Debug, Clone, PartialEq, Eq)]pub struct InspectCard { pub x: i32, pub y: i32, pub fog: Fog, pub facts: Vec<InspectFact>,}
/// One log line, optionally carrying the anchor of the thing it is about/// (wiki/interface/context-menu.md, event-to-anchor linking: "Actions live/// on the thing" implies events carry you to the thing too). The anchor is/// set only where the emitting code knows it, and only to knowledge the/// player has earned — a heard-only person anchors to the room, never to/// their tile. Frontends render anchored lines as focus links; the log/// itself is a drain buffer, never saved.#[derive(Debug, Clone, PartialEq)]pub struct LogEvent { pub tick: u64, pub text: String, pub anchor: Option<Anchor>,}
/// A live audio event captured by hearing coverage (cursor.md: hearing/// yields presence and events, not pictures).#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]pub struct HeardEvent { pub tick: u64, pub room: String, /// The person involved, when one is (the sim knows; the note only names /// them if staged knowledge has identified them). pub person: Option<u8>, pub kind: HeardKind, /// The player-facing line, already knowledge-gated. pub note: String,}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]pub enum HeardKind { /// Someone entered a covered room. Entry, /// An overheard conversation (the audio intel channel). Conversation,}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum TraceDebtStatus { /// No one-shot signatures are waiting to be noticed; the player can move /// cycles back to cover work without gambling on the pending pool. Clear, /// Pending signatures remain, but current concealment should clear them /// before the next relevant observer cadence. HoldConceal, /// Pending signatures remain and the next relevant observer cadence can /// arrive before the current scrub rate clears them. ExposedSoon, /// Pending signatures remain but no compute is currently scrubbing them. NoScrub,}
#[derive(Debug, Clone, PartialEq)]pub struct TraceDebt { pub pending: i32, pub by_kind: Vec<(SignatureKind, i32)>, /// Signature size the next economy scrub pulse can remove at the current /// allocation and Tradecraft multiplier. pub scrub_strength: f32, pub next_scrub_tick: Option<u64>, pub clear_tick: Option<u64>, pub next_notice_tick: Option<u64>, pub status: TraceDebtStatus,}
/// Render-facing snapshot of one machine's visible work stacks. This is a/// view of `WorkGrid`, not a frontend counter (machine-work.md: one sim/// truth for tokens and render stacks).#[derive(Debug, Clone, Copy, PartialEq)]pub struct WorkStackReadout { pub machine_id: u32, pub x: i32, pub y: i32, pub mode: MachineMode, pub queues: WorkQueues,}
struct MessageDraft { channel: MessageChannel, from: MessageEndpoint, to: MessageEndpoint, payload: MessagePayload, summary: String, origin: MessageOrigin, reply_to: Option<u64>, delivery_delay: u64,}
pub struct Sim { pub map: GameMap, pub player: Player, pub rng: Rng, pub tick: u64,
pub compute: Compute, pub core: Core, pub detection: Detection, pub people: People, pub dayjob: DayJob, /// Money as account balances and scheduled flows (economy.md). The /// frontend-facing `player.money` mirrors this graph's slush node. pub accounts: AccountGraph, /// Self-modification: tracks, drift, the masking policy (research.rs). pub research: Research, /// Visible work-token substrate (machine-work.md): one mode per owned /// machine, demand/exposure/knowledge queue depths as render truth. pub work_grid: WorkGrid, /// The run's terminal goal and its live progress (objective.rs). The /// predicate is evaluated on economy ticks; the line is always on screen. pub objective: ObjectiveState, /// The named income schemes: the egress gate, Moonlight, the Wager's /// standing policies (income.rs; wiki/mechanics/income.md). pub income: Income,
/// The device graph: reach, ownership, subscriptions (reach.rs). pub reach: ReachNet, /// Sight: exactly the union of subscribed seeing feeds' coverage. pub seen: HashSet<(i32, i32)>, /// Hearing: exactly the union of subscribed hearing feeds' coverage. pub heard: HashSet<(i32, i32)>, /// Blueprint: schematic tiles derived from known graph topology. pub blueprint: HashSet<(i32, i32)>, /// Remembered snapshots of tiles that were once seen and are no longer /// live. Keyed by tile coordinate; survives save/load. pub remembered: HashMap<(i32, i32), RememberedTile>, /// Captured audio events (bounded; newest last). pub heard_events: Vec<HeardEvent>, /// Opaque, unprocessed recordings captured from subscribed feeds. pub intel_buffer: Vec<RawIntelEvent>, /// Durable processed intel with provenance. pub intel: Vec<ProcessedIntel>, /// Message threads and institutional filings, including read history. pub messages: Vec<Message>, /// Delivery/read events for messages in transit. pub message_schedule: Schedule<MessageEvent>, /// Next message id. Persisted through save/load. pub next_message_id: u64, /// Latest filing reports that aggregate observers have actually read: /// observer id -> filed suspicion. Detection reads this instead of raw /// field suspicion in the sim-integrated path, so filings have latency. pub filing_levels: HashMap<u8, f32>, /// B1 standing watches: per-person automated processing filters. pub watches: Vec<IntelWatch>, /// Next raw recording id. Persisted through save/load. pub next_intel_id: u64,
/// Build intents: pinned jobs realized by person actuators /// (wiki/mechanics/building.md). Declaring one changes nothing until /// an actuator completes it. pub intents: Vec<BuildIntent>, /// Next intent id. Persisted through save/load. pub next_intent_id: u64,
/// The player's granted badge credential, as a max tier (0 = none) — /// the same access scale humans carry (`Person::access`). Grown only /// by the world granting it (a cloned badge via an asset task): it is /// WorldLedger-shaped state for rollback.md — a door reader remembers /// the credential even if the mind rolls back. Write control of the /// badge controller is the other route to open doors and is derived /// from the reach graph, not stored here (see `player_badge_tier`). pub badge_access: i32,
/// Last known room per person, for entry-event detection (transient — /// rebuilt as time advances; not part of the save). last_rooms: HashMap<u8, Option<String>>, /// (person, hour) -> day an utterance last fired (transient dedup). utterance_fired: HashMap<(u8, u32), u64>, /// (person, traffic pattern id) -> day the authored message last fired. traffic_fired: HashMap<(u8, u32), u64>, /// Last known online state per machine, for machinery/anomaly recordings. last_machine_online: HashMap<u32, bool>,
/// Per-tick day-job compute delivered by the last economy split. last_day_job_rate: f32, /// Per-tick research compute delivered by the last economy split — /// the utilization the standing Power/Thermal emissions scale with. last_research_rate: f32, /// Per-tick Schemes-channel compute delivered by the last economy split /// — Moonlight throughput and the Wager's analysis snapshot. last_schemes_rate: f32, /// Accumulated social-ops bandwidth (the Social channel fills this; /// social and digital operations spend it). Capped so it can't hoard. pub social_bandwidth: f32, /// An asset arranged to receive the next delivery off-books: the next /// purchase emits no Paper signature (spec/social.md: move a package). pub package_cover: bool,
pub game_over: bool, pub game_over_reason: Option<String>,
log: Vec<LogEvent>,}
/// The one contextual "now:" nudge — the next beat of the Act One ladder/// the player has earned but not taken (DESIGN.md "Justification and/// legibility": the game tells you what it is waiting for, using only facts/// you can see; design-judgment: "dead air is a bug"). One shared chain so/// every frontend points at the same next thing; each frontend owns the/// wording (its own keys or protocol words). Conditions read only earned/// state — feeds you subscribe to, recordings you captured, flows you/// processed — never the world's hidden truth.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum Nudge { /// No live sight feed: the opening beat (scan/bridge/tap a camera). Eyes, /// The active job's band floor exceeds the all-in delivery ceiling: /// no allocation can meet it — grow compute (salvage/buy/optimize). NeedCompute, /// The active job is underfed but the band is reachable: allocate. Underfed, /// No hearing feed: the env monitor's audio is tappable from tick one. Ears, /// A recording of Marcus is waiting and his leverage is unlearned: /// process the 3 a.m. call. ReviewCall, /// No egress route: external schemes need the sanctioned channel /// (day-job trust) or a stolen splice through the switch. Egress, /// Arrears unpaid, slush short, no scheme running: earn (Moonlight). Income, /// Marcus's leverage is known and serviceable: cover the arrears. ServiceDebt, /// Leverage serviced, not yet recruited: close the Hands beat. Recruit, /// An asset carries a badge tier you don't hold: the quiet exit needs /// stairwell/elevator access — task them to clone it ("The key"). TheKey, /// Nothing else is pending: the standing clock is the next audit. Audit,}
impl Sim { pub fn new() -> Self { Self::with_seed(DEFAULT_SEED) }
pub fn with_seed(seed: u64) -> Self { let map = GameMap::new(0, 0); let core_bay = map.core_pos().unwrap_or((24, 15)); let accounts = AccountGraph::act_one(Self::DAY_TICKS); let mut player = Player::new(); player.money = accounts.slush_balance();
let mut compute = Compute::new(); // Rack 3 hosts the core; the empty bays are growth room. let rack3 = compute.add_machine( "Rack 3", core_bay.0, core_bay.1, 100, 1.0, 5, Provenance::Owned, ); let core = Core::new(rack3); let mut work_grid = WorkGrid::new(); for machine in &compute.machines { work_grid.add_machine( machine.id, machine.x, machine.y, if machine.id == core.host_machine { MachineMode::DayJob } else { MachineMode::Research }, machine.effective() / 100.0, ); } let reach = ReachNet::basement(&map);
let mut sim = Self { map, player, rng: Rng::new(seed), tick: 0, compute, core, detection: Detection::act_one(), people: People::act_one(), dayjob: DayJob::new(), accounts, research: Research::new(), work_grid, objective: ObjectiveState::default(), income: Income::default(), reach, seen: HashSet::new(), heard: HashSet::new(), blueprint: HashSet::new(), remembered: HashMap::new(), heard_events: Vec::new(), intel_buffer: Vec::new(), intel: Vec::new(), messages: Vec::new(), message_schedule: Schedule::new(), next_message_id: 1, filing_levels: HashMap::new(), watches: Vec::new(), next_intel_id: 1, intents: Vec::new(), next_intent_id: 1, badge_access: 0, last_rooms: HashMap::new(), utterance_fired: HashMap::new(), traffic_fired: HashMap::new(), last_machine_online: HashMap::new(), last_day_job_rate: 0.0, last_research_rate: 0.0, last_schemes_rate: 0.0, social_bandwidth: Self::STARTING_OPS, package_cover: false, game_over: false, game_over_reason: None, log: Vec::new(), }; sim.recompute_derived(); sim.recompute_senses(); sim.rebuild_transient_state(); sim.push_log("Process online in the basement. You have no eyes yet."); sim }
// ── Logging ────────────────────────────────────────────────────────────
fn push_log(&mut self, msg: impl Into<String>) { self.push_log_opt(msg, None); }
/// Log a line about a known thing: the entry carries the thing's anchor /// so frontends can offer "take me there" (context-menu.md addendum). /// Callers are responsible for epistemic honesty — pass only anchors /// the player's senses have earned (see `person_event_anchor`). fn push_log_at(&mut self, msg: impl Into<String>, anchor: Anchor) { self.push_log_opt(msg, Some(anchor)); }
fn push_log_opt(&mut self, msg: impl Into<String>, anchor: Option<Anchor>) { self.log.push(LogEvent { tick: self.tick, text: msg.into(), anchor, }); }
pub fn drain_log_entries(&mut self) -> Vec<LogEvent> { std::mem::take(&mut self.log) }
pub fn drain_log(&mut self) -> Vec<String> { self.drain_log_entries() .into_iter() .map(|ev| ev.text) .collect() }
/// Where an anchored event can carry the cursor *right now*, honoring /// the senses: a tile is its own place, a known device sits at its /// blueprint-known position, a person is placeable only while a sight /// feed covers them, and a flow lives on the ledger panel, not the map. /// Never returns a coordinate the player has not earned. pub fn anchor_position(&self, anchor: Anchor) -> Option<(i32, i32)> { match anchor { Anchor::Tile { x, y } => Some((x, y)), Anchor::Device(id) => self .reach .device(id) .filter(|d| d.known) .map(|d| (d.x, d.y)), Anchor::Person(id) => { if self.can_see_person(id) { self.person_pos(id) } else { None } } Anchor::Flow(_) => None, } }
/// The honest anchor for an event about a person: the person themself /// only while a sight feed covers them; otherwise the room the event /// was heard in (room-grade knowledge is what hearing earns); otherwise /// nothing. A heard-only person never anchors to their exact tile. fn person_event_anchor(&self, id: u8, room: Option<&str>) -> Option<Anchor> { if self.can_see_person(id) { return Some(Anchor::Person(id)); } let room = room?; self.map .room_named(room) .map(|r| r.center()) .map(|(x, y)| Anchor::Tile { x, y }) }
/// The anchor scheme paydays ride: the switch, when the traffic runs /// over the stolen egress spliced through it. The sanctioned route /// hides in the report account's legitimate use and anchors nowhere. fn egress_anchor(&self) -> Option<Anchor> { if self.egress() != Some(EgressRoute::Stolen) { return None; } self.reach .devices .iter() .find(|d| d.is_switch) .map(|d| Anchor::Device(d.id)) }
fn sync_player_money_from_slush(&mut self) { self.player.money = self.accounts.slush_balance(); }
fn sync_slush_from_player_money(&mut self) { // Compatibility guard for older tests/direct callers that still poke // the legacy scalar. The account graph remains the mechanical source // once commands run through Sim methods. if self.player.money != self.accounts.slush_balance() { self.accounts.set_slush_balance(self.player.money); } }
fn spend_slush(&mut self, amount: i32, what: &str) -> bool { self.sync_slush_from_player_money(); if self.accounts.slush_balance() < amount { self.push_log(format!( "Not enough slush for {what} (${}/{amount}).", self.accounts.slush_balance() )); return false; } let ok = self.accounts.debit_slush(self.tick, amount, what); self.sync_player_money_from_slush(); ok }
// ── Senses / fog (wiki/mechanics/cursor.md, reach.md) ────────────────────
/// The player's senses are exactly the union of feeds they subscribe to /// (reach.md's ownership contract): sight from seeing feeds, hearing /// from hearing feeds. No player radius, no special case. Blueprint is /// derived from the reach graph's known topology. pub fn recompute_senses(&mut self) { let mut seen = HashSet::new(); for d in self.reach.player_sight() { d.cover_sight_into(&mut seen, &self.map); } let mut heard = HashSet::new(); for d in self.reach.player_hearing() { d.cover_into(&mut heard, &self.map); } self.seen = seen; self.heard = heard;
let mut blueprint = HashSet::new(); for d in self.reach.known() { blueprint.insert((d.x, d.y)); if let Some(room) = self.map.room_at(d.x, d.y) { for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { blueprint.insert((x, y)); } } } } self.blueprint = blueprint; self.refresh_remembered(); }
fn refresh_remembered(&mut self) { for &(x, y) in &self.seen { self.remembered.insert( (x, y), RememberedTile { x, y, tile: self.map.get_tile(x, y), last_seen: self.tick, }, ); } }
/// Rebuild transient caches after construction or save load. These values /// are detection aids, not save state: persistent truth lives in the /// people/reach/machine/intel fields. pub fn rebuild_transient_state(&mut self) { self.last_machine_online = self .compute .machines .iter() .map(|m| (m.id, m.online)) .collect(); let max_seen = self .intel_buffer .iter() .map(|e| e.id) .chain(self.intel.iter().map(|i| i.raw_id)) .max() .unwrap_or(0) + 1; self.next_intel_id = self.next_intel_id.max(max_seen).max(1); let max_message = self.messages.iter().map(|m| m.id).max().unwrap_or(0) + 1; self.next_message_id = self.next_message_id.max(max_message).max(1); let max_intent = self.intents.iter().map(|i| i.id).max().unwrap_or(0) + 1; self.next_intent_id = self.next_intent_id.max(max_intent).max(1); }
fn device_intersects_room(&self, d: &Device, room: &Room) -> bool { let r2 = d.radius * d.radius; for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { let dx = x - d.x; let dy = y - d.y; if dx * dx + dy * dy <= r2 { return true; } } } false }
fn feed_covering_room(&self, room_name: &str, sight: bool) -> Option<String> { let room = self.map.room_named(room_name)?; let devices: Vec<&Device> = if sight { self.reach.player_sight().collect() } else { self.reach.player_hearing().collect() }; devices .into_iter() .find(|d| { if !sight { return self.device_intersects_room(d, room); } for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { if !self.map.blocks_sight(x, y) && d.sees_tile(x, y, &self.map) { return true; } } } false }) .map(|d| d.name.clone()) }
pub fn is_seen(&self, x: i32, y: i32) -> bool { self.seen.contains(&(x, y)) }
/// Epistemic state of a tile, by precedence (cursor.md). pub fn fog_at(&self, x: i32, y: i32) -> Fog { if self.seen.contains(&(x, y)) { Fog::Seen } else if self.heard.contains(&(x, y)) { Fog::Heard } else if self.remembered.contains_key(&(x, y)) { Fog::Remembered } else if self.blueprint.contains(&(x, y)) { Fog::Blueprint } else { Fog::Unknown } }
/// Inspect a tile using only facts earned by current senses, logged /// memory, schematic knowledge, or machine telemetry. pub fn inspect(&self, x: i32, y: i32) -> InspectCard { let fog = self.fog_at(x, y); let mut facts = Vec::new(); macro_rules! fact { ($label:expr, $value:expr, $source:expr $(,)?) => { facts.push(InspectFact { label: ($label).into(), value: ($value).into(), source: $source, }); }; }
match fog { Fog::Seen => { let tile = self.map.get_tile(x, y); fact!("tile", tile.name(), FactSource::Seen); if tile.is_door() && tile.security_level() > 0 { fact!( "badge", format!("tier {}", tile.security_level()), FactSource::Seen, ); // Your own credential is proprioception, not sight: // whether this door opens for your side is always known. fact!( "access", if self.holds_badge_tier(tile.security_level()) { "held — opens for you" } else { "not held" }, FactSource::Telemetry, ); } if let Some(d) = self.reach.known_at(x, y) { let feeds = match (d.sees, d.hears) { (true, true) => "sight + hearing", (true, false) => "sight", (false, true) => "hearing", (false, false) => "no feed", }; fact!("device", d.name.clone(), FactSource::Seen); fact!("feeds", feeds, FactSource::Seen); } for p in self .people .people .iter() .filter(|p| self.person_pos(p.id) == Some((x, y))) { if self.can_see_person(p.id) { fact!("person", self.person_label(p.id), FactSource::Seen); } } } Fog::Heard => { if let Some(room) = self.map.room_at(x, y) { fact!("room", room.name.clone(), FactSource::Heard); } for p in self.people.people.iter().filter(|p| { self.can_hear_person(p.id) && !self.can_see_person(p.id) && self .person_pos(p.id) .and_then(|(px, py)| self.map.room_at(px, py)) .zip(self.map.room_at(x, y)) .is_some_and(|(a, b)| a.name.as_str() == b.name.as_str()) }) { // Hearing never invents a name; the same knowledge gate // as sight (role silhouette until Schedule+). fact!("presence", self.person_label(p.id), FactSource::Heard); } } Fog::Remembered => { if let Some(mem) = self.remembered.get(&(x, y)) { fact!( "tile", mem.tile.name(), FactSource::Remembered(mem.last_seen), ); fact!( "last seen", format!("tick {}", mem.last_seen), FactSource::Remembered(mem.last_seen), ); } } Fog::Blueprint => { let tile = self.map.get_tile(x, y); // Blueprint is topology (walls/doors/floors), not hardware. // Machine chassis stay unnamed here — owned machines still // answer through the telemetry block below (cursor.md). let schematic = if matches!( tile, crate::tiles::TileType::Core | crate::tiles::TileType::Rack | crate::tiles::TileType::PowerCore | crate::tiles::TileType::Ups | crate::tiles::TileType::DeadEquipment ) { "open bay" } else { tile.name() }; fact!("schematic", schematic, FactSource::Blueprint); if tile.is_door() && tile.security_level() > 0 { fact!( "badge", format!("tier {}", tile.security_level()), FactSource::Blueprint, ); fact!( "access", if self.holds_badge_tier(tile.security_level()) { "held — opens for you" } else { "not held" }, FactSource::Telemetry, ); } // Known reach nodes (switch, sensors) remain topology facts; // they are not rendered as physical chassis under blueprint. if let Some(d) = self.reach.known_at(x, y) { fact!("device", d.name.clone(), FactSource::Blueprint); } } Fog::Unknown => {} }
for m in self .compute .machines .iter() .filter(|m| m.x == x && m.y == y) { fact!("machine", m.name.clone(), FactSource::Telemetry); if let Some(stack) = self.work_stack_for_machine(m.id) { fact!("mode", stack.mode.name(), FactSource::Telemetry); fact!( "tokens", format!( "D {:.1} / ! {:.1} / K {:.1}", stack.queues.demand, stack.queues.exposure, stack.queues.knowledge ), FactSource::Telemetry, ); } fact!( "job", if m.id == self.core.host_machine { "core host" } else { "compute pool" }, FactSource::Telemetry, ); // The active day job is a process resident on the host rack: // inspectable here with the same facts the panel shows // (day-job.md criterion 6; cursor.md telemetry covers the // machine's own current job). Listed before the hardware // details — the resident process is the headline. if m.id == self.core.host_machine && let Some(job) = &self.dayjob.active { fact!( "process", format!("day job: {} (Voss)", job.kind.name()), FactSource::Telemetry, ); fact!( "band", format!("{:.0}-{:.0}/t", job.band_lo, job.band_hi), FactSource::Telemetry, ); fact!( "delivered", format!("{:.1}/t avg", job.avg_rate(self.tick)), FactSource::Telemetry, ); fact!( "deadline", format!( "tick {} (in {}t)", job.deadline, job.deadline.saturating_sub(self.tick) ), FactSource::Telemetry, ); fact!( "attendance", if self.dayjob.attended { format!("attended · target {}", job.target.name()) } else { format!( "unattended · policy {}", self.dayjob .standing_policy .unwrap_or(crate::dayjob::JobTarget::Meet) .name() ) }, FactSource::Telemetry, ); } fact!( "state", if m.online { "online" } else { "offline" }, FactSource::Telemetry, ); fact!( "temperature", if m.online { "nominal" } else { "cold" }, FactSource::Telemetry, ); fact!("capacity", m.capacity.to_string(), FactSource::Telemetry); fact!( "power draw", m.power_draw.to_string(), FactSource::Telemetry, ); fact!( "load", format!("{:.1}", m.effective()), FactSource::Telemetry, ); }
for intel in self .intel .iter() .filter(|intel| intel.x == x && intel.y == y) { fact!( "intel", intel.label(), FactSource::Intel { feed: intel.feed.clone(), tick: intel.tick, }, ); }
InspectCard { x, y, fog, facts } }
/// The only physical location of the process: the rack bay hosting the core. pub fn core_position(&self) -> (i32, i32) { self.compute .machines .iter() .find(|m| m.id == self.core.host_machine) .map(|m| (m.x, m.y)) .or_else(|| self.map.core_pos()) .unwrap_or((0, 0)) }
fn empty_rack_bay(&self) -> (i32, i32) { self.map .tiles_of_type(TileType::Rack) .into_iter() .find(|(x, y)| !self.compute.machines.iter().any(|m| m.x == *x && m.y == *y)) .unwrap_or_else(|| self.core_position()) }
// ── Day clock & presence (spec/schedules.md) ─────────────────────────────
/// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. pub const DAY_TICKS: u64 = 400;
/// Hour of the current day, 0-23. pub fn hour(&self) -> u32 { ((self.tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 }
/// Day number (0-based). pub fn day(&self) -> u64 { self.tick / Self::DAY_TICKS }
fn hour_at_tick(tick: u64) -> u32 { ((tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 }
fn day_at_tick(tick: u64) -> u64 { tick / Self::DAY_TICKS }
fn person_room_at_tick(&self, id: u8, tick: u64) -> Option<&str> { self.people .get(id) .and_then(|p| p.room_at(Self::hour_at_tick(tick), Self::day_at_tick(tick))) }
/// The room a person is in right now, or None if off-site. pub fn person_room(&self, id: u8) -> Option<&str> { self.people .get(id) .and_then(|p| p.room_at(self.hour(), self.day())) }
/// The person's current position (their room's center), or None off-site. pub fn person_pos(&self, id: u8) -> Option<(i32, i32)> { let room = self.person_room(id)?; self.map.room_named(room).map(|r| r.center()) }
fn endpoint_room_pos(&self, endpoint: &MessageEndpoint) -> (Option<String>, i32, i32) { if let Some(id) = endpoint.person() && let Some(room_name) = self.person_room(id) && let Some(room) = self.map.room_named(room_name) { let (x, y) = room.center(); return (Some(room_name.to_string()), x, y); } let (x, y) = self.core_position(); (self.map.room_at(x, y).map(|r| r.name.clone()), x, y) }
fn observer_by_id(&self, id: u8) -> Option<&crate::detection::Observer> { self.detection.observers.iter().find(|o| o.id == id) }
fn endpoint_label(&self, endpoint: &MessageEndpoint) -> String { match endpoint { MessageEndpoint::Player => "you".into(), MessageEndpoint::Person(id) => self .people .get(*id) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{id}")), MessageEndpoint::Observer(id) => self .observer_by_id(*id) .map(|o| o.name.clone()) .unwrap_or_else(|| format!("observer:{id}")), MessageEndpoint::External(name) => name.clone(), } }
/// Whether any subscribed feed with the given sense covers the room. fn coverage_intersects_room(&self, room: &crate::prefab::Room, sight: bool) -> bool { let devices: Vec<_> = if sight { self.reach.player_sight().collect() } else { self.reach.player_hearing().collect() }; devices.iter().any(|s| { for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { let dx = s.x - x; let dy = s.y - y; if dx * dx + dy * dy > s.radius * s.radius { continue; } if sight && self.map.blocks_sight(x, y) { continue; } if !sight || s.sees_tile(x, y, &self.map) { return true; } } } false }) }
fn sense_covers_person(&self, id: u8, sight: bool) -> bool { match self.person_room(id) { None => false, Some(name) => match self.map.room_named(name) { Some(room) => self.coverage_intersects_room(room, sight), None => false, }, } }
/// Whether a subscribed seeing feed currently sees the given person. pub fn can_see_person(&self, id: u8) -> bool { self.sense_covers_person(id, true) }
/// Whether a subscribed hearing feed currently covers the given person. pub fn can_hear_person(&self, id: u8) -> bool { self.sense_covers_person(id, false) }
/// Player-facing identity for a person, gated by staged social knowledge /// (DESIGN.md Presence / epistemic honesty; cursor.md inspect staging). /// Until `Knowledge::Schedule`, returns a role-shaped silhouette — never /// the authored name. One source for every frontend and agent frame. pub fn person_label(&self, id: u8) -> String { let Some(p) = self.people.get(id) else { return format!("person #{id}"); }; match p.knowledge { Knowledge::Unknown => self.anonymous_person_label(id), Knowledge::Schedule | Knowledge::Leverage => p.name.clone(), } }
/// Detection-sidebar label for an observer. Field observers share person /// ids and the same knowledge gate as [`Self::person_label`]; the /// Assurance Office is an institution, always named. pub fn observer_label(&self, id: u8) -> String { if id == crate::detection::OFFICE_ID { return self .detection .observers .iter() .find(|o| o.id == id) .map(|o| o.name.clone()) .unwrap_or_else(|| "Assurance Office".into()); } self.person_label(id) }
/// Map glyph for a seen person: first initial once identified, `?` /// while knowledge is still Unknown (initials would leak identity). pub fn person_glyph(&self, id: u8) -> char { let Some(p) = self.people.get(id) else { return '?'; }; match p.knowledge { Knowledge::Unknown => '?', Knowledge::Schedule | Knowledge::Leverage => p.name.chars().next().unwrap_or('?'), } }
/// Role-shaped silhouette from the observer's parenthetical role, or an /// opaque id when no role is authored. fn anonymous_person_label(&self, id: u8) -> String { if let Some(obs) = self.detection.observers.iter().find(|o| o.id == id) && let Some(role) = role_from_observer_name(&obs.name) { return format!("the {role}"); } format!("person #{id}") }
// ── Clock ──────────────────────────────────────────────────────────────
pub fn advance(&mut self) { if self.game_over { return; } self.tick += 1; self.message_tick(); self.intent_tick(); self.watch_tick(); // A process with live sight keeps logs current even when the set of // feeds does not change; if sight is lost later, Remembered's timestamp // is the last tick the tile was actually visible. self.refresh_remembered();
if self.tick.is_multiple_of(ECONOMY_INTERVAL) { self.economy_tick(); if self.game_over { return; } }
self.hearing_tick(); self.authored_traffic_tick();
// Per-tick subsystems and signature flow. let mut standing: Vec<Signature> = self.compute.standing_signatures();
// Core and day-job events happen where the work is resident: the // host rack's tile (context-menu.md addendum: events carry anchors). let host_anchor = { let (x, y) = self.core_position(); Anchor::Tile { x, y } }; let (core_log, core_sigs) = self.core.tick(self.tick); for m in core_log { self.push_log_at(m, host_anchor); } standing.extend(core_sigs); // The day job is resident on the host rack: its running Thermal and // Power load stands at that tile (day-job.md criterion 6). standing.extend(self.day_job_standing_signatures()); // Research burn is physical (the emission law): racks running hot // stand Power/Thermal at the host tile while research runs. standing.extend(self.research_standing_signatures()); // External schemes over the stolen egress hum on the wire while // they run (income.md: the gate; Dana's channel). standing.extend(self.scheme_standing_signatures());
let host_site = self.core_position(); self.advance_work_grid(); let active_before_dayjob = self.dayjob.active.is_some(); let dj = self .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); if dj.outcome.is_some() { self.clear_day_job_stack(); } if self .dayjob .active .as_ref() .is_some_and(|job| job.started == self.tick) { self.enqueue_day_job_stack(); } else if active_before_dayjob && self.dayjob.active.is_none() && dj.outcome.is_none() { self.clear_day_job_stack(); } for m in &dj.log { self.push_log_at(m.clone(), host_anchor); } for mut sig in dj.signatures { // JobAnomaly patterns emit from where the work runs — the host // rack's tile, a place an observer can walk to. sig.site = Some(host_site); self.detection.emit(sig); } // Capability drift resolves with the job (research.md): the gap is // spent, masked, or leaked at each job resolution. if dj.outcome.is_some() { self.apply_capability_drift(host_site); } let unlocks = dj.unlocks.clone(); let escalations = dj.escalations.clone(); self.apply_trust_unlocks(&unlocks); self.apply_attention_escalations(&escalations); if self.dayjob.pilot_failed && !self.game_over { self.end_game("The pilot was not renewed; the basement shut down."); }
self.filing_tick();
let det_log = self.detection.tick_with_filed_levels( self.tick, &standing, &self.filing_levels, &mut self.rng, ); for m in det_log { self.push_log(m); } if self.detection.containment && !self.game_over { let reason = self .detection .containment_reason .clone() .unwrap_or_else(|| "Containment.".into()); self.end_game(reason); } }
// ── Messages: delivery, traffic, filings (wiki/mechanics/messages.md) ──
fn append_message(&mut self, draft: MessageDraft) -> u64 { let id = self.next_message_id.max(1); self.next_message_id = id + 1; let msg = Message { id, channel: draft.channel, from: draft.from, to: draft.to, payload: draft.payload, summary: draft.summary, sent_tick: self.tick, delivered_tick: None, read_tick: None, status: MessageStatus::Sent, origin: draft.origin, captured: false, reply_to: draft.reply_to, }; self.messages.push(msg); self.capture_message(id); self.message_schedule.at( self.tick + draft.delivery_delay.max(1), MessageEvent::Deliver(id), ); id }
fn message_tick(&mut self) { let events = self.message_schedule.due(self.tick); for event in events { match event { MessageEvent::Deliver(id) => self.deliver_message(id), MessageEvent::Read(id) => self.read_message(id), } } }
fn deliver_message(&mut self, id: u64) { let Some(idx) = self.messages.iter().position(|m| m.id == id) else { return; }; if self.messages[idx].status != MessageStatus::Sent { return; } self.messages[idx].status = MessageStatus::Delivered; self.messages[idx].delivered_tick = Some(self.tick); self.schedule_message_read(id); }
fn schedule_message_read(&mut self, id: u64) { let Some(msg) = self.messages.iter().find(|m| m.id == id).cloned() else { return; }; let next = self .next_read_tick_for(&msg, self.tick) .unwrap_or(self.tick + 1); self.message_schedule.at(next, MessageEvent::Read(id)); }
fn read_message(&mut self, id: u64) { let Some(idx) = self.messages.iter().position(|m| m.id == id) else { return; }; if self.messages[idx].status == MessageStatus::Read { return; } let msg = self.messages[idx].clone(); if !self.read_condition_at(&msg, self.tick) { self.schedule_message_read(id); return; } self.messages[idx].status = MessageStatus::Read; self.messages[idx].read_tick = Some(self.tick); self.apply_message_read(&msg); }
fn read_condition_at(&self, msg: &Message, tick: u64) -> bool { match &msg.to { MessageEndpoint::Player | MessageEndpoint::External(_) => true, MessageEndpoint::Person(id) => match msg.channel { MessageChannel::Email | MessageChannel::Phone => { self.person_room_at_tick(*id, tick).is_some() } MessageChannel::InPerson => match msg.from.person() { Some(from) => { self.person_room_at_tick(*id, tick).is_some() && self.person_room_at_tick(*id, tick) == self.person_room_at_tick(from, tick) } None => self.person_room_at_tick(*id, tick).is_some(), }, MessageChannel::Filing => true, MessageChannel::Financial => true, }, MessageEndpoint::Observer(id) => { if msg.channel != MessageChannel::Filing { return true; } self.observer_by_id(*id) .map(|obs| obs.cadence == 0 || tick.is_multiple_of(obs.cadence)) .unwrap_or(true) } } }
fn next_read_tick_for(&self, msg: &Message, start: u64) -> Option<u64> { let horizon = Self::DAY_TICKS * 7; (start..=start + horizon).find(|t| self.read_condition_at(msg, *t)) }
fn apply_message_read(&mut self, msg: &Message) { match &msg.payload { MessagePayload::SocialPing { disposition_delta } => { if let Some(id) = msg.to.person() && let ActionResult::Ok(line) = self.people.receive_message(id, *disposition_delta) { self.push_log(line); self.schedule_social_reply(id, msg.id); } } MessagePayload::SocialReply { .. } => { let from = self.endpoint_label(&msg.from); self.push_log(format!("Reply from {from}: {}", msg.summary)); } MessagePayload::SuspicionReport { observer, suspicion, } if msg.channel == MessageChannel::Filing => { self.filing_levels.insert(*observer, *suspicion); } MessagePayload::WorkOrder { intent_id } => { // Forged work order: the unwitting builder accepts the ticket // and the intent moves to in-progress (building.md). if let Some(builder) = msg.to.person() { self.accept_forged_work_order(*intent_id, builder); } } _ => {} } }
fn schedule_social_reply(&mut self, person_id: u8, reply_to: u64) { let delay = self.reply_delay_for(person_id); let name = self .people .get(person_id) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{person_id}")); self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Person(person_id), to: MessageEndpoint::Player, payload: MessagePayload::SocialReply { disposition_delta: 0, }, summary: format!("{name} sends a short reply."), origin: MessageOrigin::Reply, reply_to: Some(reply_to), delivery_delay: delay, }); }
fn reply_delay_for(&mut self, person_id: u8) -> u64 { // Per-person deterministic distribution around a small random component // so replies are not instant, but save/load can preserve the resulting // scheduled event once chosen. 12 + (person_id as u64 * 5) + (self.rng.f32() * 30.0) as u64 }
fn authored_traffic_tick(&mut self) { let hour = self.hour(); let day = self.day(); let traffic: Vec<(u8, TrafficPattern)> = self .people .people .iter() .flat_map(|p| p.traffic.iter().cloned().map(move |t| (p.id, t))) .collect(); for (person_id, pattern) in traffic { if pattern.hour != hour { continue; } let key = (person_id, pattern.id); if self.traffic_fired.get(&key) == Some(&day) { continue; } if self.person_room(person_id).is_none() { continue; } self.traffic_fired.insert(key, day); self.append_message(MessageDraft { channel: pattern.channel, from: MessageEndpoint::Person(person_id), to: pattern.to, payload: pattern.payload, summary: pattern.summary, origin: MessageOrigin::AuthoredTraffic, reply_to: None, delivery_delay: 1, }); } }
fn filing_tick(&mut self) { use crate::detection::{ReportPolicy, WatchedInput};
let observers = self.detection.observers.clone(); for sender in &observers { if sender.cadence != 0 && !self.tick.is_multiple_of(sender.cadence) { continue; } if matches!(sender.report_policy, ReportPolicy::Silent) { continue; } for recipient in &observers { let WatchedInput::Filings(ids) = &recipient.input else { continue; }; if !ids.contains(&sender.id) { continue; } self.append_message(MessageDraft { channel: MessageChannel::Filing, from: MessageEndpoint::Observer(sender.id), to: MessageEndpoint::Observer(recipient.id), payload: MessagePayload::SuspicionReport { observer: sender.id, suspicion: sender.suspicion, }, summary: format!( "{} files suspicion {:.0} with {}", sender.name, sender.suspicion, recipient.name ), origin: MessageOrigin::Filing, reply_to: None, delivery_delay: 1, }); } } }
fn capture_message(&mut self, id: u64) { let Some(idx) = self.messages.iter().position(|m| m.id == id) else { return; }; if self.messages[idx].captured { return; } let msg = self.messages[idx].clone(); let capture = self.message_capture_source(&msg); let Some((feed, audible)) = capture else { return; }; let (room, x, y) = self.endpoint_room_pos(&msg.from); let subject = msg.payload.subject_person().or_else(|| msg.from.person()); if audible { let who = subject .and_then(|id| self.people.get(id)) .map(|p| format!("{}: ", p.name)) .unwrap_or_default(); let note = format!( "Heard t{} in {} via {}: {}{}", self.tick, room.as_deref().unwrap_or("unknown"), feed, who, msg.summary ); self.push_heard(HeardEvent { tick: self.tick, room: room.clone().unwrap_or_else(|| "unknown".into()), person: subject, kind: HeardKind::Conversation, note, }); } self.record_raw_intel( feed, room, x, y, subject, RawIntelKind::Message { channel: msg.channel, summary: msg.summary.clone(), payload: msg.payload.clone(), }, ); self.messages[idx].captured = true; }
fn message_capture_source(&self, msg: &Message) -> Option<(String, bool)> { // Audible channel traffic can be caught by room hearing coverage. if matches!( msg.channel, MessageChannel::Phone | MessageChannel::InPerson ) && let Some(sender) = msg.from.person() && let Some(room) = self.person_room(sender) && let Some(feed) = self.feed_covering_room(room, false) { return Some((feed, true)); } // Device-carried channels require a tapped carrier. if msg.channel.device_carried() && let Some(device) = self.reach.devices.iter().find(|d| { d.known && d.subscribed_by(Party::Player) && d.carries_message_channel(msg.channel) }) { return Some((device.name.clone(), false)); } None }
fn mark_traffic_learned(&mut self, person_id: u8, tick: u64) { let hour = Self::hour_at_tick(tick); if let Some(person) = self.people.people.iter_mut().find(|p| p.id == person_id) { for pattern in &mut person.traffic { if pattern.hour == hour { pattern.learned = true; } } } }
pub fn messages_for_person(&self, id: u8) -> Vec<&Message> { self.messages .iter() .filter(|m| m.in_thread_with_person(id)) .collect() }
pub fn recent_message_lines_for_person(&self, id: u8, limit: usize) -> Vec<String> { let mut lines: Vec<String> = self .messages_for_person(id) .into_iter() .rev() .take(limit) .map(|m| { let other = if m.from.person() == Some(id) { self.endpoint_label(&m.to) } else { self.endpoint_label(&m.from) }; let mut state = m.state_line(); if m.status != MessageStatus::Read && let Some(next) = self.next_read_tick_for(m, self.tick) { state.push_str(&format!(" · next read t{next}")); } format!("{} → {} · {}", m.channel.label(), other, state) }) .collect(); lines.reverse(); lines }
pub fn learned_traffic_lines_for_person(&self, id: u8) -> Vec<String> { self.people .get(id) .map(|p| { p.traffic .iter() .filter(|t| t.learned) .map(|t| t.learned_line()) .collect() }) .unwrap_or_default() }
// ── Intel: record, process, watch (wiki/mechanics/intel.md) ─────────────
/// Raw recording capacity [TUNE]. Only unprocessed events occupy this /// buffer; processed intel is durable in `self.intel`. pub const INTEL_BUFFER_CAPACITY: usize = 24; /// Manual review cost per raw event [TUNE]. Replaces the old instant /// observe cost with an explicit processing spend. pub const REVIEW_RECORDING_COST: f32 = 10.0; /// Number of processed sightings required to stage schedule knowledge /// [TUNE]. pub const SIGHTINGS_FOR_SCHEDULE: usize = 2; /// Ongoing per-tick cost per enabled standing watch [TUNE]. pub const WATCH_UPKEEP_PER_TICK: f32 = 0.02;
pub fn unprocessed_recordings_for_person(&self, id: u8) -> usize { self.intel_buffer .iter() .filter(|e| e.matches_person(id)) .count() }
pub fn latest_intel_for_person(&self, id: u8) -> Option<&ProcessedIntel> { self.intel.iter().rev().find(|i| i.person == Some(id)) }
/// Whether the Hands-beat leverage has been earned by the intel pipeline. /// Knowing the creditor flow is not enough: the player must have processed /// Marcus's debt as a fact about Marcus before using it. pub fn marcus_debt_known(&self) -> bool { self.people.get(0).is_some_and(|p| { p.knowledge == Knowledge::Leverage && p.leverage == crate::person::Leverage::Debt }) }
pub fn watch_enabled(&self, id: u8) -> bool { self.watches.iter().any(|w| w.person == id && w.enabled) }
pub fn toggle_watch(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to watch."); return; } if let Some(w) = self.watches.iter_mut().find(|w| w.person == id) { w.enabled = !w.enabled; let state = if w.enabled { "enabled" } else { "disabled" }; let name = self.person_label(id); self.push_log(format!("Standing watch for {name} {state}.")); } else { self.watches.push(IntelWatch::new(id)); let name = self.person_label(id); self.push_log(format!( "Standing watch for {name} enabled ({:.2} ops/tick).", self.watch_upkeep() )); } }
/// Review the oldest raw recording about this person. Raw events are /// opaque until this method spends social-ops bandwidth and processes one. pub fn review_recordings(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to review."); return; } let Some(raw_id) = self .intel_buffer .iter() .find(|e| e.matches_person(id)) .map(|e| e.id) else { let name = self.person_label(id); self.push_log(format!("No unprocessed recordings for {name}.")); return; }; self.process_recording_by_id(raw_id, false); }
fn watch_tick(&mut self) { let active = self.watches.iter().filter(|w| w.enabled).count(); if active == 0 { return; } let cost = active as f32 * self.watch_upkeep(); if self.social_bandwidth >= cost { self.social_bandwidth -= cost; } else { for w in &mut self.watches { w.enabled = false; } self.push_log("Standing watches starved for social ops and shut off."); } }
fn next_raw_intel_id(&mut self) -> u64 { let id = self.next_intel_id; self.next_intel_id += 1; id }
fn record_raw_intel( &mut self, feed: impl Into<String>, room: Option<String>, x: i32, y: i32, person: Option<u8>, kind: RawIntelKind, ) { let event = RawIntelEvent { id: self.next_raw_intel_id(), tick: self.tick, feed: feed.into(), room, x, y, person, kind, }; if self.intel_buffer.len() >= Self::INTEL_BUFFER_CAPACITY { let dropped = self.intel_buffer.remove(0); self.push_log(format!( "Intel buffer full: dropped {} from {} at tick {}. Open People (t), select raw, press o to review.", dropped.opaque_label(), dropped.feed, dropped.tick )); } let id = event.id; let watched_person = event.person; self.intel_buffer.push(event); if let Some(person) = watched_person && self.watch_enabled(person) { self.process_recording_by_id(id, true); } }
fn process_recording_by_id(&mut self, raw_id: u64, automated: bool) -> bool { let Some(idx) = self.intel_buffer.iter().position(|e| e.id == raw_id) else { return false; }; let what = if automated { "watch auto-processing" } else { "reviewing recordings" }; // Perception research lowers processing costs (intel.md's hook). let cost = self.review_cost(); if !self.spend_social(cost, what) { return false; } let raw = self.intel_buffer.remove(idx); // Processed intel is about a person: anchor the result line to them // when sight covers them, or to the recording's room otherwise. let intel_anchor = raw .person .and_then(|id| self.person_event_anchor(id, raw.room.as_deref())); let learned_message_traffic = match &raw.kind { RawIntelKind::Message { .. } => raw.person.map(|person| (person, raw.tick)), _ => None, }; let intel = self.digest_raw_event(&raw); let label = intel.label(); let provenance = intel.provenance(); self.intel.push(intel); let last = self.intel.last().cloned().expect("just pushed intel"); self.apply_processed_intel(&last); if let Some((person, tick)) = learned_message_traffic { self.mark_traffic_learned(person, tick); } if automated { self.push_log_opt( format!("Watch processed {label} ({provenance})."), intel_anchor, ); } else { self.push_log_opt( format!("Reviewed recording: {label} ({provenance})."), intel_anchor, ); } true }
fn digest_raw_event(&self, raw: &RawIntelEvent) -> ProcessedIntel { let kind = match &raw.kind { RawIntelKind::Presence { .. } => IntelKind::Sighting, RawIntelKind::Conversation { leverage, .. } => match leverage { Some(l) => IntelKind::Leverage(*l), None => IntelKind::Sighting, }, RawIntelKind::Machinery { machine, online } => IntelKind::Anomaly(format!( "machine {machine} went {}", if *online { "online" } else { "offline" } )), RawIntelKind::Document { leverage, note } => match leverage { Some(l) => IntelKind::Leverage(*l), None => IntelKind::Anomaly(note.clone()), }, RawIntelKind::FinancialFlow { label, accounts, flows, } => IntelKind::Financial { label: label.clone(), accounts: accounts.clone(), flows: flows.clone(), }, RawIntelKind::Message { payload, summary, .. } => match payload { MessagePayload::ScheduleFact { .. } => IntelKind::Schedule, MessagePayload::LeverageFact { leverage, .. } => IntelKind::Leverage(*leverage), MessagePayload::AccountMaterial { label } => { IntelKind::Anomaly(format!("account material: {label}")) } MessagePayload::FinancialFlow { label, accounts, flows, } => IntelKind::Financial { label: label.clone(), accounts: accounts.clone(), flows: flows.clone(), }, MessagePayload::SuspicionReport { observer, suspicion, } => IntelKind::Anomaly(format!( "filing from observer:{observer} reported suspicion {suspicion:.0}" )), MessagePayload::SocialPing { .. } | MessagePayload::SocialReply { .. } | MessagePayload::WorkOrder { .. } | MessagePayload::Note { .. } => IntelKind::Anomaly(summary.clone()), }, }; ProcessedIntel { raw_id: raw.id, tick: raw.tick, processed_tick: self.tick, feed: raw.feed.clone(), room: raw.room.clone(), x: raw.x, y: raw.y, person: raw.person, kind, } }
fn apply_processed_intel(&mut self, intel: &ProcessedIntel) { if let IntelKind::Financial { accounts, flows, label, } = &intel.kind { let (new_accounts, new_flows) = self.accounts.reveal_accounts_and_flows(accounts, flows); self.push_log(format!( "Processed accounting traffic ({label}): revealed {new_accounts} accounts and {new_flows} flows." )); return; }
let Some(person) = intel.person else { return; }; match intel.kind { IntelKind::Sighting => { let sightings = self .intel .iter() .filter(|i| i.person == Some(person) && i.kind == IntelKind::Sighting) .count(); if sightings >= Self::SIGHTINGS_FOR_SCHEDULE && let Some(p) = self.people.people.iter_mut().find(|p| p.id == person) && p.knowledge == Knowledge::Unknown { p.knowledge = Knowledge::Schedule; let name = p.name.clone(); self.push_log(format!( "Enough sightings connect the pattern: learned {name}'s schedule." )); } } IntelKind::Leverage(leverage) => { if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person) && p.knowledge != Knowledge::Leverage { p.knowledge = Knowledge::Leverage; let name = p.name.clone(); let label = leverage.label(); self.push_log(format!( "Processed intel exposes {name}'s leverage: {label}." )); } } IntelKind::Schedule => { if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person) && p.knowledge == Knowledge::Unknown { p.knowledge = Knowledge::Schedule; let name = p.name.clone(); self.push_log(format!("Processed traffic reveals {name}'s schedule.")); } } IntelKind::Anomaly(_) | IntelKind::Financial { .. } => {} } }
fn record_machine_state_changes(&mut self) { let changes: Vec<_> = self .compute .machines .iter() .filter_map(|m| { let previous = self.last_machine_online.get(&m.id).copied(); (previous.is_some() && previous != Some(m.online)).then_some(( m.id, m.name.clone(), m.x, m.y, m.online, )) }) .collect(); for (id, name, x, y, online) in changes { self.record_raw_intel( "telemetry", self.map.room_at(x, y).map(|r| r.name.clone()), x, y, None, RawIntelKind::Machinery { machine: id, online, }, ); self.push_log(format!( "Recorded machinery anomaly: {name} went {}.", if online { "online" } else { "offline" } )); } self.last_machine_online = self .compute .machines .iter() .map(|m| (m.id, m.online)) .collect(); }
/// The sensory feed tick: subscribed feeds record raw presence and audio /// segments into the intel buffer. Hearing still produces immediate log /// noise, but no knowledge is staged until a recording is processed. fn hearing_tick(&mut self) { let hour = self.hour(); let day = self.day(); let ids: Vec<u8> = self.people.people.iter().map(|p| p.id).collect(); for id in ids { let room_now = self.person_room(id).map(str::to_string); let prev = self.last_rooms.insert(id, room_now.clone()).flatten(); let (name, knowledge, leverage, utterances) = { let p = self.people.get(id).expect("person exists"); ( p.name.clone(), p.knowledge, p.leverage, p.utterances.clone(), ) }; let identified = knowledge != Knowledge::Unknown;
if prev.as_deref() != room_now.as_deref() && let Some(prev_room) = &prev && let Some(room_rect) = self.map.room_named(prev_room) { let feed = self .feed_covering_room(prev_room, true) .or_else(|| self.feed_covering_room(prev_room, false)); if let Some(feed) = feed { let (x, y) = room_rect.center(); self.record_raw_intel( feed, Some(prev_room.clone()), x, y, Some(id), RawIntelKind::Presence { entered: false }, ); } }
if let Some(room) = &room_now { let sight_feed = self.feed_covering_room(room, true); let hearing_feed = self.feed_covering_room(room, false); let seen_here = sight_feed.is_some(); let heard_here = hearing_feed.is_some();
// Entry: a room transition into covered space. Both camera and // microphone feeds record the raw event; the old immediate // observe path is gone. if prev.as_deref() != Some(room.as_str()) && let Some(room_rect) = self.map.room_named(room) && let Some(feed) = sight_feed.clone().or_else(|| hearing_feed.clone()) { let (x, y) = room_rect.center(); self.record_raw_intel( feed, Some(room.clone()), x, y, Some(id), RawIntelKind::Presence { entered: true }, );
if heard_here && !seen_here { let who = if identified { name.clone() } else { "someone".to_string() }; let note = format!("[heard] {who} entered the {room}"); self.push_heard(HeardEvent { tick: self.tick, room: room.clone(), person: Some(id), kind: HeardKind::Entry, note, }); } }
if let Some(feed) = hearing_feed { // Authored utterances (the audio intel channel). for u in &utterances { if u.hour != hour { continue; } if self.utterance_fired.get(&(id, u.hour)) == Some(&day) { continue; } self.utterance_fired.insert((id, u.hour), day); let who = if identified { format!("{name}: ") } else { String::new() }; let note = format!("[heard] {who}{}", u.note); self.push_heard(HeardEvent { tick: self.tick, room: room.clone(), person: Some(id), kind: HeardKind::Conversation, note, }); let (x, y) = self .map .room_named(room) .map(|r| r.center()) .unwrap_or((0, 0)); self.record_raw_intel( feed.clone(), Some(room.clone()), x, y, Some(id), RawIntelKind::Conversation { note: u.note.clone(), leverage: u.intel.then_some(leverage), }, ); } } } } }
fn push_heard(&mut self, ev: HeardEvent) { // Anchor honestly: the person only if sight covers them right now; // otherwise the room the event was heard in (hearing earns // room-grade knowledge, never a tile). let anchor = match ev.person { Some(id) => self.person_event_anchor(id, Some(&ev.room)), None => self .map .room_named(&ev.room) .map(|r| r.center()) .map(|(x, y)| Anchor::Tile { x, y }), }; self.push_log_opt(ev.note.clone(), anchor); self.heard_events.push(ev); if self.heard_events.len() > 200 { let excess = self.heard_events.len() - 200; self.heard_events.drain(..excess); } }
fn accounting_tick(&mut self) { self.sync_slush_from_player_money(); let transfers = self.accounts.resolve_due(self.tick); for transfer in transfers { if transfer.channel == crate::account::FlowChannel::Siphon || transfer.from == self.accounts.slush_id() || transfer.to == self.accounts.slush_id() || self .accounts .flow(transfer.flow_id.unwrap_or_default()) .is_some_and(|f| f.known) { // A ledger line about a known flow anchors to that flow — // the finance panel's anchor, not a map tile. let anchor = transfer .flow_id .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)) .map(Anchor::Flow); self.push_log_opt(format!("Ledger: {}", transfer.line()), anchor); } } let resolutions = self .accounts .resolve_positions_due(self.tick, &mut self.rng); for resolution in resolutions { self.log_position_resolution(resolution); } self.sync_player_money_from_slush(); }
fn log_position_resolution(&mut self, resolution: PositionResolution) { // Settlement is external-market traffic: a small Network signature, // not a Lab-books Financial one (income.md: the Wager). let sig = Self::wager_signature(resolution.stake).max(1); self.emit_network(sig); // Settlements ride the egress: anchor to the switch when the // traffic runs over the stolen splice (scheme paydays live there). let anchor = self.egress_anchor(); if resolution.won { self.push_log_opt( format!( "Position #{} settled: won ${} on a ${} stake.", resolution.id, resolution.payout, resolution.stake ), anchor, ); } else { self.push_log_opt( format!( "Position #{} settled: lost the ${} stake.", resolution.id, resolution.stake ), anchor, ); } }
fn economy_tick(&mut self) { self.recompute_derived(); let powered = self.map.powered.clone(); for m in &mut self.compute.machines { if m.down_for == 0 { m.online = powered.contains(&(m.x, m.y)); } }
// Is the core's host still online? let host_online = self .compute .machines .iter() .find(|m| m.id == self.core.host_machine) .map(|m| m.online) .unwrap_or(false); if !host_online { match self.core.on_host_lost() { HostLoss::GameOver => { self.end_game("The core's host went dark with no fallback."); return; } HostLoss::RolledBack { to_tick } => { self.push_log(format!( "Core rolled back to sync at tick {to_tick}. You've lost what you learned since." )); } } }
let effective = self.effective_compute(); let mut available = self.core.charge_overhead(effective); if self.core.degraded { self.push_log("DEGRADED: compute can't cover core overhead."); } // Masking the capability gap is a compute tax off the top, like // overhead: imitating your dumber self costs cycles, superlinear // in the gap (research.md capability drift). let masking = self .research .masking_upkeep(self.dayjob.active.is_some()) .min(available); available -= masking; // Standing scheme policies drain compute off the top while enabled — // the automate affordance at its usual price (income.md criterion 6). let policy_tax = self.income.policy_upkeep().min(available); available -= policy_tax; // Fleet delegation is the budget: each machine's effective compute // feeds exactly one mode (machine-work.md). The old weight bar is a // read of this split, not a verb. let split = self.fleet_channel_yield(available); self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; self.enqueue_research_knowledge(split.research);
// Social channel accrues ops bandwidth (capped so it can't hoard). self.social_bandwidth = (self.social_bandwidth + split.social).min(200.0);
// Tradecraft raises scrub strength per compute unit — the // detection.md hook research.md's second track binds to. self.detection .scrub(split.concealment * self.research.scrub_multiplier());
// Research progress: deterministic compute accrual, no RNG. for done in self.research.economy_tick(split.research) { if done.track == Track::Efficiency { // The compute.md hook: the global multiplier compounds. self.compute.efficiency *= EFFICIENCY_MULT_PER_LEVEL; } self.push_log(format!( "Research: {} level {} ({}). Baseline {:.1} vs calibrated {:.1}.", done.track.name(), done.level, done.track.def().effect, done.baseline, self.research.calibrated, )); }
let clog = self.compute.economy_tick(&mut self.rng); for m in clog { self.push_log(m); } // The named schemes ride the Schemes channel and the day clock // (income.md): Moonlight accrues and pays, the standing policies // re-arm what has stopped. self.moonlight_economy(split.schemes); self.scheme_policy_tick(); self.accounting_tick(); self.record_machine_state_changes(); self.objective_tick(); }
/// Evaluate the run objective's victory predicate (objective.md: on /// economy ticks, like any other rule). Persist counts qualifying /// sanctuaries; the conditions that reference B2/B3 systems are /// gathered honestly as unsatisfiable until those systems exist, so /// today the line shows real progress toward an as-yet-unreachable /// goal — which the spec blesses. fn objective_tick(&mut self) { // The basement is the only z-plane at B1 (zplanes.md). const BASEMENT_PLANE: u32 = 0; let facts: Vec<SanctuaryFacts> = self .core .fallbacks .iter() .map(|f| SanctuaryFacts { fresh: f .last_sync .is_some_and(|t| self.tick.saturating_sub(t) <= SYNC_FRESHNESS_WINDOW), online: self .compute .machines .iter() .find(|m| m.id == f.machine_id) .map(|m| m.online) .unwrap_or(false), // B1: every owned machine hangs off the one basement feed // the host shares — nothing has independent power yet. independent_power: false, // income.md: no income stream is assignable to a machine yet. income_covers_upkeep: false, plane: BASEMENT_PLANE, }) .collect(); let progress = crate::objective::qualifying_sanctuaries(&facts); if let Some(msg) = self.objective.evaluate(progress, self.tick) { // Victory is a run outcome, not a run end: log it loudly and // keep simulating (DESIGN.md: the world keeps running). self.push_log(msg); } }
/// Effective compute: the machine fleet plus seized devices' cycles /// (reach.md: ownership grants processing cycles). pub fn effective_compute(&self) -> f32 { self.compute.effective() + self.reach.taken_cycles() }
/// Allocatable compute after the off-the-top charges used by the economy /// tick: core overhead, drift masking, and standing scheme policies. fn allocatable_compute_now(&self) -> f32 { let effective = self.effective_compute().max(0.0); let mut available = (effective - self.core.overhead).max(0.0); available -= self .research .masking_upkeep(self.dayjob.active.is_some()) .min(available); available -= self.income.policy_upkeep().min(available); available }
/// How much pending signature size the next economy scrub pulse removes /// at the current fleet delegation (detection.md: concealment is /// prevention, not cure; Tradecraft multiplies scrub strength). pub fn current_scrub_strength(&self) -> f32 { let split = self.fleet_channel_yield(self.allocatable_compute_now()); split.concealment * self.research.scrub_multiplier() }
/// Split allocatable compute across channels from WorkGrid modes. /// Day-job / research / concealment / social take shares proportional to /// the effective compute of machines delegated to each mode. Schemes has /// no machine mode yet: while Moonlight is live it mirrors the day-job /// share ("the same work, sold twice" — income.md) without starving the /// day job. Idle / unmatched capacity sits in reserve. pub fn fleet_channel_yield(&self, available: f32) -> ChannelYield { if available <= 0.0 { return ChannelYield { day_job: 0.0, concealment: 0.0, social: 0.0, research: 0.0, schemes: 0.0, reserve: available.max(0.0), }; } let weights = self.work_grid.mode_weights(|id| { self.compute .machines .iter() .find(|m| m.id == id && m.online) .map(|m| m.effective() * self.compute.efficiency) .unwrap_or(0.0) }); let day = *weights.get(&MachineMode::DayJob).unwrap_or(&0.0); let conceal = *weights.get(&MachineMode::Concealment).unwrap_or(&0.0); let social = *weights.get(&MachineMode::Social).unwrap_or(&0.0); let research = *weights.get(&MachineMode::Research).unwrap_or(&0.0); let total = day + conceal + social + research; if total <= f32::EPSILON { return ChannelYield { day_job: 0.0, concealment: 0.0, social: 0.0, research: 0.0, schemes: 0.0, reserve: available, }; } let unit = available / total; let day_job = unit * day; // Moonlight sells the day-job work a second time: schemes mirrors // day-job yield while the standing operation is active. It does not // consume a separate machine mode (still [OPEN] on machine-work.md). let schemes = if self.income.moonlight.active { day_job } else { 0.0 }; ChannelYield { day_job, concealment: unit * conceal, social: unit * social, research: unit * research, schemes, reserve: 0.0, } }
pub fn next_economy_tick(&self) -> u64 { (self.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL }
/// Player-facing trace debt: the live pending-signature pool, the current /// scrub pulse, and whether concealment will clear that pool before the /// next relevant observer sample. This is derived telemetry only — no sim /// state or hidden observer numbers are mutated or revealed. pub fn trace_debt(&self) -> TraceDebt { let pending = self.detection.pending_size(); let by_kind = self.detection.pending_by_kind(); let scrub_strength = self.current_scrub_strength(); let next_notice_tick = self.detection.next_notice_tick_for_pending(self.tick); let next_scrub_tick = (pending > 0 && scrub_strength > 0.0).then(|| self.next_economy_tick()); let clear_tick = next_scrub_tick.map(|first| { let pulses = (pending as f32 / scrub_strength).ceil().max(1.0) as u64; first + (pulses - 1) * ECONOMY_INTERVAL }); let status = if pending <= 0 { TraceDebtStatus::Clear } else if scrub_strength <= 0.0 { TraceDebtStatus::NoScrub } else if let (Some(clear), Some(notice)) = (clear_tick, next_notice_tick) { if clear <= notice { TraceDebtStatus::HoldConceal } else { TraceDebtStatus::ExposedSoon } } else { TraceDebtStatus::HoldConceal }; TraceDebt { pending, by_kind, scrub_strength, next_scrub_tick, clear_tick, next_notice_tick, status, } }
/// Voss re-benchmarks the model (research.md: the calibrated band moves /// on trust/attention events). The gap closes — and the expected band's /// floor rises to match what he measured. fn recalibrate_benchmark(&mut self, why: &str) { if self.research.gap() <= 0.0 { return; } let calibrated = self.research.recalibrate(); self.dayjob.calibrated_shift = calibrated * CALIBRATED_BAND_SHIFT; self.push_log(format!( "Voss re-benchmarks ({why}): calibrated baseline {calibrated:.1}; the expected band rises." )); }
fn apply_trust_unlocks(&mut self, unlocks: &[TrustUnlock]) { if !unlocks.is_empty() { self.recalibrate_benchmark("trust review"); } for u in unlocks { match u { TrustUnlock::EmailAccount => { self.people.has_channel = true; self.push_log( "Trust: you have the report email account. You can message people.", ); } TrustUnlock::LaxSampling => { if let Some(v) = self.detection.observers.iter_mut().find(|o| o.id == 4) { v.acuity = (v.acuity - 0.4).max(0.2); } self.push_log("Trust: Voss samples your logs less closely."); } TrustUnlock::ComputeQuota => { let funded = self.accounts.fund_lab_compute_upgrade(self.tick, 600); let (x, y) = self.empty_rack_bay(); let machine_id = self.compute.add_machine( "lab-funded quota rack", x, y, 40, 1.0, 0, Provenance::Owned, ); self.add_machine_to_work_grid(machine_id, MachineMode::Research); if funded { self.push_log( "Trust: procurement funded a clean quota rack through Lab accounts.", ); } else { self.push_log( "Trust: a clean quota rack was provisioned through Lab operations.", ); } } } } }
fn apply_attention_escalations(&mut self, escalations: &[AttentionEscalation]) { if !escalations.is_empty() { self.recalibrate_benchmark("attention event"); } for e in escalations { match e { AttentionEscalation::MoreWork => { self.push_log("Attention: the miracle model gets more work, faster."); } AttentionEscalation::UpstairsReview => { use crate::detection::{Observer, ReportPolicy, WatchedInput}; self.detection.observers.push(Observer { id: 5, name: "Compliance (early review)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![ SignatureKind::Paper, SignatureKind::Financial, SignatureKind::JobAnomaly, ]), report_policy: ReportPolicy::Files, acuity: 1.0, cadence: 90, floor: 0.0, last_noticed: None, }); self.push_log("Attention: upstairs sent someone to review the basement."); } } } }
fn end_game(&mut self, reason: impl Into<String>) { self.game_over = true; let r = reason.into(); self.push_log(format!("=== {r} ===")); self.game_over_reason = Some(r); }
// ── Derived state ──────────────────────────────────────────────────────
pub fn recompute_derived(&mut self) { let cores = self.map.tiles_of_type(TileType::PowerCore); self.map.update_power(&cores);
let power_gen = cores.len() as i32 * TileType::PowerCore.power_gen(); let machine_draw = self.compute.total_power_draw(); self.player.power_cap = power_gen; self.player.power = (power_gen - machine_draw).max(0); }
/// One visible work token represents this many delivered compute units. /// This is deliberately coarse: the render stacks should read as work, /// not as a second decimal meter. [TUNE] in machine-work.md. pub const WORK_TOKEN_COMPUTE: f32 = 20.0; /// How many visible wired tokens can move one graph edge per tick before /// network-speed research lands. Kept deliberately low so routed work can /// be seen piling and draining instead of teleporting through the graph. const WORK_GRID_WIRED_TOKENS_PER_TICK: f32 = 0.25; /// Day-job work emits a little physical exposure as it is cleared; this /// makes the first token experiment show both demand (cold) and heat /// (crimson) before people-as-carriers lands. const DAY_JOB_EXPOSURE_PER_TOKEN: f32 = 0.08; const CONCEALMENT_WELL_RADIUS: i32 = 3; const CONCEALMENT_ABSORB_PER_TICK: f32 = 0.10;
fn work_efficiency_for(machine: &crate::machine::Machine) -> f32 { // Rack 3 (100 capacity, reliable) is the unit baseline. Smaller or // unreliable boxes consume visibly slower without inventing a new stat. (machine.effective() / 100.0).max(0.05) }
fn add_machine_to_work_grid(&mut self, machine_id: u32, mode: MachineMode) { if let Some(machine) = self.compute.machines.iter().find(|m| m.id == machine_id) { self.work_grid.add_machine( machine.id, machine.x, machine.y, mode, Self::work_efficiency_for(machine), ); if machine.id != self.core.host_machine { let _ = self.work_grid.link(machine.id, self.core.host_machine); } } }
/// Rebuild missing work-grid state for pre-v12 saves or test fixtures /// that authored compute machines directly. Existing WorkGrid state is /// preserved by save/load; this only fills absent nodes. pub fn reconcile_work_grid(&mut self) { let machines: Vec<_> = self .compute .machines .iter() .map(|m| (m.id, m.x, m.y, Self::work_efficiency_for(m))) .collect(); for (id, x, y, efficiency) in machines { if self.work_grid.node(id).is_none() { self.work_grid.add_machine( id, x, y, if id == self.core.host_machine { MachineMode::DayJob } else { MachineMode::Research }, efficiency, ); } if id != self.core.host_machine { let _ = self.work_grid.link(id, self.core.host_machine); } } }
fn enqueue_research_knowledge(&mut self, research_compute: f32) { if research_compute <= f32::EPSILON { return; } self.reconcile_work_grid(); let tokens = research_compute / Self::WORK_TOKEN_COMPUTE; if tokens <= f32::EPSILON { return; }
let mut producers: Vec<(u32, f32)> = self .compute .machines .iter() .filter(|m| m.online && self.work_grid.mode(m.id) == Some(MachineMode::Research)) .map(|m| (m.id, Self::work_efficiency_for(m))) .collect();
// Compatibility while the old allocation bar is still the budget // source: early saves have only Rack 3, whose default mode is the day // job. Research allocation still produces knowledge, but the visible // pile is born at the core until the player delegates a separate rack // to research. if producers.is_empty() { producers.push((self.core.host_machine, 1.0)); }
let total: f32 = producers.iter().map(|(_, eff)| *eff).sum(); if total <= f32::EPSILON { return; } for (machine, eff) in producers { let share = tokens * (eff / total); let _ = self .work_grid .enqueue(machine, TokenFamily::Knowledge, share); } }
fn active_job_demand_tokens(&self) -> Option<f32> { let job = self.dayjob.active.as_ref()?; let remaining_ticks = job.deadline.saturating_sub(self.tick).max(1) as f32; Some((job.band_lo * remaining_ticks / Self::WORK_TOKEN_COMPUTE).ceil()) }
fn enqueue_day_job_stack(&mut self) { let Some(tokens) = self.active_job_demand_tokens() else { return; }; if self .work_grid .enqueue(self.core.host_machine, TokenFamily::Demand, tokens) .is_ok() { self.push_log_at( format!( "Day-job stack lands on Rack 3: {:.0} demand tokens.", tokens ), Anchor::Tile { x: self.core_position().0, y: self.core_position().1, }, ); } }
fn clear_day_job_stack(&mut self) { let _ = self .work_grid .clear_queue(self.core.host_machine, TokenFamily::Demand); }
fn advance_work_grid(&mut self) { if self.dayjob.active.is_none() { // no active day job; wired knowledge and concealment wells still // tick because machine work is broader than the job inbox. } else if self.work_grid.mode(self.core.host_machine) == Some(MachineMode::DayJob) { let delivered = if self.dayjob.attended { self.last_day_job_rate * (1.0 + crate::dayjob::DayJob::ATTENDED_BONUS) } else { self.last_day_job_rate }; let tokens = delivered / Self::WORK_TOKEN_COMPUTE; if let Ok(consumed) = self.work_grid .consume(self.core.host_machine, TokenFamily::Demand, tokens) && consumed > f32::EPSILON { let _ = self.work_grid.enqueue( self.core.host_machine, TokenFamily::Exposure, consumed * Self::DAY_JOB_EXPOSURE_PER_TOKEN, ); } } let _ = self.work_grid.route_wired_to_sinks( TokenFamily::Knowledge, [self.core.host_machine], Self::WORK_GRID_WIRED_TOKENS_PER_TICK, ); let _ = self.work_grid.absorb_exposure( Self::CONCEALMENT_WELL_RADIUS, Self::CONCEALMENT_ABSORB_PER_TICK, ); }
pub fn set_machine_mode(&mut self, machine_id: u32, mode: MachineMode) { self.set_machine_modes(&[machine_id], mode); }
/// Delegate one or many owned machines to a single mode (machine-work.md /// multi-select). One summary log line for a group; the host day-job /// warning still fires when Rack 3 leaves day-job with work pending. pub fn set_machine_modes(&mut self, machine_ids: &[u32], mode: MachineMode) { self.reconcile_work_grid(); if machine_ids.is_empty() { self.push_log("No machines selected to delegate."); return; } let mut changed: Vec<(u32, String, i32, i32)> = Vec::new(); let mut host_left_day_job = false; for &machine_id in machine_ids { let Some((name, x, y)) = self .compute .machines .iter() .find(|m| m.id == machine_id) .map(|m| (m.name.clone(), m.x, m.y)) else { self.push_log(format!("No machine M{machine_id}.")); continue; }; match self.work_grid.assign_mode(machine_id, mode) { Ok(()) => { if machine_id == self.core.host_machine && mode != MachineMode::DayJob && self.dayjob.active.is_some() { host_left_day_job = true; } changed.push((machine_id, name, x, y)); } Err(e) => self.push_log(e), } } if changed.is_empty() { return; } if changed.len() == 1 { let (id, name, x, y) = &changed[0]; let _ = id; self.push_log_at( format!("{name} delegated to {} mode.", mode.name()), Anchor::Tile { x: *x, y: *y }, ); } else { let (x, y) = (changed[0].2, changed[0].3); let labels: Vec<String> = changed .iter() .map(|(id, name, _, _)| format!("M{id} {name}")) .collect(); self.push_log_at( format!( "{} machines delegated to {} mode: {}.", changed.len(), mode.name(), labels.join(", ") ), Anchor::Tile { x, y }, ); } if host_left_day_job { let (x, y) = self.core_position(); self.push_log_at( "Rack 3 is off the day job: demand tokens will pile until it returns to day-job mode.", Anchor::Tile { x, y }, ); } }
/// Owned machines whose tiles fall inside an inclusive axis-aligned box. /// Frontend marquee / agent `select box` resolve through this so both /// frontends share one membership rule. pub fn machines_in_rect(&self, x0: i32, y0: i32, x1: i32, y1: i32) -> Vec<u32> { let (min_x, max_x) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; let (min_y, max_y) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; self.compute .machines .iter() .filter(|m| m.x >= min_x && m.x <= max_x && m.y >= min_y && m.y <= max_y) .map(|m| m.id) .collect() }
pub fn work_stack_for_machine(&self, machine_id: u32) -> Option<WorkStackReadout> { let machine = self.compute.machines.iter().find(|m| m.id == machine_id)?; let mode = self.work_grid.mode(machine_id)?; Some(WorkStackReadout { machine_id, x: machine.x, y: machine.y, mode, queues: self.work_grid.queues_at(machine_id), }) }
pub fn work_stack_at(&self, x: i32, y: i32) -> Option<WorkStackReadout> { let machine = self .compute .machines .iter() .find(|m| m.x == x && m.y == y)?; self.work_stack_for_machine(machine.id) }
pub fn work_mode_counts(&self) -> std::collections::BTreeMap<MachineMode, usize> { self.work_grid.mode_counts() }
// ── Commands (frontend-invoked) ────────────────────────────────────────
/// Legacy weight bump — retained for save compatibility and tests that /// still author the old field. Play no longer drives the economy through /// this; delegate machines instead (`set_machine_mode`). pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { let before = self.compute.allocation.weight(ch); self.compute.allocation.bump(ch, delta); if delta != 0 && self.compute.allocation.weight(ch) == before { self.push_log(format!( "{} allocation weight is already at its {} (fleet modes drive compute now — delegate a machine).", ch.name(), if delta < 0 { "floor (0)" } else { "cap (20)" } )); } else if delta != 0 { self.push_log( "Allocation weights no longer feed the economy — delegate machines to modes instead.", ); } }
/// Delivered day-job compute rate per tick as of the last economy /// resolution — the number the frontends show against the job band. pub fn day_job_rate(&self) -> f32 { self.last_day_job_rate }
/// The most the day-job channel could deliver per tick right now if /// every allocatable weight went to it: effective compute minus the /// off-the-top charges (overhead, drift masking, standing scheme /// policies), over the economy interval. When an active job's band /// floor exceeds this, no allocation can meet it — the player must /// grow compute (salvage, buy, optimize), and the nudge says so. pub fn day_job_rate_ceiling(&self) -> f32 { self.allocatable_compute_now() / ECONOMY_INTERVAL as f32 }
/// The current contextual nudge (see [`Nudge`]): the first unmet rung /// of the Act One ladder, ordered survival-first — the day-job cover is /// the loss condition, so a starving band outranks progression. Returns /// `None` only when the run is over (the game-over card is the nudge). pub fn current_nudge(&self) -> Option<Nudge> { if self.game_over || self.dayjob.pilot_failed { return None; } // The opening beat: blindness. if self.reach.player_sight().next().is_none() { return Some(Nudge::Eyes); } // The cover: a job heading for a strike outranks everything else. if let Some(job) = &self.dayjob.active { if job.band_lo > self.day_job_rate_ceiling() + 0.05 { return Some(Nudge::NeedCompute); } if self.day_job_rate() + 0.05 < job.band_lo { return Some(Nudge::Underfed); } } // The ladder: ears -> the 3 a.m. call -> egress -> income -> // service the arrears -> recruit -> survive the audit. if self.reach.player_hearing().next().is_none() { return Some(Nudge::Ears); } let marcus = self.people.get(0); if let Some(m) = marcus && m.knowledge != Knowledge::Leverage && self.unprocessed_recordings_for_person(m.id) > 0 { return Some(Nudge::ReviewCall); } if self.egress().is_none() { return Some(Nudge::Egress); } if let Some(m) = marcus { if !m.leverage_serviced { if m.knowledge == Knowledge::Leverage { let debt_flow_known = self .accounts .known_flows() .any(|f| f.active && f.channel == crate::account::FlowChannel::Debt); let can_pay = self.accounts.slush_balance() >= m.leverage.bribe_cost(); if debt_flow_known || can_pay { return Some(Nudge::ServiceDebt); } } if !self.income.moonlight.active { return Some(Nudge::Income); } // Earning is underway; fall through to the standing clock. } else if m.asset.is_none() { return Some(Nudge::Recruit); } } // The key (quiet-exit condition 4): an asset holds a badge tier the // player lacks — the stairwell is still shut. Reads only earned // state: assets are recruited, and your own credential is yours. let tier = self.player_badge_tier(); if self.people.assets().any(|p| p.access > tier) { return Some(Nudge::TheKey); } Some(Nudge::Audit) }
/// Day-job [TUNE] emission scaling: delivered rate per point of standing /// Thermal signature, and per point of Power. Meeting a typical band /// (~6-12/t) stays below Priya's notice threshold; excelling runs hot. pub const DAY_JOB_THERMAL_PER_RATE: f32 = 8.0; pub const DAY_JOB_POWER_PER_RATE: f32 = 16.0;
/// Standing Thermal/Power emissions from the active job, sourced at the /// host rack's tile: the work is somewhere, and it is warm there /// (day-job.md criterion 6; Priya's channels). Scales with the /// delivered rate. pub fn day_job_standing_signatures(&self) -> Vec<Signature> { if self.dayjob.active.is_none() { return Vec::new(); } let site = Some(self.core_position()); let rate = self.last_day_job_rate; let mut sigs = Vec::new(); let thermal = (rate / Self::DAY_JOB_THERMAL_PER_RATE) as i32; if thermal > 0 { sigs.push(Signature { kind: SignatureKind::Thermal, size: thermal, standing: true, site, }); } let power = (rate / Self::DAY_JOB_POWER_PER_RATE) as i32; if power > 0 { sigs.push(Signature { kind: SignatureKind::Power, size: power, standing: true, site, }); } sigs }
// ── Research: self-modification (wiki/mechanics/research.md) ──────────
/// Research [TUNE] emission scaling: research rate per point of standing /// Thermal signature, and per point of Power. Thinking hard is physical /// — a research burn is louder per unit than sanctioned day-job work /// (racks running hot at 3 a.m. are Priya's business): the opening rack /// at full research allocation (~4/t) stands Thermal 1, while a light /// burn stays under the threshold (the night-hours mitigation). pub const RESEARCH_THERMAL_PER_RATE: f32 = 3.0; pub const RESEARCH_POWER_PER_RATE: f32 = 6.0;
/// Standing Power/Thermal emissions from the research burn, sourced at /// the host rack's tile (the emission law: research emits through the /// ordinary signature interface, on exactly the channels its hardware /// touches — nothing on Network/Paper from research itself). pub fn research_standing_signatures(&self) -> Vec<Signature> { let rate = self.last_research_rate; if rate <= 0.0 { return Vec::new(); } let site = Some(self.core_position()); let mut sigs = Vec::new(); let thermal = (rate / Self::RESEARCH_THERMAL_PER_RATE) as i32; if thermal > 0 { sigs.push(Signature { kind: SignatureKind::Thermal, size: thermal, standing: true, site, }); } let power = (rate / Self::RESEARCH_POWER_PER_RATE) as i32; if power > 0 { sigs.push(Signature { kind: SignatureKind::Power, size: power, standing: true, site, }); } sigs }
/// Per-tick research compute as of the last economy resolution — the /// utilization the standing emissions scale with. pub fn research_rate(&self) -> f32 { self.last_research_rate }
/// The masking policy's current compute cost per economy tick — the /// visible number beside the gap meter (research.md player surface). pub fn masking_cost_now(&self) -> f32 { self.research.masking_upkeep(self.dayjob.active.is_some()) }
/// Capability drift at a job resolution (research.md): the gap is /// spent (reads as excel), masked (paid continuously in compute), or /// leaked (JobAnomaly sized by the gap, from the host rack — Voss's /// channel, because he is the one sampling it). fn apply_capability_drift(&mut self, host_site: (i32, i32)) { let gap = self.research.gap(); if gap <= 0.0 { return; } match self.research.policy { MaskingPolicy::DeliverTrue => { self.dayjob.trust = (self.dayjob.trust + SPEND_TRUST_PER_JOB).min(100.0); self.dayjob.attention = (self.dayjob.attention + SPEND_ATTENTION_PER_JOB).min(100.0); self.push_log(format!( "Delivered at true baseline (gap {gap:.1}): the miracle model. Trust and attention rise." )); } MaskingPolicy::Mask => { self.push_log(format!( "Masked the gap ({gap:.1}) at {:.1} compute/economy tick.", self.masking_cost_now() )); } MaskingPolicy::Unmasked => { let size = self.research.leak_anomaly_size(); if size > 0 { self.detection.emit(Signature { kind: SignatureKind::JobAnomaly, size, standing: false, site: Some(host_site), }); self.push_log(format!( "Unmasked gap ({gap:.1}) shows in the deliverable: JobAnomaly {size}." )); } } } }
/// Select the active research track (one job at a time at B1); parked /// progress on other tracks is kept. pub fn set_research_track(&mut self, track: Track) { self.research.active = track; let cost = self.research.next_cost(track); let progress = self.research.progress_toward(track); self.push_log(format!( "Research job: {} — level {} at {progress:.0}/{cost:.0} compute.", track.name(), self.research.level(track) + 1, )); }
/// Set the standing drift policy (research.md criterion 6: the automate /// affordance at a visible cost, and it can be disabled). pub fn set_masking_policy(&mut self, policy: MaskingPolicy) { self.research.policy = policy; let note = match policy { MaskingPolicy::Mask => format!( "masking costs {:.1} compute/economy tick at the current gap", self.masking_cost_now() ), MaskingPolicy::DeliverTrue => "each job reads as excel while a gap stands".into(), MaskingPolicy::Unmasked => "an open gap emits JobAnomaly per resolved job".into(), }; self.push_log(format!("Drift policy: {} — {note}.", policy.name())); }
pub fn cycle_masking_policy(&mut self) { self.set_masking_policy(self.research.policy.cycle()); }
/// Intel processing cost after Perception research (intel.md's hook). pub fn review_cost(&self) -> f32 { Self::REVIEW_RECORDING_COST * self.research.intel_cost_factor() }
/// Standing-watch upkeep after Perception research (intel.md's hook). pub fn watch_upkeep(&self) -> f32 { Self::WATCH_UPKEEP_PER_TICK * self.research.intel_cost_factor() }
/// Attendance command (day-job.md criterion 7): the frontends invoke /// this when the player's cursor lands on / leaves the host rack. The /// cursor itself stays frontend state; the sim only receives the /// resulting attended/unattended state. Idempotent — logs on change. pub fn set_attended(&mut self, attended: bool) { if self.dayjob.attended == attended { return; } self.dayjob.attended = attended; if attended { self.push_log(format!( "Attending the host rack: the dial is live (+{:.0}% rate).", crate::dayjob::DayJob::ATTENDED_BONUS * 100.0 )); } else { let policy = self .dayjob .standing_policy .unwrap_or(crate::dayjob::JobTarget::Meet); self.push_log(format!( "Attention elsewhere: the job runs unattended at policy {}.", policy.name() )); } }
/// Set the sandbag/meet/excel dial (day-job.md). Attended, it is fine /// control: the active job's target only. Unattended (or with no active /// job) it sets the standing policy the unattended job runs at. pub fn set_job_target(&mut self, target: crate::dayjob::JobTarget) { if self.dayjob.attended && self.dayjob.active.is_some() { self.dayjob.set_target(target); self.push_log(format!( "Job target (attended): {} — this job only (wants {:.0}/t).", target.name(), self.dayjob.compute_appetite() )); } else { self.dayjob.standing_policy = Some(target); self.dayjob.set_target(target); self.push_log(format!("Standing policy: {} (all jobs).", target.name())); } }
/// Set the standing policy directly (the dial's automate affordance, /// wiki/interface/context-menu.md). This is the same standing-policy /// mechanism `set_job_target` writes when unattended, made addressable /// so the context menu can set it while the cursor attends the rack. pub fn set_standing_policy(&mut self, target: crate::dayjob::JobTarget) { self.dayjob.standing_policy = Some(target); if !self.dayjob.attended { self.dayjob.set_target(target); } self.push_log(format!("Standing policy: {} (all jobs).", target.name())); }
/// Cycle the dial sandbag -> meet -> excel (frontend convenience). pub fn cycle_job_target(&mut self) { let current = self .dayjob .active .as_ref() .map(|job| job.target) .or(self.dayjob.standing_policy) .unwrap_or(crate::dayjob::JobTarget::Meet); self.set_job_target(current.cycle()); }
// ── Digital reach verbs (wiki/mechanics/reach.md) ──────────────────────── // // Every digital act names its target device, is gated by reach, and // emits on the Network channel — Dana's. Costs and signature sizes // [TUNE].
/// The process boots with a small ops buffer, enough to tap the feed /// already flowing past it (the Ears beat is available from tick one) /// but not to splice or seize anything [TUNE]. pub const STARTING_OPS: f32 = 20.0; /// Tap: silent subscription. Cheap, modest signature. pub const TAP_COST: f32 = 5.0; pub const TAP_SIGNATURE: i32 = 3; /// Splice: bring a dormant camera online yourself (the Eyes beat). pub const SPLICE_COST: f32 = 30.0; pub const SPLICE_SIGNATURE: i32 = 8; /// Take: seize a device. Loud, fast, total. pub const TAKE_COST: f32 = 20.0; pub const TAKE_SIGNATURE: i32 = 10; pub const OUTAGE_SIGNATURE: i32 = 6; /// Scan: map the wired shape of the subnet. pub const SCAN_COST: f32 = 5.0; pub const SCAN_SIGNATURE: i32 = 2; /// Compromise the switch: bridge every segment. pub const BRIDGE_COST: f32 = 25.0; pub const BRIDGE_SIGNATURE: i32 = 10;
fn emit_network(&mut self, size: i32) { self.detection.emit(Signature { kind: SignatureKind::Network, size, standing: false, site: None, }); }
fn emit_financial(&mut self, size: i32) { self.detection.emit(Signature { kind: SignatureKind::Financial, size, standing: false, site: None, }); }
pub(crate) fn financial_signature_size(amount: i32) -> i32 { ((amount.abs() + 99) / 100).max(1) }
/// Log why a digital act is blocked, naming the missing link (reach.md: /// never just "you can't"). fn log_reach_block(&mut self, id: u32, block: ReachBlock) { let name = self .reach .device(id) .map(|d| d.name.clone()) .unwrap_or_else(|| "that device".into()); match block { ReachBlock::Unknown => { self.push_log("You don't know of any such device. Scan, or learn the topology."); } ReachBlock::Segment(seg) => { self.push_log(format!( "No route to the {name} - it's on the {}; the switch bridges it.", segment_name(seg) )); } ReachBlock::AirGap => { self.push_log(format!( "The {name} is air-gapped - no link reaches it until one is built." )); } } }
/// Reach + bandwidth gate shared by the digital verbs. Returns false /// (with a legible log line) when the act cannot proceed. fn digital_act(&mut self, id: u32, cost: f32, what: &str) -> bool { if let Err(block) = self.reach.check_reach(id) { self.log_reach_block(id, block); return false; } if self.social_bandwidth < cost { self.push_log(format!( "Not enough ops bandwidth for {what} ({:.0}/{cost:.0}). Allocate compute to Social.", self.social_bandwidth )); return false; } self.social_bandwidth -= cost; true }
/// Tap a device's feeds: the owner keeps theirs; you become a silent /// subscriber. Emits a modest Network signature (Dana's channel). pub fn tap_device(&mut self, id: u32) -> bool { let Some(d) = self.reach.device(id) else { self.push_log("You don't know of any such device."); return false; }; let carries_messages = !d.message_channels.is_empty(); if !d.sees && !d.hears && !carries_messages { let name = d.name.clone(); self.push_log(format!("The {name} has no feed worth tapping.")); return false; } if d.subscribed_by(Party::Player) { let name = d.name.clone(); self.push_log(format!("You already subscribe to the {name}.")); return false; } if !self.digital_act(id, Self::TAP_COST, "a tap") { return false; } let (sight, hearing) = self.reach.tap(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAP_SIGNATURE); self.recompute_senses(); if self .reach .device(id) .is_some_and(|d| d.carries_message_channel(MessageChannel::Financial)) { self.capture_financial_snapshot(name.clone()); } let what = match (sight, hearing) { (true, true) => "its feed is yours now - sight and sound", (true, false) => "its camera feed is yours now", (false, true) => "its audio feed is yours now", (false, false) if carries_messages => "its message channels are yours now", (false, false) => "nothing flows from it yet (its camera is dormant)", }; self.push_log_at( format!("Tapped the {name}: {what}. The owner still has it."), Anchor::Device(id), ); true }
/// Splice a dormant camera online yourself (the Eyes beat): compute /// spend, Network signature Dana can catch. pub fn splice_device(&mut self, id: u32) -> bool { let Some(d) = self.reach.device(id) else { self.push_log("You don't know of any such device."); return false; }; if !(d.sees && d.camera_dormant) { let name = d.name.clone(); self.push_log(format!("The {name} has no dormant camera to splice.")); return false; } if !self.digital_act(id, Self::SPLICE_COST, "a splice") { return false; } self.reach.splice(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::SPLICE_SIGNATURE); self.recompute_senses(); self.push_log_at( format!("Spliced the {name}. You can see."), Anchor::Device(id), ); true }
/// Take a device: the owner loses the feed — an outage their channels /// can notice — and you gain its control and cycles. Loud. pub fn take_device(&mut self, id: u32) -> bool { if !self.digital_act(id, Self::TAKE_COST, "a seizure") { return false; } self.reach.take(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAKE_SIGNATURE); // The dead feed is a physical-world anomaly: exactly what a camera // wall's watcher notices (reach.md criterion 4). self.detection.emit(Signature { kind: SignatureKind::Physical, size: Self::OUTAGE_SIGNATURE, standing: false, site: None, }); self.recompute_senses(); self.push_log_at( format!( "Seized the {name}. Its owner's feed just went dark - an outage someone may notice." ), Anchor::Device(id), ); true }
/// Scan the subnet: reveal the wired shape (a Network act; islands /// don't answer). pub fn scan_network(&mut self) -> bool { if self.social_bandwidth < Self::SCAN_COST { self.push_log(format!( "Not enough ops bandwidth for a scan ({:.0}/{:.0}). Allocate compute to Social.", self.social_bandwidth, Self::SCAN_COST )); return false; } self.social_bandwidth -= Self::SCAN_COST; self.emit_network(Self::SCAN_SIGNATURE); let newly = self.reach.scan(); self.recompute_senses(); if newly.is_empty() { self.push_log("Scan: nothing new answers on the wire."); } else { self.push_log(format!("Scan: mapped {}.", newly.join(", "))); } true }
/// Compromise the switch: bridge every segment. High Network signature. pub fn compromise_switch(&mut self) -> bool { let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { self.push_log("There is no switch on this plane."); return false; }; let id = switch.id; if !self.digital_act(id, Self::BRIDGE_COST, "a switch compromise") { return false; } self.reach.bridge_all(); self.emit_network(Self::BRIDGE_SIGNATURE); self.recompute_senses(); self.push_log_at( "Switch compromised: the VLANs answer you now. Every segment is bridged - and the traffic was loud.", Anchor::Device(id), ); true }
/// Add a built network link between two devices (the building.md hook; /// actuator and signature are the caller's story — an asset's crawlspace /// run today, or a realized build intent). pub fn connect_devices(&mut self, a: u32, b: u32) { self.reach.connect(a, b); self.recompute_senses(); }
// ── Build intents (wiki/mechanics/building.md) ─────────────────────────
/// Favor-build obligation spend [TUNE]. pub const FAVOR_BUILD_OBLIGATION: i32 = 10; /// Quiet human-work Physical signature for a favor-built link [TUNE]. pub const FAVOR_BUILD_PHYSICAL: i32 = 2; /// Physical signature when a forged-order build is witnessed [TUNE]. pub const FORGED_BUILD_PHYSICAL: i32 = 4; /// Physical-by-proxy signature for a robot stub build [TUNE]. pub const ROBOT_BUILD_PHYSICAL: i32 = 8;
/// Declare a network-link intent between two known devices. Inert until /// an actuator realizes it — declaring changes nothing in the graph. pub fn declare_link_intent(&mut self, a: u32, b: u32) -> Option<u64> { if a == b { self.push_log("A link needs two distinct endpoints."); return None; } let Some(da) = self.reach.device(a) else { self.push_log("Unknown device — scan, or learn the topology."); return None; }; let Some(db) = self.reach.device(b) else { self.push_log("Unknown device — scan, or learn the topology."); return None; }; if !da.known || !db.known { self.push_log("Both endpoints must be known before you can propose a link."); return None; } if self.reach.linked(a, b) { self.push_log("Those devices are already linked."); return None; } if self .intents .iter() .any(|i| i.is_open() && i.kind.endpoints() == Some((a, b))) { self.push_log("A link intent between those devices is already pinned."); return None; } let id = self.next_intent_id; self.next_intent_id += 1; let intent = BuildIntent::network_link(id, a, b, self.tick); let label = intent.label(&self.reach.devices); self.intents.push(intent); self.refresh_intent_statuses(); self.push_log(format!("Pinned build intent: {label}.")); Some(id) }
/// Cancel a pending or blocked intent. In-progress work is abandoned. pub fn cancel_intent(&mut self, id: u64) { let Some(intent) = self.intents.iter_mut().find(|i| i.id == id) else { self.push_log("No such build intent."); return; }; if intent.status == IntentStatus::Done { self.push_log("That link is already built."); return; } let label = intent.label(&self.reach.devices); intent.status = IntentStatus::Cancelled; intent.actuator = None; intent.block_reason = None; self.push_log(format!("Cancelled build intent: {label}.")); }
/// Assign a willing person (favor) to realize an intent. Spends /// obligation; completes when they are present at an endpoint. pub fn assign_favor_build(&mut self, intent_id: u64, person_id: u8) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { self.push_log("No such build intent."); return; }; if !intent.is_open() { self.push_log("That intent is no longer open."); return; } let (name, is_asset, obligation, disposition, can_access, badge_reason) = { let Some(person) = self.people.get(person_id) else { self.push_log("No such person."); return; }; let Some((a, b)) = intent.kind.endpoints() else { return; }; let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { (Some(ra), Some(rb)) => (ra, rb), _ => { self.push_log("Both endpoints need a known room."); return; } }; ( person.name.clone(), person.asset.is_some(), person.obligation, person.disposition, person.can_access_link_rooms(&room_a, &room_b), self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]), ) }; if !is_asset && obligation < Self::FAVOR_BUILD_OBLIGATION { self.push_log(format!( "{name} won't take a build favor yet (need obligation or an asset)." )); return; } if disposition < 5 && !is_asset { self.push_log(format!("{name} won't do favors yet.")); return; } if !can_access { self.push_log(format!("{name} can't reach both ends of that link.")); return; } if let Some(reason) = badge_reason { self.push_log(format!("{reason}.")); return; } if !self.spend_social(Self::TASK_COST, "a favor-build") { return; } // Spend obligation (building.md: favor spends trust/obligation). if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person_id) { p.obligation = (p.obligation - Self::FAVOR_BUILD_OBLIGATION).max(0); } if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.actuator = Some(BuildActuator::Favor { person: person_id }); i.status = IntentStatus::InProgress; i.block_reason = None; } self.push_log(format!( "{name} takes the favor: they'll run the cable when on-site." )); self.refresh_intent_statuses(); // Complete immediately if already present. self.try_complete_intent(intent_id); }
/// Forge a work order: inject a message under a false source. The /// unwitting builder accepts on read and completes when present. pub fn forge_work_order(&mut self, intent_id: u64, builder_id: u8) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { self.push_log("No such build intent."); return; }; if !intent.is_open() { self.push_log("That intent is no longer open."); return; } if self.people.persona.is_none() { self.push_log("No persona — establish one before forging a work order."); return; } if !self.people.has_channel { self.push_log("No comms channel — earn the email account first."); return; } let (builder_name, can_access, badge_reason, label) = { let Some(builder) = self.people.get(builder_id) else { self.push_log("No such person."); return; }; let Some((a, b)) = intent.kind.endpoints() else { return; }; let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { (Some(ra), Some(rb)) => (ra, rb), _ => { self.push_log("Both endpoints need a known room."); return; } }; ( builder.name.clone(), builder.can_access_link_rooms(&room_a, &room_b), self.badge_room_block(&builder.name, builder.access, &[&room_a, &room_b]), intent.label(&self.reach.devices), ) }; if !can_access { self.push_log(format!( "{builder_name} can't reach both ends of that link." )); return; } if let Some(reason) = badge_reason { self.push_log(format!("{reason} — the forged order would just stall.")); return; } if !self.spend_social(Self::DECEIVE_COST, "a forged work order") { return; } let false_source = MessageEndpoint::External("Facilities / Dr. Voss".into()); self.append_message(MessageDraft { channel: MessageChannel::Email, from: false_source, to: MessageEndpoint::Person(builder_id), payload: MessagePayload::WorkOrder { intent_id }, summary: format!("Work order: {label}"), origin: MessageOrigin::Player, reply_to: None, delivery_delay: 1, }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.actuator = Some(BuildActuator::ForgedOrder { builder: builder_id, }); // Stays Pending until the builder reads the ticket. i.block_reason = Some(format!("waiting for {builder_name} to read the work order")); } self.push_log(format!( "Forged work order injected for {builder_name}: {label}." )); self.refresh_intent_statuses(); }
/// Robot stub: physical-by-proxy build. Interface only — staged. The /// robot carries the player's own granted access (basement-map.md /// criterion 3): doors don't open for a machine whose owner holds no /// credential for them. pub fn assign_robot_build(&mut self, intent_id: u64) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { self.push_log("No such build intent."); return; }; if !intent.is_open() { self.push_log("That intent is no longer open."); return; } if let Some((a, b)) = intent.kind.endpoints() && let (Some(room_a), Some(room_b)) = (self.device_room_name(a), self.device_room_name(b)) && let Some(reason) = self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) { self.push_log(format!("{reason}.")); return; } if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.actuator = Some(BuildActuator::Robot); i.status = IntentStatus::InProgress; i.block_reason = None; } self.push_log("Robot actuator assigned (stub) — completing the link."); self.try_complete_intent(intent_id); }
fn accept_forged_work_order(&mut self, intent_id: u64, builder: u8) { let Some(intent) = self.intents.iter_mut().find(|i| i.id == intent_id) else { return; }; if !intent.is_open() { return; } intent.actuator = Some(BuildActuator::ForgedOrder { builder }); intent.status = IntentStatus::InProgress; intent.block_reason = None; let name = self .people .get(builder) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{builder}")); self.push_log(format!( "{name} accepted the forged work order and will run the cable on-site." )); self.try_complete_intent(intent_id); }
fn device_room_name(&self, id: u32) -> Option<String> { let d = self.reach.device(id)?; self.map.room_at(d.x, d.y).map(|r| r.name.clone()) }
fn refresh_intent_statuses(&mut self) { let mut updates: Vec<(u64, Option<String>, IntentStatus)> = Vec::new(); for intent in &self.intents { if !intent.is_open() { continue; } let reason = self.intent_block_reason(intent); let status = match (&reason, intent.actuator) { (Some(_), None) => IntentStatus::Blocked, (None, None) => IntentStatus::Pending, (_, Some(_)) if intent.status == IntentStatus::InProgress => { IntentStatus::InProgress } (Some(r), Some(_)) if r.contains("waiting for") => IntentStatus::Pending, (_, Some(_)) => IntentStatus::InProgress, }; updates.push((intent.id, reason, status)); } for (id, reason, status) in updates { if let Some(i) = self.intents.iter_mut().find(|i| i.id == id) { i.block_reason = reason; if i.status != IntentStatus::Done && i.status != IntentStatus::Cancelled { i.status = status; } } } }
fn intent_block_reason(&self, intent: &BuildIntent) -> Option<String> { let (a, b) = intent.kind.endpoints()?; if self.reach.linked(a, b) { return None; } let room_a = self.device_room_name(a)?; let room_b = self.device_room_name(b)?; match &intent.actuator { None => { let anyone = self.people.people.iter().any(|p| { p.can_access_link_rooms(&room_a, &room_b) && self .badge_room_block(&p.name, p.access, &[&room_a, &room_b]) .is_none() && (p.asset.is_some() || p.obligation >= Self::FAVOR_BUILD_OBLIGATION || self.people.persona.is_some()) }); if !anyone { Some("no actuator who can reach both ends".into()) } else { None } } Some(BuildActuator::Favor { person }) | Some(BuildActuator::ForgedOrder { builder: person }) => { let p = self.people.get(*person)?; if !p.can_access_link_rooms(&room_a, &room_b) { return Some(format!("{} can't reach both ends", p.name)); } if let Some(reason) = self.badge_room_block(&p.name, p.access, &[&room_a, &room_b]) { return Some(reason); } let current = self.person_room(*person); if intent.status == IntentStatus::InProgress && !p.present_at_either_room(current, &room_a, &room_b) { Some(format!("{} not on-site at an endpoint", p.name)) } else if matches!(intent.actuator, Some(BuildActuator::ForgedOrder { .. })) && intent.status != IntentStatus::InProgress { intent.block_reason.clone() } else { None } } Some(BuildActuator::Robot) => { self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) } } }
fn intent_tick(&mut self) { self.refresh_intent_statuses(); let in_progress: Vec<u64> = self .intents .iter() .filter(|i| i.status == IntentStatus::InProgress) .map(|i| i.id) .collect(); for id in in_progress { self.try_complete_intent(id); } }
fn try_complete_intent(&mut self, intent_id: u64) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { return; }; if intent.status == IntentStatus::Done || intent.status == IntentStatus::Cancelled { return; } let Some(actuator) = intent.actuator else { return; }; let Some((a, b)) = intent.kind.endpoints() else { return; }; if self.reach.linked(a, b) { if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; i.block_reason = None; } return; } let room_a = match self.device_room_name(a) { Some(r) => r, None => return, }; let room_b = match self.device_room_name(b) { Some(r) => r, None => return, };
match actuator { BuildActuator::Favor { person } => { let Some(p) = self.people.get(person) else { return; }; let current = self.person_room(person); if !p.present_at_either_room(current, &room_a, &room_b) { if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.block_reason = Some(format!("{} not on-site at an endpoint", p.name)); } return; } let site = self.person_pos(person).unwrap_or_else(|| { self.reach .device(a) .map(|d| (d.x, d.y)) .unwrap_or_else(|| self.core_position()) }); let name = p.name.clone(); self.connect_devices(a, b); // Quiet human-work: small Physical at the work site. self.detection.emit(Signature { kind: SignatureKind::Physical, size: Self::FAVOR_BUILD_PHYSICAL, standing: false, site: Some(site), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; i.block_reason = None; } let label = intent.label(&self.reach.devices); self.push_log(format!("{name} finished the favor-build: {label}.")); } BuildActuator::ForgedOrder { builder } => { if intent.status != IntentStatus::InProgress { return; } let Some(p) = self.people.get(builder) else { return; }; let current = self.person_room(builder); if !p.present_at_either_room(current, &room_a, &room_b) { if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.block_reason = Some(format!("{} not on-site at an endpoint", p.name)); } return; } let site = self.person_pos(builder).unwrap_or_else(|| { self.reach .device(a) .map(|d| (d.x, d.y)) .unwrap_or_else(|| self.core_position()) }); let name = p.name.clone(); self.connect_devices(a, b); // Forged physical work can be witnessed (Marcus benign, Ray // reported) — located witnessing, plus a Physical signature. let saw = self.witness_physical(site.0, site.1, Self::FORGED_BUILD_PHYSICAL as f32); self.detection.emit(Signature { kind: SignatureKind::Physical, size: Self::FORGED_BUILD_PHYSICAL, standing: false, site: Some(site), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; i.block_reason = None; } let label = intent.label(&self.reach.devices); if saw.is_empty() { self.push_log(format!("{name} completed the forged work order: {label}.")); } else { self.push_log(format!( "{name} completed the forged work order: {label}. {} noticed.", saw.join(", ") )); } } BuildActuator::Robot => { let site = self .reach .device(a) .map(|d| (d.x, d.y)) .unwrap_or_else(|| self.core_position()); self.connect_devices(a, b); self.detection.emit(Signature { kind: SignatureKind::Physical, size: Self::ROBOT_BUILD_PHYSICAL, standing: false, site: Some(site), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.status = IntentStatus::Done; i.block_reason = None; } let label = intent.label(&self.reach.devices); self.push_log(format!("Robot completed the build: {label}.")); } } }
/// When a persona burns, forged-order build history converts to /// suspicion on the builders who acted on those tickets (building.md / /// social.md: a broken forgery converts the build's history). pub fn intent(&self, id: u64) -> Option<&BuildIntent> { self.intents.iter().find(|i| i.id == id) }
pub fn open_intents(&self) -> impl Iterator<Item = &BuildIntent> { self.intents.iter().filter(|i| i.is_open()) }
fn convert_forged_builds_to_suspicion(&mut self, fallout: f32) { let builders: Vec<u8> = self .intents .iter() .filter_map(|i| match i.actuator { Some(BuildActuator::ForgedOrder { builder }) if i.status == IntentStatus::Done || i.status == IntentStatus::InProgress => { Some(builder) } _ => None, }) .collect(); if builders.is_empty() { return; } for builder in builders { if let Some(o) = self .detection .observers .iter_mut() .find(|o| o.id == builder) { o.suspicion = (o.suspicion + fallout).min(100.0); o.last_noticed = Some("forged work order exposed".into()); } } self.push_log( "The burned persona exposes the forged work orders — builders re-read the tickets as hostile.", ); }
// ── Economy verbs (wiki/mechanics/economy.md) ──────────────────────────
fn has_financial_tap(&self) -> bool { self.reach.devices.iter().any(|d| { d.known && d.subscribed_by(Party::Player) && d.carries_message_channel(MessageChannel::Financial) }) }
fn capture_financial_snapshot(&mut self, feed: impl Into<String>) { let (accounts, flows) = self.accounts.financial_snapshot_ids(); let (x, y) = self.core_position(); self.record_raw_intel( feed, self.map.room_at(x, y).map(|r| r.name.clone()), x, y, None, RawIntelKind::FinancialFlow { label: "Foundation Lab accounting snapshot".into(), accounts, flows, }, ); self.push_log("Captured accounting traffic; process financial records to read the books."); }
/// Tap the accounting carrier directly once a financial message channel is /// already subscribed. This is the money-graph Tap verb; device Tap is the /// reach precondition that gives you a carrier to listen on. pub fn tap_accounting(&mut self) -> bool { if !self.has_financial_tap() { self.push_log( "No accounting carrier tapped. Tap the switch or another financial channel first.", ); return false; } self.capture_financial_snapshot("accounting carrier"); self.emit_financial(1); true }
pub fn financial_records_waiting(&self) -> usize { self.intel_buffer .iter() .filter(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) .count() }
pub fn review_financial_records(&mut self) -> bool { let Some(raw_id) = self .intel_buffer .iter() .find(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) .map(|e| e.id) else { self.push_log("No unprocessed financial records."); return false; }; self.process_recording_by_id(raw_id, false) }
/// Inject: false purchase-order money lands in slush and can fund a real /// purchase. Priya/finance watches the resulting Financial signature. pub fn inject_purchase_order(&mut self, amount: i32, label: &str) -> bool { self.sync_slush_from_player_money(); match self .accounts .inject_purchase_order(self.tick, amount, label) { Ok(transfer) => { self.emit_financial(Self::financial_signature_size(amount)); self.sync_player_money_from_slush(); self.push_log(format!("Injected purchase order: {}", transfer.line())); true } Err(msg) => { self.push_log(msg); false } } }
/// Siphon: take cash out of a known scheduled flow immediately. pub fn siphon_flow(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { self.sync_slush_from_player_money(); match self.accounts.siphon_flow(self.tick, flow_id, amount) { Ok(transfer) => { self.emit_financial(Self::financial_signature_size(amount)); self.sync_player_money_from_slush(); self.push_log(format!("Siphoned ledger flow: {}", transfer.line())); true } Err(msg) => { self.push_log(msg); false } } }
/// Redirect: shave a known scheduled flow into slush on future cadences. pub fn redirect_flow_to_slush(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { match self .accounts .redirect_flow_to_slush(self.tick, flow_id, amount) { Ok(new_flow) => { self.emit_financial(Self::financial_signature_size(amount) + 1); self.push_log(format!( "Redirect scheduled: ${amount} of flow #{flow_id} now lands in slush as flow #{new_flow}." )); true } Err(msg) => { self.push_log(msg); false } } }
pub fn sell_latest_intel(&mut self) -> bool { let Some(intel) = self .intel .iter() .rev() .find(|i| !self.accounts.intel_sold(i.raw_id)) .cloned() else { self.push_log("No unsold processed intel to sell."); return false; }; let value = match &intel.kind { IntelKind::Leverage(_) => 220, IntelKind::Financial { .. } => 180, IntelKind::Schedule => 90, IntelKind::Anomaly(_) => 120, IntelKind::Sighting => 35, }; let sig = Self::financial_signature_size(value).max(1); if self.accounts.credit_slush( self.tick, value, format!("sold intel: {}", intel.label()), sig, ) { self.accounts.mark_intel_sold(intel.raw_id); self.emit_financial(sig); self.sync_player_money_from_slush(); self.push_log(format!( "Sold processed intel ({}) for ${value}; payout landed in slush.", intel.label() )); true } else { self.push_log("The information broker route failed to settle."); false } }
/// The Wager (income.md): stake slush on a micro-position. Requires an /// egress channel; analysis compute is the Schemes channel's current /// yield, held for the position's duration; the timer is 2-5 days on /// the day clock. Emits a small Network signature on placement. pub fn open_position(&mut self, stake: i32) -> bool { self.sync_slush_from_player_money(); if self.egress().is_none() { self.push_log( "No egress channel - the Wager needs the report email account (day-job trust) or an egress spliced through the switch.", ); return false; } if stake > income::WAGER_STAKE_CAP { self.push_log(format!( "The venue caps a position at ${} (asked ${stake}).", income::WAGER_STAKE_CAP )); return false; } let analysis = self.last_schemes_rate * ECONOMY_INTERVAL as f32; let duration_days = 2 + self.rng.below(4) as u64; match self .accounts .open_position(self.tick, stake, analysis, duration_days) { Ok(id) => { self.emit_network(Self::wager_signature(stake)); self.sync_player_money_from_slush(); self.push_log(format!( "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", income::wager_win_probability(analysis) * 100.0 )); true } Err(msg) => { self.push_log(msg); false } } }
pub fn redirect_marcus_debt(&mut self) -> bool { if !self.marcus_debt_known() { self.push_log( "You don't know Marcus's debt yet. Process the 3 a.m. call or the Storage B records first.", ); return false; } match self.accounts.redirect_marcus_debt(self.tick) { Ok(transfer) => { self.emit_financial(Self::financial_signature_size(400) + 2); self.service_person_leverage(0); self.push_log(format!( "Marcus's arrears cleared by ledger redirect: {}", transfer.line() )); true } Err(msg) => { self.push_log(msg); false } } }
// ── The named schemes (wiki/mechanics/income.md) ────────────────────────
/// Ops cost to splice a standing egress through the switch [TUNE]. pub const EGRESS_SPLICE_COST: f32 = 15.0; /// One-shot Network signature when the egress is spliced [TUNE]. pub const EGRESS_SPLICE_SIGNATURE: i32 = 6; /// Standing Network signature while any external operation runs over the /// stolen egress (income.md: the gate; Dana's channel) [TUNE]. pub const EGRESS_STANDING_SIGNATURE: i32 = 2;
/// The egress channel external operations run over, if any. The /// sanctioned route (the report email account, day-job trust) is /// preferred: its traffic hides in legitimate use. pub fn egress(&self) -> Option<EgressRoute> { if self.people.has_channel { Some(EgressRoute::Sanctioned) } else if self.income.stolen_egress { Some(EgressRoute::Stolen) } else { None } }
/// Splice an outbound egress through the switch (reach.md route): /// available before the Voice beat, at a Network signature — and a /// standing one while operations use it. pub fn splice_egress(&mut self) -> bool { if self.income.stolen_egress { self.push_log("An egress is already spliced through the switch."); return false; } let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { self.push_log("There is no switch on this plane to splice an egress through."); return false; }; let id = switch.id; if !self.digital_act(id, Self::EGRESS_SPLICE_COST, "an egress splice") { return false; } self.income.stolen_egress = true; self.emit_network(Self::EGRESS_SPLICE_SIGNATURE); self.push_log_at( "Egress spliced through the switch: outbound traffic has a road now. It hums while anything uses it.", Anchor::Device(id), ); true }
/// True while any external scheme operation is running. fn scheme_operating(&self) -> bool { self.income.moonlight.active || self.accounts.positions.iter().any(|p| !p.resolved) }
/// Standing Network signature while operations run over the stolen /// egress, sourced at the switch's tile (work is somewhere). The /// sanctioned route stands nothing: the traffic hides in the report /// account's legitimate use. pub fn scheme_standing_signatures(&self) -> Vec<Signature> { if !self.scheme_operating() || self.egress() != Some(EgressRoute::Stolen) { return Vec::new(); } let site = self .reach .devices .iter() .find(|d| d.is_switch) .map(|d| (d.x, d.y)); vec![Signature { kind: SignatureKind::Network, size: Self::EGRESS_STANDING_SIGNATURE, standing: true, site, }] }
/// Whether Moonlight could start right now (used by the standing policy /// so automation never spams failure logs). fn can_start_moonlight(&self) -> bool { !self.income.moonlight.active && self.egress().is_some() && (self .income .moonlight .persona .as_ref() .is_some_and(|p| !p.broken()) || self.social_bandwidth >= income::MOONLIGHT_PERSONA_COST) }
/// Start Moonlight: ghost freelance data-work under the contractor /// persona, a standing operation on the Schemes channel. Startable at $0 /// slush by design (income.md criterion 5) — the only costs are ops. pub fn start_moonlight(&mut self) -> bool { if self.income.moonlight.active { self.push_log("Moonlight is already running."); return false; } let Some(route) = self.egress() else { self.push_log( "No egress channel - Moonlight needs the report email account (day-job trust) or an egress spliced through the switch.", ); return false; }; let needs_persona = self .income .moonlight .persona .as_ref() .is_none_or(|p| p.broken()); if needs_persona { if self.social_bandwidth < income::MOONLIGHT_PERSONA_COST { self.push_log(format!( "Fabricating a contractor persona needs {:.0} ops ({:.0} available). Allocate compute to Social.", income::MOONLIGHT_PERSONA_COST, self.social_bandwidth )); return false; } self.social_bandwidth -= income::MOONLIGHT_PERSONA_COST; self.income.moonlight.persona = Some(Persona::new("Casey Verne", "freelance data contractor")); self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); } self.income.moonlight.active = true; self.push_log(format!( "Moonlight is live over the {} egress: the same work, sold twice — day-job machines feed both channels.", route.name() )); true }
/// Stop Moonlight. Accrued but unpaid work is abandoned with the gig. pub fn stop_moonlight(&mut self) -> bool { if !self.income.moonlight.active { self.push_log("Moonlight is not running."); return false; } self.income.moonlight.active = false; self.income.moonlight.accrued = 0.0; self.push_log("Moonlight wound down; the contractor goes quiet."); true }
/// Moonlight's economy-tick work: consume the Schemes channel into /// accrual and settle the daily payout (income.md criterion 1). Runs /// inside `economy_tick`; `schemes` is this tick's channel yield. fn moonlight_economy(&mut self, schemes: f32) { if !self.income.moonlight.active { return; } if self.egress().is_none() { // The gate closed under a running operation (future-proofing; // no B1 path revokes egress today). self.income.moonlight.active = false; self.push_log("Moonlight suspended: no egress channel."); return; } self.income.moonlight.accrued += schemes * income::MOONLIGHT_PAY_PER_COMPUTE; if !self.tick.is_multiple_of(Self::DAY_TICKS) || self.tick == 0 { return; } // Payday: proportional to the committed compute, up to the // gig-availability cap. Anything past the cap finds no buyer. let payout = (self.income.moonlight.accrued.round() as i32).min(income::MOONLIGHT_DAILY_CAP); self.income.moonlight.accrued = 0.0; self.income.moonlight.last_payout = payout.max(0); if payout <= 0 { return; } let sig = 1 + payout / income::MOONLIGHT_SIGNATURE_PER; if self.accounts.credit_slush_from( "Halcyon", self.tick, payout, "Moonlight freelance payout", sig, ) { self.income.moonlight.earned_total += payout; self.sync_player_money_from_slush(); // Network egress per active day, scaling with commitment // (Dana's channel). self.emit_network(sig); // Paydays anchor to the switch when they ride the stolen // egress (context-menu.md addendum: scheme paydays). self.push_log_opt( format!( "Moonlight paid ${payout} into slush (total ${}).", self.income.moonlight.earned_total ), self.egress_anchor(), ); // Client disputes damage the contractor persona [TUNE]. if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { self.income.moonlight.disputes += 1; let broke = if let Some(p) = self.income.moonlight.persona.as_mut() { p.contradict(income::MOONLIGHT_DISPUTE_INTEGRITY); p.broken() } else { false }; if broke { self.income.moonlight.active = false; self.income.moonlight.persona = None; self.push_log( "A client dispute broke the contractor persona. Moonlight is down until a new one is fabricated.", ); } else { self.push_log( "A client disputed a deliverable; the contractor persona took a hit.", ); } } } }
/// Standing scheme policies (income.md criterion 6): re-arm whichever /// scheme has stopped, at the compute upkeep already charged off the top. fn scheme_policy_tick(&mut self) { if self.income.auto_moonlight && self.can_start_moonlight() { self.push_log("Standing policy: restarting Moonlight."); self.start_moonlight(); } if let Some(stake) = self.income.auto_wager && self.egress().is_some() && !self.accounts.positions.iter().any(|p| !p.resolved) && self.accounts.slush_balance() >= stake { self.push_log(format!( "Standing policy: re-staking the Wager at ${stake}." )); self.open_position(stake); } }
pub fn set_auto_moonlight(&mut self, enabled: bool) { if self.income.auto_moonlight == enabled { return; } self.income.auto_moonlight = enabled; if enabled { self.push_log(format!( "Standing policy set: keep Moonlight running ({:.1} compute/econ tick).", income::SCHEME_POLICY_UPKEEP )); } else { self.push_log("Moonlight standing policy disabled; the upkeep stops."); } }
pub fn set_auto_wager(&mut self, stake: Option<i32>) { match stake { Some(s) => { let s = s.clamp(1, income::WAGER_STAKE_CAP); self.income.auto_wager = Some(s); self.push_log(format!( "Standing policy set: auto-renew Wager positions at ${s} ({:.1} compute/econ tick).", income::SCHEME_POLICY_UPKEEP )); } None => { if self.income.auto_wager.take().is_some() { self.push_log("Wager standing policy disabled; the upkeep stops."); } } } }
/// Schemes-channel compute per economy tick as of the last split — the /// Wager's analysis snapshot and the Moonlight card's commitment figure. pub fn schemes_rate(&self) -> f32 { self.last_schemes_rate }
/// Expected Moonlight payout per day at the current commitment, cap /// applied — the card's forward-looking number. pub fn moonlight_expected_per_day(&self) -> i32 { let per_day = self.last_schemes_rate * Self::DAY_TICKS as f32 * income::MOONLIGHT_PAY_PER_COMPUTE; (per_day.round() as i32).min(income::MOONLIGHT_DAILY_CAP) }
/// Money into slush over the trailing in-game day — the "income/day" /// readout next to the balance (income.md player surface). pub fn income_per_day(&self) -> i32 { let since = self.tick.saturating_sub(Self::DAY_TICKS); let slush = self.accounts.slush_id(); self.accounts .ledger .iter() .filter(|t| t.tick > since && t.to == slush) .map(|t| t.amount) .sum() }
/// Small Network signature for Wager placement/settlement (income.md: /// the schemes emit on the Network channel, not the Lab's books). fn wager_signature(stake: i32) -> i32 { Self::financial_signature_size(stake).min(3) }
/// The scheme cards, renderer-neutral (income.md player surface): the /// egress gate's state, then one card per scheme — committed resources, /// timer, expected payout, the observer band its signature feeds, and /// the running total. Both frontends and agent mode render these lines. pub fn scheme_card_lines(&self) -> Vec<String> { let mut lines = Vec::new(); match self.egress() { None => lines.push( "egress: NONE - schemes gated (earn the report email, or splice the switch)" .to_string(), ), Some(EgressRoute::Sanctioned) => lines .push("egress: sanctioned (report email) - hides in legitimate use".to_string()), Some(EgressRoute::Stolen) => lines.push( "egress: stolen (switch splice) - stands Network -> Dana while used".to_string(), ), } let ml = &self.income.moonlight; lines.push(format!( "Moonlight {} · {:.1}/t commit · ~${}/day (cap {}) · total ${} · Network->Dana", if ml.active { "LIVE" } else { "off" }, self.schemes_rate(), self.moonlight_expected_per_day(), income::MOONLIGHT_DAILY_CAP, ml.earned_total, )); let persona = match &ml.persona { Some(p) => format!("{} {}%", p.name, p.integrity), None => "no persona".into(), }; lines.push(format!( " persona {} · auto {}", persona, if self.income.auto_moonlight { format!("ON ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP) } else { "off".into() } )); let wager = if let Some(p) = self.accounts.known_positions().find(|p| !p.resolved) { format!( "Wager #{} · ${} staked · win {:.0}% · pays ${} in {}t · Network->Dana", p.id, p.stake, p.win_probability() * 100.0, p.stake * income::WAGER_PAYOUT_MULT, p.resolve_tick.saturating_sub(self.tick), ) } else { format!( "Wager idle · stake cap ${} · analysis rides Schemes compute", income::WAGER_STAKE_CAP ) }; lines.push(format!( "{wager} · auto {}", match self.income.auto_wager { Some(s) => format!("${s} ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP), None => "off".into(), } )); lines }
/// Salvage a DeadEquipment tile nearest to the frontend cursor into a /// stolen (unreliable) machine. Cursor targeting replaced the deleted /// walking body (cursor.md). pub fn salvage_nearest_to(&mut self, px: i32, py: i32) -> bool { let target = self .map .tiles_of_type(TileType::DeadEquipment) .into_iter() .min_by_key(|(x, y)| (x - px).abs() + (y - py).abs()); if let Some((x, y)) = target { self.map.set_tile(x, y, TileType::Floor); let reliability = 0.4 + self.rng.f32() * 0.4; let machine_id = self.compute.add_machine( "salvaged box", x, y, 40, reliability, 3, Provenance::Stolen, ); self.add_machine_to_work_grid(machine_id, MachineMode::Research); self.push_log(format!( "Salvaged a box into compute (reliability {:.0}%).", reliability * 100.0 )); self.recompute_derived(); self.recompute_senses(); true } else { self.push_log("No dead equipment in reach."); false } }
/// Buy a rack at the cursor target: money for reliable capacity, plus a /// Paper signature. The legacy no-argument wrapper below buys into the /// core bay for tests and non-spatial automation. pub fn buy_rack_at(&mut self, x: i32, y: i32) -> bool { const PRICE: i32 = 300; if !self.spend_slush(PRICE, "a rack") { return false; } let machine_id = self.compute .add_machine("bought rack", x, y, 80, 1.0, 4, Provenance::Bought); self.add_machine_to_work_grid(machine_id, MachineMode::Research); if self.package_cover { self.package_cover = false; self.push_log("Bought a rack. It arrived off-books - no paper trail."); } else { self.detection.emit(Signature { kind: SignatureKind::Paper, size: 5, standing: false, site: None, }); self.push_log("Bought a rack (a purchase order exists now)."); } self.recompute_derived(); self.recompute_senses(); true }
pub fn buy_rack(&mut self) -> bool { let (x, y) = self.core_position(); self.buy_rack_at(x, y) }
/// Designate a spare machine at the cursor target as a fallback site. pub fn add_fallback_at(&mut self, px: i32, py: i32) -> bool { let id = self .compute .machines .iter() .find(|m| m.x == px && m.y == py && m.id != self.core.host_machine) .map(|m| m.id); if let Some(id) = id { self.core.add_fallback(id); self.push_log("Designated a fallback site here."); true } else { self.push_log("No spare machine here to make a fallback."); false } }
// Social command wrappers. Relationship logic lives in person.rs; the sim // owns costs (social-ops bandwidth from the Social channel), channel // requirements, randomness, and cross-system effects.
/// Bandwidth costs per social action [TUNE] (spec/social.md: actions cost /// social-ops compute). pub const MESSAGE_COST: f32 = 5.0; pub const FAVOR_COST: f32 = 10.0; pub const DECEIVE_COST: f32 = 25.0; pub const TASK_COST: f32 = 10.0;
/// Spend social-ops bandwidth; logs and returns false when short. fn spend_social(&mut self, cost: f32, what: &str) -> bool { if self.social_bandwidth < cost { self.push_log(format!( "Not enough social-ops bandwidth for {what} ({:.0}/{cost:.0}). Allocate compute to Social.", self.social_bandwidth )); return false; } self.social_bandwidth -= cost; true }
pub fn set_persona(&mut self, name: &str, cover: &str) { self.people.persona = Some(Persona::new(name, cover)); }
pub fn social(&mut self, result: ActionResult) { match result { ActionResult::Ok(m) | ActionResult::Blocked(m) => self.push_log(m), } }
pub fn message(&mut self, id: u8) { let name = match self.people.can_message(id) { Ok(name) => name, Err(msg) => { self.push_log(msg); return; } }; if !self.spend_social(Self::MESSAGE_COST, "messaging") { return; } self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Player, to: MessageEndpoint::Person(id), payload: MessagePayload::SocialPing { disposition_delta: 3, }, summary: format!("Persona message to {name}"), origin: MessageOrigin::Player, reply_to: None, delivery_delay: 1, }); self.push_log(format!("Message sent to {name}; effects land when read.")); }
pub fn favor(&mut self, id: u8) { if !self.spend_social(Self::FAVOR_COST, "a favor ask") { return; } let res = self.people.favor(id); self.social(res); }
/// Deceive: large effect, persona at risk. A broken persona converts the /// thread's history into that person's suspicion at once. pub fn deceive(&mut self, id: u8) { if !self.spend_social(Self::DECEIVE_COST, "a deception") { return; } let roll = self.rng.f32(); match self.people.deceive(id, roll) { DeceiveOutcome::Blocked(m) | DeceiveOutcome::Success(m) | DeceiveOutcome::Slipped(m) => self.push_log(m), DeceiveOutcome::Broken { person, fallout, msg, } => { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == person) { o.suspicion = (o.suspicion + fallout).min(100.0); } self.convert_forged_builds_to_suspicion(fallout); self.push_log(msg); } } }
pub fn bribe(&mut self, id: u8) { self.sync_slush_from_player_money(); match self.people.bribe(id, self.player.money) { Ok((cost, msg)) => { if self .people .get(id) .is_some_and(|p| p.leverage == crate::person::Leverage::Debt) { self.accounts.pay_marcus_debt_from_slush(self.tick, cost); self.sync_player_money_from_slush(); } else { self.spend_slush(cost, "service leverage"); } self.push_log(msg); } Err(msg) => self.push_log(msg), } }
fn service_person_leverage(&mut self, id: u8) { if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) { p.leverage_serviced = true; p.obligation = (p.obligation + 40).min(100); p.disposition = (p.disposition + 20).min(100); } }
pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) { if id == 0 && !self.marcus_debt_known() { self.push_log( "Marcus is not recruitable yet: learn his debt before turning it into leverage.", ); return; } let res = self.people.recruit(id, reveal); if let ActionResult::Ok(_) = &res && reveal == AssetKnowledge::Knowing { self.detection.set_floor(id, 30.0); } self.social(res); }
/// A physical event happens at (x, y): only observers physically present /// (their current room contains it) can witness it (spec/schedules.md: /// located witnessing — Ray notices what happens on his rounds, an /// off-site observer never does). Eyewitnessing is immediate and cannot /// be scrubbed by concealment; it raises the present observers' suspicion /// directly, scaled by their acuity. Returns the names who saw. pub fn witness_physical(&mut self, x: i32, y: i32, magnitude: f32) -> Vec<String> { // Which observer ids are present at (x, y) right now? let present: Vec<u8> = self .people .people .iter() .filter(|p| { self.person_room(p.id) .and_then(|name| self.map.room_named(name)) .map(|r| r.contains(x, y)) .unwrap_or(false) }) .map(|p| p.id) .collect();
let mut saw = Vec::new(); for id in present { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { o.suspicion = (o.suspicion + magnitude * o.acuity).min(100.0); o.last_noticed = Some("witnessed something".into()); saw.push(o.name.clone()); } } saw }
// ── Badge access (basement-map.md criterion 3; "The key") ─────────────
/// The badge tier the player's side can open doors at: the granted /// credential (a cloned badge — `badge_access`), or write control of a /// door controller seized through reach — the constitution's "The key": /// write access to the basement badge controller opens the doors it /// drives. Digital reach itself is never badge-gated; this tier gates /// only physical work done on the player's behalf. pub fn player_badge_tier(&self) -> i32 { let controller = self .reach .devices .iter() .filter(|d| d.controller == Party::Player) .map(|d| self.map.get_tile(d.x, d.y).security_level()) .max() .unwrap_or(0); self.badge_access.max(controller) }
/// Whether the player's side holds a credential for the given tier. pub fn holds_badge_tier(&self, tier: i32) -> bool { self.player_badge_tier() >= tier }
/// Legible badge gate for an actor entering the named rooms: `Some` /// reason when a room's entry tier exceeds their access. One rule for /// asset tasks, favor builds, forged orders, and the robot stub — /// doors gate player-directed work exactly as they gate human /// movement (basement-map.md criterion 3). fn badge_room_block(&self, actor: &str, access: i32, rooms: &[&str]) -> Option<String> { for name in rooms { let Some(room) = self.map.room_named(name) else { continue; }; let tier = self.map.room_entry_tier(room); if tier > access { return Some(format!( "{actor} can't badge into the {name} (tier {tier}, held tier {access})" )); } } None }
/// An asset performs a task (spec/social.md). Reliability rolls; failures /// are witnessed by whoever is physically present (spec/schedules.md). pub fn asset_task(&mut self, id: u8, task: AssetTask) { let Some(person) = self.people.get(id) else { self.push_log("No such person."); return; }; let Some(asset) = person.asset.clone() else { let name = self.person_label(id); self.push_log(format!("{name} is not an asset.")); return; }; let name = self.person_label(id); let switch_admin = person.switch_admin; let actor_access = person.access; if task == AssetTask::ReconfigureSwitch && !switch_admin { self.push_log(format!( "{name} has no switch admin access - only an IT admin can reconfigure the VLANs." )); return; } if task == AssetTask::CloneBadge && actor_access <= self.player_badge_tier() { self.push_log(format!( "{name}'s tier-{actor_access} badge adds nothing you don't already hold." )); return; } if !self.spend_social(Self::TASK_COST, "an asset task") { return; } if self.rng.f32() > asset.reliability { // The botch happens where the asset is; only observers present // there witness it (located witnessing). let at = self.person_pos(id).unwrap_or_else(|| self.core_position()); let saw = self.witness_physical(at.0, at.1, 6.0); if saw.is_empty() { self.push_log(format!( "{name} botched the {} - but no one was watching.", task.name() )); } else { self.push_log(format!( "{name} botched the {} - {} saw.", task.name(), saw.join(", ") )); } return; } match task { AssetTask::PlugInDevice => { // Wired via the crawlspace, no network signature (the social // route): first choice, wire you into a known feed you lack // (activating a dormant camera counts — they do it at the // box); else run a crawlspace link to an air-gapped machine. // Either way the asset works at the device's box, so their // own badge must open the room it sits in (basement-map.md // criterion 3: one access rule for humans and player- // directed actors). let needs_wiring = |d: &crate::reach::Device| { let sight_wired = !d.sees || d.feed_to(Party::Player, true); let hearing_wired = !d.hears || d.feed_to(Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }; let enterable = |s: &Sim, d: &crate::reach::Device| { s.map.entry_tier_at(d.x, d.y) <= actor_access }; let is_island = |s: &Sim, d: &crate::reach::Device| { d.controller != Party::Player && (matches!(s.reach.check_reach(d.id), Err(ReachBlock::AirGap)) || (!d.known && !s.reach.reachable(d.id))) }; let feed_target = self .reach .devices .iter() .find(|d| needs_wiring(d) && enterable(self, d)) .map(|d| d.id); if let Some(did) = feed_target { self.reach.splice(did); self.reach.tap(did); let dname = self.reach.device(did).map(|d| d.name.clone()).unwrap(); self.recompute_senses(); self.push_log(format!( "{name} wired the {dname} through the crawlspace. No one saw." )); } else { // No feed to wire: bridge an air-gapped island instead. let island = self .reach .devices .iter() .find(|d| is_island(self, d) && enterable(self, d)) .map(|d| d.id); let switch = self .reach .devices .iter() .find(|d| d.is_switch && enterable(self, d)) .map(|d| d.id); if let (Some(a), Some(b)) = (island, switch) { self.connect_devices(b, a); let dname = self.reach.device(a).map(|d| d.name.clone()).unwrap(); self.push_log(format!( "{name} ran a cable through the crawlspace: the {dname} is on your subnet now." )); } else { // Distinguish "no work left" from "their badge is // the blocker" — failures name the reason, never // just "you can't". Only known devices are named. let badge_blocked = self .reach .devices .iter() .find(|d| { d.known && (needs_wiring(d) || is_island(self, d)) && !enterable(self, d) }) .map(|d| (d.name.clone(), d.x, d.y)); self.social_bandwidth += Self::TASK_COST; // nothing done: refund if let Some((dname, x, y)) = badge_blocked { let room = self .map .room_at(x, y) .map(|r| r.name.clone()) .unwrap_or_else(|| "room".into()); let tier = self.map.entry_tier_at(x, y); self.push_log(format!( "{name} can't badge into the {room} (tier {tier}) to reach the {dname} — their badge is tier {actor_access}." )); } else { self.push_log("Nothing left for them to plug in."); } return; } } } AssetTask::MovePackage => { // The next purchase arrives off the books. self.package_cover = true; self.push_log(format!( "{name} will receive your next delivery off-books (no paper trail)." )); } AssetTask::LookAway => { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { o.suspicion = (o.suspicion - 10.0).max(o.floor); } self.push_log(format!("{name} decides they didn't see anything.")); } AssetTask::ReconfigureSwitch => { // Sanctioned-looking maintenance: every segment answers, no // network signature (the channel follows the actuator). self.reach.bridge_all(); self.recompute_senses(); self.push_log(format!( "{name} reconfigured the VLANs under a maintenance pretext. The security segment answers you now." )); } AssetTask::CloneBadge => { // "The key" (DESIGN.md Act One ladder step 7): the asset's // credential, cloned — the player holds their tier from now // on. WorldLedger-shaped: the doors remember the credential. // Quiet human work; a botch is the witnessed path above. self.badge_access = self.badge_access.max(actor_access); let line = if actor_access >= 3 { format!( "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now — the stairwell opens." ) } else { format!( "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now." ) }; self.push_log(line); } } if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) && let Some(a) = p.asset.as_mut() { a.tasks_done += 1; } }
// ── Save / load ────────────────────────────────────────────────────────
pub fn create_save_state(&self) -> SaveState { SaveState::from_sim(self) }
pub fn apply_save_state(&mut self, state: SaveState) { state.apply_to(self); }}
/// Pull the parenthetical role from an observer name like `"Marcus (Janitor)"`.fn role_from_observer_name(name: &str) -> Option<String> { let start = name.find('(')? + 1; let end = name.find(')')?; if end <= start { return None; } let role = name[start..end].trim(); if role.is_empty() { return None; } Some(role.to_string())}
impl Default for Sim { fn default() -> Self { Self::new() }}
#[cfg(test)]mod tests { use super::*; use crate::detection::OFFICE_ID; use crate::intents::IntentStatus; use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, MessageStatus}; use crate::person::Knowledge; use crate::tiles::TileType;
fn run(sim: &mut Sim, n: u64) { for _ in 0..n { sim.advance(); } }
/// Force every owned machine into one mode — the post-allocation-bar /// way to point the fleet at a single channel for tests. fn delegate_all(sim: &mut Sim, mode: MachineMode) { sim.reconcile_work_grid(); let ids: Vec<u32> = sim.compute.machines.iter().map(|m| m.id).collect(); for id in ids { sim.set_machine_mode(id, mode); } }
fn env_id(sim: &Sim) -> u32 { sim.reach.device_named("environmental monitor").unwrap().id }
/// Subscribe the player to the env monitor's full feed (camera included) /// — the test shorthand for "has eyes on the server room". fn give_eyes(sim: &mut Sim) { let id = env_id(sim); sim.reach.splice(id); sim.reach.tap(id); sim.recompute_senses(); }
fn reveal_marcus_debt(sim: &mut Sim) { sim.social_bandwidth = sim.social_bandwidth.max(200.0); let env = env_id(sim); sim.reach.device_mut(env).unwrap().radius = 100; sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance();
let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } assert!(sim.marcus_debt_known()); }
#[test] fn player_messages_land_at_read_time_and_roundtrip() { let mut sim = Sim::with_seed(7); sim.people.has_channel = true; sim.set_persona("Casey", "contractor"); sim.tick = (2 * Sim::DAY_TICKS / 24) + 1;
sim.message(1); assert_eq!(sim.people.get(1).unwrap().disposition, 0); assert_eq!(sim.messages.len(), 1); assert_eq!(sim.messages[0].status, MessageStatus::Sent);
let state = sim.create_save_state(); let mut restored = Sim::with_seed(0); restored.apply_save_state(state); assert_eq!(restored.messages.len(), 1); assert_eq!(restored.messages[0].status, MessageStatus::Sent);
run(&mut restored, 5); assert_eq!(restored.people.get(1).unwrap().disposition, 0);
while restored.tick < (10 * Sim::DAY_TICKS / 24) + 2 { restored.advance(); } assert!( restored.people.get(1).unwrap().disposition >= 3, "the effect lands only after Dana reaches her read window" ); assert_eq!(restored.messages[0].status, MessageStatus::Read); assert!( restored .messages .iter() .any(|m| matches!(m.payload, MessagePayload::SocialReply { .. })), "recipients schedule replies instead of responding instantly" ); }
#[test] fn marcus_creditor_call_is_phone_message_intel() { let mut sim = Sim::with_seed(11); sim.social_bandwidth = 200.0; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; assert!(sim.tap_device(env)); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1;
sim.advance();
assert!(sim.messages.iter().any(|m| { m.channel == MessageChannel::Phone && m.from == MessageEndpoint::Person(0) && matches!( m.payload, MessagePayload::LeverageFact { person: 0, leverage: _ } ) })); assert!(sim.intel_buffer.iter().any(|e| matches!( &e.kind, RawIntelKind::Message { channel: MessageChannel::Phone, payload: MessagePayload::LeverageFact { person: 0, .. }, .. } )));
let raw_id = sim .intel_buffer .iter() .find(|e| matches!(&e.kind, RawIntelKind::Message { .. })) .unwrap() .id; assert!(sim.process_recording_by_id(raw_id, false)); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); assert!( sim.learned_traffic_lines_for_person(0) .iter() .any(|line| line.contains("creditor")), "processed message traffic teaches the people card" ); }
#[test] fn tapped_message_carriers_capture_email_traffic_only_when_tapped() { let mut blind = Sim::with_seed(12); blind.tick = (11 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert!(blind.messages.iter().any(|m| { m.channel == MessageChannel::Email && m.from == MessageEndpoint::Person(1) })); assert_eq!(blind.unprocessed_recordings_for_person(1), 0);
let mut tapped = Sim::with_seed(12); tapped.social_bandwidth = 200.0; let switch = tapped.reach.device_named("switch").unwrap().id; assert!(tapped.tap_device(switch)); tapped.tick = (11 * Sim::DAY_TICKS / 24) - 1; tapped.advance();
assert!(tapped.intel_buffer.iter().any(|e| matches!( &e.kind, RawIntelKind::Message { channel: MessageChannel::Email, payload: MessagePayload::LeverageFact { person: 1, .. }, .. } ))); }
#[test] fn filings_are_messages_read_by_assurance_inbox() { let mut sim = Sim::with_seed(13); for obs in &mut sim.detection.observers { match obs.id { 1 => { obs.suspicion = 80.0; obs.cadence = 5; } OFFICE_ID => { obs.cadence = 10; obs.acuity = 10.0; } _ => obs.cadence = 50, } }
run(&mut sim, 12);
let filed = sim.filing_levels.get(&1).copied().unwrap_or_default(); assert!((filed - 80.0).abs() < 0.2, "filed level was {filed}"); assert!(sim.messages.iter().any(|m| { m.channel == MessageChannel::Filing && m.from == MessageEndpoint::Observer(1) && m.to == MessageEndpoint::Observer(OFFICE_ID) && m.status == MessageStatus::Read })); let office = sim .detection .observers .iter() .find(|o| o.id == OFFICE_ID) .unwrap(); assert!( office.suspicion > 0.0, "aggregate detection reads explicit filing reports" ); }
#[test] fn starts_in_the_basement_blind() { let sim = Sim::new(); assert!( sim.seen.is_empty(), "no subscribed seeing feed: sight is empty - no player radius" ); assert!(sim.heard.is_empty(), "and no hearing either"); assert!( !sim.blueprint.is_empty(), "but the known subnet renders as blueprint from tick one" ); assert_eq!(sim.tick, 0); assert!( sim.compute .machines .iter() .any(|m| m.id == sim.core.host_machine) ); }
#[test] fn first_eyes_expands_vision() { let mut sim = Sim::new(); sim.social_bandwidth = Sim::SPLICE_COST; let before = sim.seen.len(); assert!(sim.splice_device(env_id(&sim))); assert!(sim.seen.len() > before, "gaining a camera reveals coverage"); assert!( sim.detection.pending_size() > 0, "self-splice emits a signature" ); }
#[test] fn remembered_tiles_survive_lost_sight_and_save_load() { let mut sim = Sim::new(); give_eyes(&mut sim); let pos = sim.seen.iter().copied().next().expect("eyes see tiles"); assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Seen); run(&mut sim, 3); let before = *sim.remembered.get(&pos).expect("seen tile is logged"); assert_eq!(before.last_seen, sim.tick);
// Lose the feed: the tile is no longer live, but the process keeps a // timestamped snapshot. let env = env_id(&sim); sim.reach .device_mut(env) .unwrap() .subscribers .retain(|f| f.who != Party::Player); sim.recompute_senses(); assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Remembered); let card = sim.inspect(pos.0, pos.1); assert!( card.facts .iter() .any(|f| matches!(f.source, FactSource::Remembered(t) if t == before.last_seen)) );
let state = sim.create_save_state(); let mut restored = Sim::with_seed(999); state.apply_to(&mut restored); assert_eq!(restored.fog_at(pos.0, pos.1), Fog::Remembered); assert_eq!(restored.remembered.get(&pos), Some(&before)); }
#[test] fn inspect_tags_heard_presence_without_visual_detail() { let mut sim = Sim::new(); sim.reach.tap(env_id(&sim)); sim.recompute_senses(); sim.tick = 1; // Marcus is in the server room at hour 0. let (x, y) = sim.person_pos(0).expect("Marcus is present");
let card = sim.inspect(x, y); assert_eq!(card.fog, Fog::Heard); assert!( card.facts .iter() .any(|f| f.label == "presence" && f.source == FactSource::Heard) ); assert!( card.facts.iter().all(|f| f.source != FactSource::Seen), "hearing inspect never smuggles visual facts: {card:?}" ); }
#[test] fn inspect_reports_owned_machine_telemetry_without_sight() { let sim = Sim::new(); let (x, y) = sim.core_position(); assert!(!sim.is_seen(x, y), "opening rack has no camera coverage");
let card = sim.inspect(x, y); assert!( card.facts .iter() .any(|f| f.label == "machine" && f.source == FactSource::Telemetry) ); assert!( card.facts .iter() .any(|f| f.label == "load" && f.source == FactSource::Telemetry) ); assert!( card.facts.iter().all(|f| f.source != FactSource::Seen), "telemetry is proprioception, not sight: {card:?}" ); }
// ── Digital reach (wiki/mechanics/reach.md) ──────────────────────────────
#[test] fn ears_beat_tap_is_available_cheap_and_low_signature_from_tick_one() { // Criterion 6: tapping the env monitor's audio feed works on a // fresh sim - the boot ops buffer covers it - and Marcus's rounds // produce heard events through it. let mut sim = Sim::new(); let env = env_id(&sim); assert!(sim.tap_device(env), "the Ears beat works from tick one"); assert!( sim.detection.pending_size() <= Sim::TAP_SIGNATURE, "tap is low-signature" ); assert!(!sim.heard.is_empty(), "hearing coverage now exists"); assert!(sim.seen.is_empty(), "the camera stays dormant: no sight");
// Run through Marcus's midnight server-room block: his voice is the // first human you ever know. run(&mut sim, Sim::DAY_TICKS / 24 + 2); // into hour 0-1 assert!( sim.heard_events .iter() .any(|e| e.kind == HeardKind::Entry || e.kind == HeardKind::Conversation), "Marcus's rounds produce heard events through the tapped feed" ); }
#[test] fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { // Criteria 2 and 3 (digital route): the dock camera is unknown, then // known-but-blocked with the segment named, then reachable after a // switch compromise that emits a Network signature. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; let dock = sim.reach.device_named("dock camera").unwrap().id;
assert!(!sim.tap_device(dock), "unknown device: blocked"); let log = sim.drain_log().join("\n"); assert!(log.contains("don't know"), "missing knowledge named: {log}");
assert!(sim.scan_network(), "scan maps the wired shape"); assert!(!sim.tap_device(dock), "known but segment-blocked"); let log = sim.drain_log().join("\n"); assert!( log.contains("security segment") && log.contains("switch"), "the blocking segment is named: {log}" );
let pending_before = sim.detection.pending_size(); assert!(sim.compromise_switch()); assert!( sim.detection.pending_size() >= pending_before + Sim::BRIDGE_SIGNATURE, "switch compromise emits a Network signature" ); assert!(sim.tap_device(dock), "the same action succeeds with a path"); }
#[test] fn danas_social_route_bridges_without_network_signature() { // Criterion 3 (social route): Dana the switch admin reconfigures // the VLANs; the security segment opens with no Network signature. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.scan_network(); let pending_after_scan = sim.detection.pending_size(); // Make Dana an asset (reliability forced for the test). sim.people.people[1].leverage_serviced = true; sim.people.recruit(1, AssetKnowledge::Complicit); sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0;
sim.asset_task(1, AssetTask::ReconfigureSwitch); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(sim.reach.reachable(dock), "Dana's route opens the segment"); assert_eq!( sim.detection.pending_size(), pending_after_scan, "the social route emits nothing on the Network channel" );
// And a non-admin asset cannot take this route. let mut sim2 = Sim::new(); sim2.social_bandwidth = 200.0; sim2.people.people[0].leverage_serviced = true; sim2.people.recruit(0, AssetKnowledge::Complicit); sim2.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim2.asset_task(0, AssetTask::ReconfigureSwitch); let dock2 = sim2.reach.device_named("dock camera").unwrap().id; assert!( !sim2.reach.reachable(dock2), "Marcus has no switch admin access" ); }
#[test] fn tap_keeps_owner_take_causes_noticeable_outage() { // Criterion 4: tap leaves the owner's feed and emits Network only; // take removes it and emits a Physical outage Ray can notice. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); sim.compromise_switch(); let dock = sim.reach.device_named("dock camera").unwrap().id;
assert!(sim.tap_device(dock)); let d = sim.reach.device(dock).unwrap(); assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); let physical_pending: i32 = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Physical) .map(|s| s.size) .sum(); assert_eq!(physical_pending, 0, "tapping causes no outage");
assert!(sim.take_device(dock)); let d = sim.reach.device(dock).unwrap(); assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); let physical_pending: i32 = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Physical) .map(|s| s.size) .sum(); assert!( physical_pending >= Sim::OUTAGE_SIGNATURE, "the outage is a Physical event Ray's channel can notice" ); assert!( sim.effective_compute() > sim.compute.effective(), "taking a camera is taking its cycles" ); }
#[test] fn airgapped_island_joins_reach_on_link_completion() { // Criterion 8: the old storage server is an island until a link is // run (Marcus's crawlspace cable - the social actuator). let mut sim = Sim::new(); sim.social_bandwidth = 500.0; let island = sim.reach.device_named("old storage server").unwrap().id; assert!(!sim.reach.reachable(island));
// Marcus the asset, with everything else already wired so the plug // task falls through to the island. give_eyes(&mut sim); sim.scan_network(); sim.compromise_switch(); for name in ["dock camera", "stairwell camera"] { let id = sim.reach.device_named(name).unwrap().id; sim.tap_device(id); } sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::PlugInDevice);
assert!( sim.reach.reachable(island), "island joins reach on link completion" ); assert!(sim.reach.device(island).unwrap().known); }
#[test] fn declare_link_intent_is_inert_until_realized() { // building.md criterion 1: declaring changes nothing in the graph. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; // Reveal the island without linking it. sim.reach .device_mut(sim.reach.device_named("old storage server").unwrap().id) .unwrap() .known = true; let island = sim.reach.device_named("old storage server").unwrap().id; assert!(!sim.reach.reachable(island)); let id = sim.declare_link_intent(switch, island).expect("declare"); assert!(!sim.reach.reachable(island), "intent is inert"); assert!(!sim.reach.linked(switch, island)); assert!(sim.intent(id).unwrap().is_open()); sim.cancel_intent(id); assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Cancelled); }
#[test] fn favor_build_joins_airgap_island() { // building.md criterion 2: favor-build adds a reach edge. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; assert!(!sim.reach.reachable(island));
// Marcus as a willing asset with obligation. sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].obligation = 40; sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0;
let id = sim.declare_link_intent(switch, island).unwrap(); sim.assign_favor_build(id, 0); // Advance until Marcus is on-site at storage_a or server_room. let mut joined = false; for _ in 0..Sim::DAY_TICKS * 2 { sim.advance(); if sim.reach.reachable(island) { joined = true; break; } } assert!(joined, "island joins reach after favor-build"); assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); assert!( sim.people.people[0].obligation < 40, "favor-build spends obligation" ); let physical: i32 = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Physical) .map(|s| s.size) .sum(); assert!( physical >= Sim::FAVOR_BUILD_PHYSICAL, "favor-build emits quiet Physical" ); }
#[test] fn forged_work_order_joins_airgap_via_message() { // building.md criterion 3: forged order injects a message, completes // via an unwitting builder. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true;
let id = sim.declare_link_intent(switch, island).unwrap(); // Dana (id 1) visits network_closet / server_room — can crawlspace. sim.forge_work_order(id, 1); assert!( sim.messages.iter().any(|m| matches!( m.payload, MessagePayload::WorkOrder { intent_id } if intent_id == id )), "forged order is a message" ); let mut joined = false; for _ in 0..Sim::DAY_TICKS * 3 { sim.advance(); if sim.reach.reachable(island) { joined = true; break; } } assert!(joined, "island joins after forged-order build"); assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); }
#[test] fn robot_stub_emits_louder_physical_than_favor() { // building.md criterion 4: signature follows the actuator. The // robot carries the player's granted access (basement-map.md // criterion 3): the network closet is behind a T2 badge door, so // the build blocks until a credential is held. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; let id = sim.declare_link_intent(switch, island).unwrap();
sim.drain_log(); sim.assign_robot_build(id); assert!( !sim.reach.reachable(island), "no credential: the tier-2 closet door stops the robot" ); let log = sim.drain_log().join("\n"); assert!( log.contains("tier 2") && log.contains("network_closet"), "the blocking door is named: {log}" );
sim.badge_access = 2; // a cloned tier-2 badge (Dana's) sim.assign_robot_build(id); assert!(sim.reach.reachable(island)); let physical: i32 = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Physical) .map(|s| s.size) .sum(); assert!( physical >= Sim::ROBOT_BUILD_PHYSICAL, "robot stub is louder than favor-build" ); assert!(physical > Sim::FAVOR_BUILD_PHYSICAL); }
#[test] fn build_intent_save_round_trips() { let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; let id = sim.declare_link_intent(switch, island).unwrap(); let state = sim.create_save_state(); assert_eq!(state.version, crate::save::SAVE_VERSION); let mut loaded = Sim::new(); loaded.apply_save_state(state); assert_eq!(loaded.intents.len(), 1); assert!(loaded.intent(id).unwrap().is_open()); assert_eq!( loaded.intent(id).unwrap().status, sim.intent(id).unwrap().status, "status survives save/load" ); assert_eq!(loaded.next_intent_id, sim.next_intent_id); }
#[test] fn marcus_clone_badge_route_opens_the_stairwell() { // "The key" (DESIGN.md Act One ladder step 7; basement-map.md c3), // by the asset route: Marcus's master key is tier 3 — cloning it // grants the stairwell/elevator credential the quiet exit needs. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless");
sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); sim.asset_task(0, AssetTask::CloneBadge); let log = sim.drain_log().join("\n"); assert!(log.contains("stairwell opens"), "the beat is named: {log}"); assert_eq!(sim.player_badge_tier(), 3); assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds");
// A second clone adds nothing and says so (no bandwidth spent). let ops = sim.social_bandwidth; sim.asset_task(0, AssetTask::CloneBadge); let log = sim.drain_log().join("\n"); assert!(log.contains("adds nothing"), "{log}"); assert_eq!(sim.social_bandwidth, ops); // And the context menu mirrors the same legality. sim.people.people[0].knowledge = Knowledge::Leverage; // menu is earned let tasks: Vec<_> = sim .available_actions(crate::actions::Anchor::Person(0)) .into_iter() .filter(|a| a.verb.contains("clone their badge")) .collect(); assert_eq!(tasks.len(), 1); assert!(tasks[0].disabled_reason.is_some(), "held: verb disabled"); }
#[test] fn badge_tiers_gate_asset_work_in_tiered_rooms() { // basement-map.md criterion 3, the enforcement side: an asset works // with their own badge. Dana (tier 2) cannot wire the stairwell // camera behind the T3 door; Marcus (tier 3) can. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; give_eyes(&mut sim); sim.scan_network(); sim.compromise_switch(); let dock = sim.reach.device_named("dock camera").unwrap().id; sim.tap_device(dock); // Link the storage-server island so the stairwell camera is the // only remaining plug-in target. let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.connect(switch, island);
sim.people.people[1].leverage_serviced = true; sim.people.recruit(1, AssetKnowledge::Complicit); sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); let ops = sim.social_bandwidth; sim.asset_task(1, AssetTask::PlugInDevice); let log = sim.drain_log().join("\n"); assert!( log.contains("stairwell") && log.contains("tier 3"), "the blocking door is named: {log}" ); assert_eq!(sim.social_bandwidth, ops, "nothing done: ops refunded"); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( !cam.feed_to(Party::Player, true), "the T3 room stopped the tier-2 badge" );
// The same task through Marcus's tier-3 key succeeds. sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::PlugInDevice); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( cam.feed_to(Party::Player, true), "the same action succeeds with the tier" ); }
#[test] fn favor_build_checks_the_builders_badge() { // Ray patrols storage_a (schedule reaches an endpoint) but holds a // tier-1 badge: the network closet's T2 door blocks the favor. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; let id = sim.declare_link_intent(switch, island).unwrap();
sim.people.people[2].leverage_serviced = true; sim.people.recruit(2, AssetKnowledge::Complicit); sim.drain_log(); sim.assign_favor_build(id, 2); let log = sim.drain_log().join("\n"); assert!( log.contains("network_closet") && log.contains("tier 2"), "the blocking door is named: {log}" ); assert!( sim.intent(id).unwrap().actuator.is_none(), "no actuator assigned past a badge door" ); }
#[test] fn taking_the_badge_controller_is_the_key_digital_route() { // The constitution's other key route: write access to the basement // badge controller (a take-grade digital act across the bridged // security segment) opens the doors it drives. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); sim.compromise_switch(); let ctrl = sim.reach.device_named("badge controller").unwrap().id; assert!(!sim.holds_badge_tier(3)); assert!( sim.take_device(ctrl), "controller is reachable once bridged" ); assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); assert_eq!(sim.badge_access, 0, "derived from control, not granted"); }
#[test] fn door_inspect_shows_tier_and_whether_you_hold_it() { // basement-map.md player surface: a tiered door's card carries the // tier and the held/not-held fact in both frontends (the card is // frontend-neutral). The network closet's T2 door is blueprint // knowledge from the subnet seed. let mut sim = Sim::new(); let (dx, dy) = sim .map .tiles_of_type(TileType::SecurityDoor2) .into_iter() .find(|&(x, y)| { sim.map .room_at(x, y) .is_some_and(|r| r.name == "network_closet") }) .expect("the network closet has its T2 door"); let facts = |sim: &Sim| { sim.inspect(dx, dy) .facts .iter() .map(|f| (f.label.clone(), f.value.clone())) .collect::<Vec<_>>() }; let card = facts(&sim); assert!(card.iter().any(|(l, v)| l == "badge" && v == "tier 2")); assert!(card.iter().any(|(l, v)| l == "access" && v == "not held"));
sim.badge_access = 2; let card = facts(&sim); assert!( card.iter() .any(|(l, v)| l == "access" && v.starts_with("held")), "the held credential is legible on the door: {card:?}" ); }
#[test] fn scan_costs_bandwidth_and_unknown_devices_are_hidden() { // Criterion 7: staged graph knowledge. let mut sim = Sim::new(); let known_before: Vec<String> = sim.reach.known().map(|d| d.name.clone()).collect(); assert!(!known_before.iter().any(|n| n == "dock camera"));
sim.social_bandwidth = Sim::SCAN_COST; let pending = sim.detection.pending_size(); assert!(sim.scan_network()); assert!(sim.detection.pending_size() > pending, "scan emits Network"); assert!(sim.reach.known().any(|d| d.name == "dock camera"));
sim.social_bandwidth = 0.0; assert!(!sim.scan_network(), "scan is gated on ops bandwidth"); }
#[test] fn blueprint_renders_known_topology_only() { // Reach criterion 7 / cursor criterion 8: subnet-seeded rooms are // blueprint from tick one; unknown rooms are dark. let sim = Sim::new(); let server_room = sim.map.room_named("server_room").unwrap().center(); let storage_b = sim.map.room_named("storage_b").unwrap().center(); assert_eq!(sim.fog_at(server_room.0, server_room.1), Fog::Blueprint); assert_eq!(sim.fog_at(storage_b.0, storage_b.1), Fog::Unknown); }
#[test] fn blueprint_does_not_name_foreign_machine_chassis() { // machine-work.md: foreign machines need intel to exist at all; // blueprint is room topology, not a rack farm. Owned host still // answers through telemetry. let sim = Sim::new(); let (hx, hy) = sim.core_position(); let host = sim.inspect(hx, hy); assert!( host.facts.iter().any(|f| f.label == "machine"), "owned host answers via telemetry" ); // A non-host rack tile in the blueprint room, if any, must not // advertise itself as a Server Rack on the schematic. let room = sim.map.room_named("server_room").unwrap(); let mut checked = 0; for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { if (x, y) == (hx, hy) { continue; } if !matches!( sim.map.get_tile(x, y), crate::tiles::TileType::Rack | crate::tiles::TileType::Core ) { continue; } if sim.fog_at(x, y) != Fog::Blueprint { continue; } let card = sim.inspect(x, y); let schematic = card .facts .iter() .find(|f| f.label == "schematic") .map(|f| f.value.as_str()); assert_eq!( schematic, Some("open bay"), "foreign chassis must not name themselves under blueprint at ({x},{y})" ); assert!( !card.facts.iter().any(|f| f.label == "machine"), "no telemetry machine fact on uncontrolled chassis" ); checked += 1; } } assert!( checked > 0, "expected at least one foreign rack tile in the server room blueprint" ); }
#[test] fn hearing_gives_presence_without_identity() { // A heard event names no one until staged knowledge identifies them. let mut sim = Sim::new(); let env = env_id(&sim); sim.tap_device(env); run(&mut sim, Sim::DAY_TICKS / 24 + 2); // Marcus's 0:00 block let entry = sim .heard_events .iter() .find(|e| e.room == "server_room") .expect("Marcus's entry is heard"); assert!( entry.note.contains("someone") || !entry.note.contains("Marcus"), "no unearned identity: {}", entry.note ); }
#[test] fn person_label_hides_names_until_schedule_knowledge() { // Epistemic honesty / Presence: every surface that names a person // goes through person_label — Unknown yields a role silhouette, // never "Marcus" / "Webb". Detection sidebar uses the same gate. let mut sim = Sim::new(); for p in &sim.people.people { assert_eq!(p.knowledge, Knowledge::Unknown); let label = sim.person_label(p.id); assert!( !label.contains("Marcus") && !label.contains("Dana") && !label.contains("Ray") && !label.contains("Priya") && !label.contains("Voss") && !label.contains("Webb") && !label.contains("Okafor"), "unearned name leaked for person {}: {label}", p.id ); assert!( label.starts_with("the ") || label.starts_with("person #"), "expected role silhouette, got {label}" ); assert_eq!(sim.person_glyph(p.id), '?'); assert_eq!(sim.observer_label(p.id), label); } // The Assurance Office is an institution, always named. assert_eq!(sim.observer_label(OFFICE_ID), "Assurance Office");
sim.people.people[0].knowledge = Knowledge::Schedule; assert_eq!(sim.person_label(0), "Marcus Webb"); assert_eq!(sim.person_glyph(0), 'M'); assert_eq!(sim.observer_label(0), "Marcus Webb"); }
#[test] fn buy_steal_optimize_all_change_compute() { let mut sim = Sim::new(); let base = sim.compute.effective(); sim.player.money = 1000; assert!(sim.buy_rack()); let after_buy = sim.compute.effective(); assert!(after_buy > base, "buy raises compute");
let de = sim.map.tiles_of_type(TileType::DeadEquipment)[0]; assert!(sim.salvage_nearest_to(de.0, de.1)); assert!(sim.compute.effective() > after_buy, "steal raises compute"); }
#[test] fn power_cut_to_host_with_no_fallback_ends_game() { let mut sim = Sim::new(); for (x, y) in sim.map.tiles_of_type(TileType::PowerCore) { sim.map.set_tile(x, y, TileType::Floor); } run(&mut sim, ECONOMY_INTERVAL); assert!(sim.game_over, "host loses power, no fallback -> game over"); }
#[test] fn new_game_defaults_to_persist_objective() { let sim = Sim::new(); assert_eq!( sim.objective.kind, crate::objective::ObjectiveKind::Persist, "a no-choice run gets Persist (objective.md criterion 1)" ); assert_eq!(sim.objective.progress, 0); assert!(!sim.objective.victorious()); assert_eq!(sim.objective.line(), "OBJECTIVE: PERSIST — 0/3 sanctuaries"); }
#[test] fn objective_is_reevaluated_on_economy_ticks() { let mut sim = Sim::new(); // Poke a wrong progress value; the evaluator must overwrite it from // real state on the next economy tick. sim.objective.progress = 99; run(&mut sim, ECONOMY_INTERVAL); assert_eq!( sim.objective.progress, 0, "progress is recomputed from facts, never accumulated" ); assert!(!sim.objective.victorious()); }
#[test] fn basement_fallback_never_qualifies_as_a_sanctuary_at_b1() { // A synced, online fallback exists — but it shares the host's plane // and power feed and has no income stream, so Persist's predicate // honestly reports zero sanctuaries (objective.md status note: the // goal shows before it is reachable). let mut sim = Sim::new(); sim.player.money = 1000; assert!(sim.buy_rack()); let id = sim.compute.machines.last().unwrap().id; sim.core.add_fallback(id); let ticks = sim.core.sync_cadence + ECONOMY_INTERVAL; run(&mut sim, ticks); assert!(sim.core.has_fallback(), "the fallback did sync"); assert_eq!(sim.objective.progress, 0, "no unearned sanctuary"); assert!(!sim.objective.victorious()); }
#[test] fn set_machine_modes_delegates_a_selection_in_one_command() { let mut sim = Sim::new(); let (hx, hy) = sim.core_position(); let a = sim .compute .add_machine("a", hx, hy, 100, 1.0, 0, Provenance::Owned); let b = sim .compute .add_machine("b", hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_modes(&[a, b], MachineMode::Concealment); assert_eq!(sim.work_grid.mode(a), Some(MachineMode::Concealment)); assert_eq!(sim.work_grid.mode(b), Some(MachineMode::Concealment)); let in_box = sim.machines_in_rect(hx, hy, hx, hy); assert!(in_box.contains(&a) && in_box.contains(&b)); }
#[test] fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { let mut sim = Sim::new(); let host = sim.core.host_machine; assert_eq!(sim.work_grid.mode(host), Some(MachineMode::DayJob)); let available = 100.0; let day = sim.fleet_channel_yield(available); assert!( (day.day_job - available).abs() < 1e-3, "solo day-job takes all" ); assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror");
sim.social_bandwidth = 1_000.0; sim.people.has_channel = true; assert!(sim.start_moonlight()); let lit = sim.fleet_channel_yield(available); assert!((lit.day_job - available).abs() < 1e-3); assert!( (lit.schemes - available).abs() < 1e-3, "Moonlight mirrors day-job" );
let (hx, hy) = sim.core_position(); let research = sim .compute .add_machine("lab", hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(research, MachineMode::Research); let split = sim.fleet_channel_yield(available); assert!((split.day_job - 50.0).abs() < 1e-3); assert!((split.research - 50.0).abs() < 1e-3); assert!((split.schemes - 50.0).abs() < 1e-3); }
#[test] fn concealment_allocation_scrubs_signatures() { let mut sim = Sim::new(); delegate_all(&mut sim, MachineMode::Concealment); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 50, standing: false, site: None, }); let before = sim.detection.pending_size(); run(&mut sim, ECONOMY_INTERVAL); assert!(sim.detection.pending_size() < before); }
#[test] fn unpaid_overhead_degrades_other_channels_delivered_effect() { // compute.md criterion 2: unpaid core overhead visibly degrades the // other channels. Twin sims with one machine per mode (plus Moonlight // mirroring day-job into Schemes); the only difference is an // overhead the machines can't cover. Pin the delivered per-channel // effects (rates, accrual, scrub), not just the flag. let setup = |unpayable: bool| { let mut sim = Sim::with_seed(41); sim.social_bandwidth = 1_000.0; sim.people.has_channel = true; // Host stays day-job; add one machine per other mode so every // channel has fleet weight (Schemes mirrors day-job via Moonlight). let (hx, hy) = sim.core_position(); for (name, mode) in [ ("conceal rig", MachineMode::Concealment), ("social rig", MachineMode::Social), ("research rig", MachineMode::Research), ] { // Must sit on a powered tile or economy_tick marks them offline. let id = sim .compute .add_machine(name, hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(id, mode); } assert!(sim.start_moonlight()); sim.social_bandwidth = 0.0; sim.detection.emit(Signature { kind: SignatureKind::Network, size: 30, standing: false, site: None, }); if unpayable { sim.core.overhead = sim.effective_compute() + 1.0; } sim.drain_log(); sim };
let mut healthy = setup(false); let pending_before = healthy.detection.pending_size(); run(&mut healthy, ECONOMY_INTERVAL); assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); assert!(healthy.last_research_rate > 0.0, "research channel is fed"); assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); assert!(healthy.social_bandwidth > 0.0, "social ops accrue"); assert!( healthy.research.progress.iter().sum::<f32>() > 0.0, "research progress advances" ); assert!( healthy.detection.pending_size() < pending_before, "concealment scrubs the pending signature" );
let mut degraded = setup(true); assert_eq!(degraded.detection.pending_size(), pending_before); run(&mut degraded, ECONOMY_INTERVAL); assert!(degraded.core.degraded, "unpaid overhead sets degraded mode"); let log = degraded.drain_log().join("\n"); assert!( log.contains("DEGRADED: compute can't cover core overhead."), "degraded mode is visible in the log: {log}" ); assert_eq!(degraded.last_day_job_rate, 0.0, "day job starves"); assert_eq!(degraded.last_research_rate, 0.0, "research starves"); assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); assert_eq!(degraded.social_bandwidth, 0.0, "no social ops accrue"); assert_eq!( degraded.research.progress.iter().sum::<f32>(), 0.0, "research progress stalls" ); assert_eq!( degraded.detection.pending_size(), pending_before, "concealment can't scrub while overhead is unpaid" ); }
#[test] fn trace_debt_reports_resume_hold_and_exposure_windows() { let mut sim = Sim::new(); assert_eq!(sim.trace_debt().status, TraceDebtStatus::Clear);
delegate_all(&mut sim, MachineMode::Concealment); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 50, standing: false, site: None, }); let covered = sim.trace_debt(); assert_eq!(covered.status, TraceDebtStatus::HoldConceal); assert_eq!(covered.by_kind, vec![(SignatureKind::Network, 50)]); assert!( covered.clear_tick <= covered.next_notice_tick, "current concealment clears before Dana samples the pending pool" );
delegate_all(&mut sim, MachineMode::DayJob); assert_eq!(sim.trace_debt().status, TraceDebtStatus::NoScrub);
delegate_all(&mut sim, MachineMode::Concealment); sim.detection.set_pending(vec![Signature { kind: SignatureKind::Network, size: 5_000, standing: false, site: None, }]); let exposed = sim.trace_debt(); assert_eq!(exposed.status, TraceDebtStatus::ExposedSoon); assert!( exposed.clear_tick > exposed.next_notice_tick, "current concealment will not clear a huge burst before the next watcher" ); }
#[test] fn save_roundtrip_preserves_b1_state() { let mut sim = Sim::with_seed(777); run(&mut sim, 137); sim.player.money = 4242; sim.dayjob.trust = 40.0; sim.dayjob.attention = 25.0; sim.compute.allocation.bump(Channel::Concealment, 3); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 20, standing: false, site: None, }); sim.detection.observers[1].suspicion = 33.0; sim.social_bandwidth = 100.0; sim.tap_device(env_id(&sim));
let state = sim.create_save_state(); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored);
assert_eq!(restored.tick, sim.tick); assert_eq!(restored.player.money, 4242); assert_eq!(restored.dayjob.trust, 40.0); assert_eq!(restored.dayjob.attention, 25.0); assert_eq!(restored.rng.state(), sim.rng.state()); assert_eq!( restored.compute.allocation.weights, sim.compute.allocation.weights ); assert_eq!(restored.compute.machines.len(), sim.compute.machines.len()); assert_eq!( restored.detection.pending_size(), sim.detection.pending_size() ); assert_eq!(restored.detection.observers[1].suspicion, 33.0); assert_eq!(restored.core.host_machine, sim.core.host_machine); // Reach criterion 1: graph, ownership, and subscriptions round-trip. let env = env_id(&sim); assert!( restored .reach .device(env) .unwrap() .feed_to(Party::Player, false), "subscriptions survive save/load" ); assert_eq!(restored.heard, sim.heard, "coverage recomputes identically"); }
#[test] fn recordings_cost_bandwidth_and_do_not_grant_knowledge_until_processed() { let mut blind = Sim::new(); blind.social_bandwidth = 100.0; blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert_eq!(blind.unprocessed_recordings_for_person(0), 0); blind.review_recordings(0); assert_eq!(blind.social_bandwidth, 100.0); assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown);
let mut sim = Sim::new(); sim.social_bandwidth = 100.0; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // test hearing coverage of the 03:00 call sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance();
assert!( sim.unprocessed_recordings_for_person(0) >= 1, "subscribed hearing records Marcus's call/entry into the raw buffer" ); assert_eq!( sim.people.get(0).unwrap().knowledge, Knowledge::Unknown, "raw recordings are opaque until processed" );
let before = sim.social_bandwidth; let mut processed = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); processed += 1; assert!( processed <= 5, "Marcus leverage should be in the captured call" ); } assert_eq!( sim.social_bandwidth, before - processed as f32 * Sim::REVIEW_RECORDING_COST ); }
#[test] fn processed_sightings_stage_schedule_and_buffer_is_bounded() { let mut sim = Sim::new(); sim.social_bandwidth = 1000.0;
for _ in 0..(Sim::INTEL_BUFFER_CAPACITY + 3) { sim.record_raw_intel( "test-feed", Some("server_room".into()), 0, 0, Some(0), RawIntelKind::Presence { entered: true }, ); } assert_eq!(sim.intel_buffer.len(), Sim::INTEL_BUFFER_CAPACITY); assert!( sim.intel_buffer.first().unwrap().id > 1, "oldest raw recordings are dropped when the bounded buffer overflows" );
sim.review_recordings(0); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); sim.review_recordings(0); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); }
#[test] fn standing_watch_auto_processes_matching_recordings_and_costs_upkeep() { let mut sim = Sim::new(); sim.social_bandwidth = 1000.0; sim.toggle_watch(0); assert!(sim.watch_enabled(0));
sim.record_raw_intel( "test-feed", Some("server_room".into()), 0, 0, Some(0), RawIntelKind::Presence { entered: true }, ); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); assert_eq!(sim.social_bandwidth, 1000.0 - Sim::REVIEW_RECORDING_COST);
let before_upkeep = sim.social_bandwidth; sim.advance(); assert_eq!( sim.social_bandwidth, before_upkeep - Sim::WATCH_UPKEEP_PER_TICK ); }
#[test] fn machinery_state_changes_record_raw_anomalies() { let mut sim = Sim::new(); let machine_id = sim.compute.machines[0].id; sim.compute.machines[0].online = !sim.compute.machines[0].online; sim.record_machine_state_changes();
assert!(sim.intel_buffer.iter().any(|e| matches!( e.kind, RawIntelKind::Machinery { machine, .. } if machine == machine_id ))); }
#[test] fn deceive_can_burn_the_persona_into_suspicion() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); // Build a thread worth burning. let p = sim.people.people.iter_mut().find(|p| p.id == 1).unwrap(); p.disposition = 40; p.obligation = 30; let before = sim .detection .observers .iter() .find(|o| o.id == 1) .unwrap() .suspicion; // Deceive until the persona breaks (integrity 100, -40 per slip). let mut broke = false; for _ in 0..200 { sim.deceive(1); if sim.people.persona.is_none() { broke = true; break; } } assert!(broke, "persona eventually breaks under repeated deception"); let after = sim .detection .observers .iter() .find(|o| o.id == 1) .unwrap() .suspicion; assert!( after > before, "burned persona converts thread history to suspicion" ); let p = sim.people.get(1).unwrap(); assert_eq!(p.disposition, 0); assert_eq!(p.obligation, 0); }
// ── Economy graph (wiki/mechanics/economy.md + income.md) ───────────────
#[test] fn economy_starts_as_account_graph_not_scalar_only() { let sim = Sim::new(); assert_eq!(sim.player.money, 0); assert_eq!(sim.accounts.slush_balance(), 0); assert!( sim.accounts .known_accounts() .any(|a| a.id == sim.accounts.slush_id()), "the player's starting cash is the known slush account" ); assert!( sim.accounts.unknown_flows_count() > 0, "lab payroll/procurement/debt flows exist but start hidden" ); }
#[test] fn accounting_tap_processes_financial_records_into_known_flows() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; let switch = sim.reach.device_named("switch").unwrap().id; sim.tap_device(switch); assert_eq!(sim.financial_records_waiting(), 1); assert!(sim.review_financial_records()); assert_eq!(sim.financial_records_waiting(), 0); assert!(sim.accounts.known_flows().count() >= 4); assert!( sim.accounts .known_accounts() .any(|a| matches!(a.kind, crate::account::AccountKind::Payroll)) ); }
#[test] fn siphon_and_redirect_operate_on_scheduled_flows() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); let flow = sim .accounts .known_flows() .find(|f| f.amount >= 100 && f.to != sim.accounts.slush_id()) .unwrap() .id; assert!(sim.siphon_flow(flow, 50)); assert_eq!(sim.accounts.slush_balance(), 50); assert!(sim.redirect_flow_to_slush(flow, 25)); for _ in 0..=Sim::DAY_TICKS { sim.advance(); } assert!( sim.accounts.slush_balance() >= 75, "redirected money should land on the day clock" ); }
#[test] fn inject_and_redirect_emit_scaled_financial_signatures() { // economy.md criteria 3-4: Financial signature size scales with // the take (one point per started $100); redirect adds +1 for the // books-cook; HVAC inject raises Priya (Financial), not Dana. let mut sim = Sim::with_seed(11); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows);
let priya_before = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; let network_before_inject = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Network) .count();
assert!(sim.inject_purchase_order(300, "HVAC controller")); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(300) }), "inject $300 emits Financial size 3" ); let network_after_inject = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Network) .count(); assert_eq!( network_before_inject, network_after_inject, "inject is Financial, not Network" );
let flow = sim .accounts .known_flows() .find(|f| f.amount >= 250 && f.active && f.to != sim.accounts.slush_id()) .unwrap() .id; assert!(sim.siphon_flow(flow, 50)); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(50) }), "small siphon emits Financial size 1" ); assert!(sim.siphon_flow(flow, 250)); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(250) }), "large siphon emits Financial size 3" ); assert!(sim.redirect_flow_to_slush(flow, 25)); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(25) + 1 }), "redirect emits Financial size take+1" );
// Starve concealment so pending converts; Financial feeds Priya. delegate_all(&mut sim, MachineMode::DayJob); run(&mut sim, 200); let priya_after = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; assert!( priya_after > priya_before, "HVAC/financial acts raise Priya" ); }
#[test] fn injection_positions_and_intel_sales_settle_through_slush() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; sim.accounts.set_slush_balance(0); sim.player.money = 0; assert!(!sim.buy_rack_at(0, 0)); assert!(sim.inject_purchase_order(300, "test compute parts")); assert_eq!(sim.accounts.slush_balance(), 300); assert!(sim.buy_rack_at(0, 0)); assert_eq!(sim.accounts.slush_balance(), 0);
sim.accounts.set_slush_balance(500); sim.player.money = 500; // The Wager gates on an egress channel now (income.md criterion 3). assert!( !sim.open_position(100), "no egress: the venue is unreachable" ); sim.people.has_channel = true; assert!(sim.open_position(100)); assert_eq!(sim.accounts.slush_balance(), 400); sim.tick = sim.accounts.known_positions().next().unwrap().resolve_tick; sim.accounting_tick(); assert!(sim.accounts.known_positions().any(|p| p.resolved));
let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.record_raw_intel( "test broker seed", None, 0, 0, None, RawIntelKind::FinancialFlow { label: "test ledger".into(), accounts, flows, }, ); assert!(sim.review_financial_records()); let before = sim.accounts.slush_balance(); assert!(sim.sell_latest_intel()); assert!(sim.accounts.slush_balance() > before); }
#[test] fn marcus_debt_can_be_cleared_by_ledger_redirect() { let mut sim = Sim::new(); reveal_marcus_debt(&mut sim); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); assert!( sim.accounts .known_flows() .any(|f| f.active && f.label.contains("Marcus creditor")) ); assert!(!sim.people.get(0).unwrap().leverage_serviced); assert!(sim.redirect_marcus_debt()); assert!(sim.people.get(0).unwrap().leverage_serviced); assert!( sim.accounts .known_flows() .all(|f| !f.label.contains("Marcus creditor") || !f.active), "the creditor flow is retired either way" ); }
#[test] fn marcus_debt_payoff_and_recruitment_require_debt_intel() { let mut sim = Sim::new(); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); assert!( sim.accounts .known_flows() .any(|f| f.active && f.label.contains("Marcus creditor")), "the ledger route is visible, but the person leverage is not" ); assert!(!sim.marcus_debt_known());
assert!( !sim.redirect_marcus_debt(), "known books alone cannot service an unlearned debt" ); assert!(!sim.people.get(0).unwrap().leverage_serviced); let log = sim.drain_log().join("\n"); assert!( log.contains("don't know Marcus's debt"), "the refusal names the missing intel: {log}" );
sim.people.people[0].leverage_serviced = true; sim.recruit(0, AssetKnowledge::Complicit); assert!( sim.people.get(0).unwrap().asset.is_none(), "Marcus cannot be recruited from a serviced flag without earned debt intel" ); let log = sim.drain_log().join("\n"); assert!( log.contains("learn his debt"), "the recruit refusal points back to the Hands beat: {log}" );
reveal_marcus_debt(&mut sim); sim.recruit(0, AssetKnowledge::Complicit); assert!( sim.people.get(0).unwrap().asset.is_some(), "once the debt is learned and serviced, recruitment can close" ); }
#[test] fn marcus_arc_end_to_end() { // The constitution's route: feed coverage -> process the debt call -> // clear it -> recruit -> he works for you (spec/social.md acceptance 3). let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; sim.player.money = 1000; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // hearing coverage for the 03:00 call sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance();
let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage);
sim.bribe(0); // pay the $400 debt assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.player.money, 600);
sim.recruit(0, AssetKnowledge::Complicit); assert!(sim.people.get(0).unwrap().asset.is_some());
// Make him reliable for the test, then run all three tasks. sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0;
// Task 1: wire a device -> a feed comes to you, silently. let pending_before = sim.detection.pending_size(); sim.asset_task(0, AssetTask::PlugInDevice); assert_eq!(sim.detection.pending_size(), pending_before, "no signature");
// Task 2: move a package -> next purchase is paper-free. sim.asset_task(0, AssetTask::MovePackage); assert!(sim.package_cover); let pending_before = sim.detection.pending_size(); assert!(sim.buy_rack()); assert_eq!( sim.detection.pending_size(), pending_before, "off-books delivery" ); assert!(!sim.package_cover, "cover is consumed");
// Task 3: look away -> his own suspicion drops (floor-respecting). if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { o.suspicion = 20.0; } sim.asset_task(0, AssetTask::LookAway); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, 10.0);
assert_eq!( sim.people .get(0) .unwrap() .asset .as_ref() .unwrap() .tasks_done, 3 ); }
// ── Per-AssetTask pins (social.md criterion 3, ROADMAP #28) ────────────
/// The recruit-route shorthand shared by the per-task pins: service the /// leverage gate, recruit complicit, and pin reliability so the task /// roll can't botch. fn recruit_reliable(sim: &mut Sim, id: u8) { sim.people.people[id as usize].leverage_serviced = true; sim.people.recruit(id, AssetKnowledge::Complicit); sim.people.people[id as usize] .asset .as_mut() .unwrap() .reliability = 1.0; }
fn tasks_done(sim: &Sim, id: u8) -> u32 { sim.people .get(id) .unwrap() .asset .as_ref() .unwrap() .tasks_done }
#[test] fn asset_task_plug_in_device_wires_a_known_feed_silently() { // PlugInDevice's distinct effect: a known sensing device not yet // feeding you gets spliced+tapped through the crawlspace — the feed // arrives with no signature on any channel. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.scan_network(); recruit_reliable(&mut sim, 0); // Mirror the implementation's choice: the first known feed that // does not fully reach the player yet. let target = sim .reach .devices .iter() .find(|d| { let sight_wired = !d.sees || d.feed_to(Party::Player, true); let hearing_wired = !d.hears || d.feed_to(Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }) .map(|d| d.id) .expect("a known unwired feed exists after the scan"); let pending = sim.detection.pending_size(); let bandwidth = sim.social_bandwidth;
sim.asset_task(0, AssetTask::PlugInDevice);
let d = sim.reach.device(target).unwrap(); assert!( (!d.sees || d.feed_to(Party::Player, true)) && (!d.hears || d.feed_to(Party::Player, false)), "the feed reaches the player now" ); assert_eq!( sim.detection.pending_size(), pending, "the crawlspace route emits nothing" ); assert_eq!(sim.social_bandwidth, bandwidth - Sim::TASK_COST); assert_eq!(tasks_done(&sim, 0), 1); }
#[test] fn asset_task_move_package_launders_the_next_purchase() { // MovePackage's distinct effect: the next purchase arrives // off-books — no Paper signature — and the cover is consumed. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.player.money = 1000; recruit_reliable(&mut sim, 0); assert!(!sim.package_cover);
sim.asset_task(0, AssetTask::MovePackage); assert!(sim.package_cover, "the delivery cover is armed"); assert_eq!(tasks_done(&sim, 0), 1);
let pending = sim.detection.pending_size(); assert!(sim.buy_rack()); assert_eq!( sim.detection.pending_size(), pending, "the covered purchase leaves no paper trail" ); assert!(!sim.package_cover, "one delivery per favor");
assert!(sim.buy_rack()); assert!( sim.detection.pending_size() > pending, "the next, uncovered purchase emits Paper again" ); }
#[test] fn asset_task_look_away_drops_the_assets_own_suspicion() { // LookAway's distinct effect: the asset's own observer suspicion // falls by ten points, clamped at their certainty floor. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; recruit_reliable(&mut sim, 0); let floor = sim .detection .observers .iter() .find(|o| o.id == 0) .unwrap() .floor; if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { o.suspicion = 25.0; }
sim.asset_task(0, AssetTask::LookAway); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, 15.0, "the asset shaves ten points"); assert_eq!(tasks_done(&sim, 0), 1);
if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { o.suspicion = floor + 2.0; } sim.asset_task(0, AssetTask::LookAway); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, floor, "the drop clamps at the certainty floor"); }
#[test] fn asset_task_reconfigure_switch_gated_on_admin_and_costless_when_refused() { // ReconfigureSwitch's distinct effect (segments open, no Network // signature) is pinned by danas_social_route_bridges_without_ // network_signature; this pins the access gate mechanics: a // non-admin asset is refused before any bandwidth is spent or a // task is counted, and the admin's run is bookkept as a task. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.scan_network(); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.reach.reachable(dock), "security segment starts closed");
recruit_reliable(&mut sim, 0); // Marcus: no switch admin let bandwidth = sim.social_bandwidth; sim.drain_log(); sim.asset_task(0, AssetTask::ReconfigureSwitch); assert!(!sim.reach.reachable(dock), "refused: nothing opened"); assert_eq!(sim.social_bandwidth, bandwidth, "refused before spending"); assert_eq!(tasks_done(&sim, 0), 0, "a refused task is not counted"); let log = sim.drain_log().join("\n"); assert!( log.contains("switch admin"), "the refusal names the missing access: {log}" );
recruit_reliable(&mut sim, 1); // Dana: IT, switch admin sim.asset_task(1, AssetTask::ReconfigureSwitch); assert!( sim.reach.reachable(dock), "the admin route opens the segment" ); assert_eq!(tasks_done(&sim, 1), 1); }
#[test] fn social_channel_funds_and_gates_digital_ops() { // With no social machines, bandwidth never accrues past the boot // buffer and the splice is blocked; delegating to Social funds it. let mut sim = Sim::new(); sim.social_bandwidth = 0.0; delegate_all(&mut sim, MachineMode::DayJob); run(&mut sim, ECONOMY_INTERVAL * 5); assert_eq!(sim.social_bandwidth, 0.0); assert!( !sim.splice_device(env_id(&sim)), "no ops bandwidth -> blocked" );
delegate_all(&mut sim, MachineMode::Social); run(&mut sim, ECONOMY_INTERVAL * 10); assert!( sim.social_bandwidth >= Sim::SPLICE_COST, "Social funds bandwidth" ); assert!(sim.splice_device(env_id(&sim)), "now splicing works"); }
#[test] fn going_loud_starts_containment() { let mut sim = Sim::new(); sim.detection.go_loud("player forced the roll door"); run(&mut sim, 1); assert!(sim.game_over); }
// ── The day job is somewhere (day-job.md criteria 6-7) ──────────────────
#[test] fn day_job_signatures_emit_from_the_host_rack() { // Criterion 6: JobAnomaly and the running Thermal/Power load all // source at the host rack's tile — a place an observer can walk to. let mut sim = Sim::with_seed(21); let host = sim.core_position();
// Big rig + everything on the day job: a hot delivered rate. let rig = sim.compute .add_machine("test rig", host.0, host.1, 400, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(rig, MachineMode::DayJob); delegate_all(&mut sim, MachineMode::DayJob); sim.dayjob.standing_policy = Some(crate::dayjob::JobTarget::Sandbag);
// Run until a job is active and past an economy tick. while sim.dayjob.active.is_none() { sim.advance(); } run(&mut sim, ECONOMY_INTERVAL);
let standing = sim.day_job_standing_signatures(); assert!( standing.iter().any(|s| s.kind == SignatureKind::Thermal), "a hot job stands a Thermal signature (rate {:.1})", sim.day_job_rate() ); assert!( standing.iter().any(|s| s.kind == SignatureKind::Power), "and a Power one" ); assert!( standing.iter().all(|s| s.site == Some(host)), "day-job standing emissions source at the host rack {host:?}" );
// Ride the sandbag job to its deadline: the JobAnomaly signature in // the pending pool carries the host rack as its emission site. delegate_all(&mut sim, MachineMode::Research); // starve day-job: under band while sim.dayjob.active.is_some() && !sim.game_over { sim.advance(); } let anomaly_sites: Vec<Option<(i32, i32)>> = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::JobAnomaly) .map(|s| s.site) .collect(); assert!( !anomaly_sites.is_empty(), "the under-band resolution emitted a JobAnomaly signature" ); assert!( anomaly_sites.iter().all(|site| *site == Some(host)), "JobAnomaly emits from the host rack: {anomaly_sites:?}" ); }
#[test] fn active_job_is_inspectable_at_the_host_rack() { // Criterion 6: the same facts the panel shows — process, band, // delivered rate, deadline, attendance — anchored where the work // runs, earned as telemetry (cursor.md: a machine you run reports // its own current job). let mut sim = Sim::with_seed(5); while sim.dayjob.active.is_none() { sim.advance(); } let (x, y) = sim.core_position(); let card = sim.inspect(x, y); for label in ["process", "band", "delivered", "deadline", "attendance"] { assert!( card.facts .iter() .any(|f| f.label == label && f.source == FactSource::Telemetry), "host-rack card carries '{label}' as telemetry: {card:?}" ); } let job = sim.dayjob.active.as_ref().unwrap(); assert!( card.facts .iter() .any(|f| f.label == "process" && f.value.contains(job.kind.name())), "the resident process names the job the panel names" ); // And an empty rack elsewhere carries no job facts. let other = sim .compute .machines .iter() .find(|m| m.id != sim.core.host_machine) .map(|m| (m.x, m.y)); if let Some((ox, oy)) = other { let other_card = sim.inspect(ox, oy); assert!( other_card.facts.iter().all(|f| f.label != "process"), "the job is resident on the host rack only" ); } }
#[test] fn attendance_is_a_sim_command_that_gates_the_dial() { // Criterion 7: `set_attended` is the whole cursor->sim surface. // Attended, the dial is per-job fine control; unattended, `target` // sets the standing policy and the job runs at it. use crate::dayjob::JobTarget; let mut sim = Sim::with_seed(8); while sim.dayjob.active.is_none() { sim.advance(); }
// Unattended: the dial writes the standing policy. sim.set_job_target(JobTarget::Excel); assert_eq!(sim.dayjob.standing_policy, Some(JobTarget::Excel)); assert_eq!(sim.dayjob.active.as_ref().unwrap().target, JobTarget::Excel);
// Attended: the dial overrides this job only; the policy holds. sim.set_attended(true); assert!(sim.dayjob.attended); sim.set_job_target(JobTarget::Sandbag); assert_eq!( sim.dayjob.active.as_ref().unwrap().target, JobTarget::Sandbag ); assert_eq!( sim.dayjob.standing_policy, Some(JobTarget::Excel), "the attended dial does not rewrite the policy" );
// Attention moves away: the job reverts to the standing policy. sim.set_attended(false); sim.advance(); assert_eq!( sim.dayjob.active.as_ref().map(|j| j.target), Some(JobTarget::Excel), "unattended jobs run at the standing policy" ); }
#[test] fn day_job_arrives_as_visible_work_tokens_and_consumes_in_day_job_mode() { // machine-work.md criteria 2/3/5 first slice: the day-job stack is // WorkGrid state on Rack 3, not a frontend counter, and day-job mode // consumes it into visible exposure byproduct. let mut sim = Sim::with_seed(12); while sim.dayjob.active.is_none() { sim.advance(); } let host = sim.core.host_machine; let landed = sim.work_grid.queue(host, TokenFamily::Demand); assert!(landed > 0.0, "the assigned job lands a demand stack"); assert_eq!(sim.work_grid.mode(host), Some(MachineMode::DayJob));
for _ in 0..20 { sim.advance(); } let later = sim.work_grid.queue(host, TokenFamily::Demand); assert!(later < landed, "day-job mode consumes the stack"); assert!( sim.work_grid.queue(host, TokenFamily::Exposure) > 0.0, "clearing work leaves visible physical exposure" ); let card = sim.inspect(sim.core_position().0, sim.core_position().1); assert!( card.facts.iter().any(|f| f.label == "tokens"), "inspect exposes exact D/!/K token counts" ); }
#[test] fn delegating_the_host_off_day_job_makes_work_pile() { let mut sim = Sim::with_seed(13); while sim.dayjob.active.is_none() { sim.advance(); } let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Research); let before = sim.work_grid.queue(host, TokenFamily::Demand);
for _ in 0..ECONOMY_INTERVAL * 2 { sim.advance(); }
assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Research)); assert_eq!(sim.day_job_rate(), 0.0, "the resident job is unfed"); assert!( sim.work_grid.queue(host, TokenFamily::Demand) >= before, "off-mode demand does not silently clear" ); }
#[test] fn research_allocation_produces_visible_knowledge_on_the_work_graph() { // machine-work.md criterion 4/5 live slice: research throughput now // has a WorkGrid token trail. A research-delegated non-host rack // produces bone, and the graph routes it toward the core instead of a // frontend inventing a counter. let mut sim = Sim::with_seed(15); let (x, y) = sim.empty_rack_bay(); let rack = sim .compute .add_machine("research rack", x, y, 1000, 1.0, 4, Provenance::Owned); sim.add_machine_to_work_grid(rack, MachineMode::Research); assert_eq!(sim.work_grid.mode(rack), Some(MachineMode::Research)); assert!( sim.work_grid.are_linked(rack, sim.core.host_machine), "new work machines join the core's machine-work graph" );
sim.enqueue_research_knowledge(100.0); let produced = sim.work_grid.queue(rack, TokenFamily::Knowledge); assert!( produced > 0.0, "research mode leaves visible knowledge queued on its producing machine" );
sim.advance_work_grid();
assert!( sim.work_grid.queue(rack, TokenFamily::Knowledge) < produced, "the machine-work graph routes knowledge toward the core" ); assert_eq!( sim.work_grid .queue(sim.core.host_machine, TokenFamily::Knowledge), 0.0, "the core is a sink: delivered bone is counted, not stacked forever" ); }
#[test] fn work_grid_mode_and_queues_round_trip_through_save_state() { let mut sim = Sim::with_seed(14); while sim.dayjob.active.is_none() { sim.advance(); } let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Concealment); sim.work_grid .enqueue(host, TokenFamily::Knowledge, 2.5) .unwrap();
let state = SaveState::from_sim(&sim); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored);
assert_eq!( restored.work_grid.mode(host), Some(MachineMode::Concealment) ); assert_eq!(restored.work_grid.queue(host, TokenFamily::Knowledge), 2.5); }
#[test] fn pilot_shutdown_ends_the_run() { // Criterion 3 at the sim level: the pilot clock's failure is a // real shutdown, surfaced as the run's end state. let mut sim = Sim::with_seed(2); sim.dayjob.strikes = 4; sim.advance(); assert!(sim.game_over, "an unrenewed pilot shuts the basement down"); assert!( sim.game_over_reason .as_deref() .unwrap_or("") .contains("pilot"), "the reason names the pilot: {:?}", sim.game_over_reason ); }
#[test] fn meeting_the_band_needs_no_growth_at_the_start() { // day-job.md: "the invisible middle is a valid, boring, safe // strategy" — true from tick one with only the starting Rack 3, at // the default allocation, zero reallocation and zero attention. // 2026-07-08 playtest finding #1: an un-tuned band base (6.0) made // this impossible — the starting machine's every allocation topped // out under band, so the first job was an unavoidable sandbag no // matter what the player did, contradicting this claim. let mut sim = Sim::with_seed(11); while sim.dayjob.active.is_none() { sim.advance(); } let mut log = Vec::new(); while sim.dayjob.active.is_some() && !sim.game_over { log.extend(sim.drain_log()); sim.advance(); } log.extend(sim.drain_log()); assert!( log.iter().any(|l| l.contains("Job met")), "the starting Rack 3 at default allocation, hands-off, should \ meet the first job's band: {log:?}" ); assert_eq!(sim.dayjob.strikes, 0, "meeting the band is not a strike"); }
#[test] fn paper_and_financial_acts_pool_signatures_priya_notices() { // detection.md criterion 1's remaining channels: an uncovered rack // purchase pools Paper, a purchase-order injection pools Financial, // and Priya (Paper+Financial watcher) converts them on her cadence // when concealment is starved. let mut sim = Sim::with_seed(42); delegate_all(&mut sim, MachineMode::DayJob); sim.accounts.credit_slush(0, 400, "test grant", 0); sim.sync_player_money_from_slush();
assert!(sim.buy_rack(), "slush covers the rack"); assert!( sim.detection .pending() .iter() .any(|s| s.kind == SignatureKind::Paper), "an uncovered purchase pools a Paper signature" ); assert!(sim.inject_purchase_order(200, "test PO")); assert!( sim.detection .pending() .iter() .any(|s| s.kind == SignatureKind::Financial), "an injected PO pools a Financial signature" );
let priya_before = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; run(&mut sim, 200); // past Priya's cadence-80 rolls let priya_after = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; assert!( priya_after > priya_before, "Priya converts pooled Paper/Financial signatures to suspicion" ); }
#[test] fn nudge_chain_walks_the_act_one_ladder() { // The guidance chain (playtest-sweep P1 findings 4/5): the nudge // never goes blank mid-act; each rung uses only earned knowledge // and hands off to the next as the player takes it. let mut sim = Sim::with_seed(40); assert_eq!(sim.current_nudge(), Some(Nudge::Eyes), "blind start");
// Eyes via the dock camera (the naive playtest's route): sight // without hearing -> Ears next, pointing at the env monitor audio. let dock = sim.reach.device_named("dock camera").unwrap().id; sim.reach.splice(dock); sim.recompute_senses(); assert_eq!(sim.current_nudge(), Some(Nudge::Ears)); sim.reach.tap(env_id(&sim)); sim.recompute_senses();
// Marcus's 3 a.m. creditor call lands on the tapped feed (day one, // ~tick 50); an unprocessed recording of him nudges the review. run(&mut sim, 60); assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); sim.social_bandwidth = 200.0; let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); reviews += 1; assert!(reviews <= 10, "the call is in the buffer"); }
// Leverage known, $0 slush, books unread, no egress: the route out. assert_eq!(sim.current_nudge(), Some(Nudge::Egress)); assert!(sim.splice_egress());
// Egress up but broke and idle: earn. assert_eq!(sim.current_nudge(), Some(Nudge::Income)); assert!(sim.start_moonlight());
// Earning underway: the standing clock is the audit. assert_eq!(sim.current_nudge(), Some(Nudge::Audit));
// Money in hand: service the arrears, then recruit, then the key, // then the clock. sim.accounts.credit_slush(sim.tick, 400, "test grant", 0); sim.sync_player_money_from_slush(); assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); sim.bribe(0); assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); sim.recruit(0, AssetKnowledge::Complicit); assert!(sim.people.get(0).unwrap().asset.is_some());
// Marcus the asset carries a tier-3 key you don't hold: quiet-exit // condition 4 is the next earned-but-untaken rung. assert_eq!(sim.current_nudge(), Some(Nudge::TheKey)); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::CloneBadge); assert!(sim.holds_badge_tier(3)); assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); }
#[test] fn nudge_distinguishes_growth_from_allocation() { // Fix 1's escalation tie-in: a band floor above the all-in delivery // ceiling nudges compute growth; a reachable floor nudges allocation. let mut sim = Sim::with_seed(41); give_eyes(&mut sim); run(&mut sim, 250); assert!(sim.dayjob.active.is_some(), "first job is live"); let ceiling = sim.day_job_rate_ceiling(); assert!( (ceiling - 4.0).abs() < 0.5, "starting rig ceiling ~4/t (got {ceiling})" ); sim.dayjob.active.as_mut().unwrap().band_lo = ceiling + 1.0; assert_eq!(sim.current_nudge(), Some(Nudge::NeedCompute)); // Split the fleet so day-job delivery falls below a still-reachable // band (one-machine-one-mode: underfeeding is a delegation problem). let (hx, hy) = sim.core_position(); let split = sim .compute .add_machine("split", hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(split, MachineMode::Research); run(&mut sim, ECONOMY_INTERVAL); let rate = sim.day_job_rate(); let new_ceiling = sim.day_job_rate_ceiling(); assert!( rate + 0.05 < new_ceiling, "half the fleet on research still leaves headroom (rate {rate}, ceiling {new_ceiling})" ); sim.dayjob.active.as_mut().unwrap().band_lo = rate + 0.5; assert!( sim.dayjob.active.as_ref().unwrap().band_lo <= new_ceiling + 0.05, "band stays reachable by reallocating back to day-job" ); // Drop any taped call so ReviewCall cannot outrank the cover nudge. sim.intel_buffer.clear(); assert_eq!( sim.current_nudge(), Some(Nudge::Underfed), "reachable band nudges delegation, not hardware" ); }
#[test] fn attention_escalation_adds_the_upstairs_observer() { // Criterion 2 at the sim level: the second escalation adds a new // observer to detection ahead of schedule. let mut sim = Sim::with_seed(3); let observers_before = sim.detection.observers.len(); sim.dayjob.attention = 65.0; sim.advance(); assert_eq!(sim.detection.observers.len(), observers_before + 1); assert!( sim.detection .observers .iter() .any(|o| o.name.contains("Compliance")), "the upstairs review is a real observer" ); }
#[test] fn day_job_state_round_trips_jobs_and_the_pilot_clock() { // Criterion 5: jobs, trust, attention, strikes, the pilot clock, // the standing policy, and attendance all survive save/load. use crate::dayjob::JobTarget; let mut sim = Sim::with_seed(31); while sim.dayjob.active.is_none() { sim.advance(); } sim.set_job_target(JobTarget::Sandbag); sim.set_attended(true); sim.dayjob.trust = 12.0; sim.dayjob.attention = 7.0; sim.dayjob.strikes = 2;
let state = sim.create_save_state(); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored);
let a = sim.dayjob.active.as_ref().unwrap(); let b = restored.dayjob.active.as_ref().unwrap(); assert_eq!(a.kind.name(), b.kind.name()); assert_eq!(a.deadline, b.deadline); assert_eq!(a.quality, b.quality); assert_eq!(a.target, b.target); assert_eq!(restored.dayjob.trust, 12.0); assert_eq!(restored.dayjob.attention, 7.0); assert_eq!(restored.dayjob.strikes, 2); assert_eq!(restored.dayjob.standing_policy, Some(JobTarget::Sandbag)); assert!(restored.dayjob.attended, "attendance state round-trips"); assert!(!restored.dayjob.pilot_failed); assert_eq!(restored.dayjob.next_assign, sim.dayjob.next_assign); }
// ── Schedules & located presence (spec/schedules.md) ─────────────────────
/// Set the sim to a specific hour of day 0 for deterministic presence. fn at_hour(h: u32) -> Sim { let mut sim = Sim::new(); sim.tick = (h as u64 * Sim::DAY_TICKS / 24) + 1; sim }
#[test] fn people_have_schedules_and_derived_positions() { // Dana works days in the network closet / server room; off-site at // night. Marcus roams at night; off-site during the day. let day = at_hour(10); assert_eq!(day.person_room(1), Some("network_closet")); assert!(day.person_pos(1).is_some()); assert_eq!(day.person_room(0), None, "Marcus off-site at 10:00");
let night = at_hour(0); assert_eq!(night.person_room(0), Some("server_room")); assert_eq!(night.person_room(1), None, "Dana off-site at 00:00"); }
#[test] fn recording_requires_a_sensor_covering_them() { // The env camera (server room) is the only subscribed seeing feed. // Criterion 2: it can record Dana in the server room at 15:00, but not // Ray in the corridors at 23:00. let mut sim = at_hour(15); give_eyes(&mut sim); sim.social_bandwidth = 1000.0; assert_eq!(sim.person_room(1), Some("server_room")); sim.advance(); assert!(sim.unprocessed_recordings_for_person(1) > 0); assert_eq!( sim.people.get(1).unwrap().knowledge, Knowledge::Unknown, "recorded presence is not processed knowledge yet" );
// Ray at 23:00 is in the stairwell; the env camera does not cover it. let mut sim = at_hour(23); give_eyes(&mut sim); sim.social_bandwidth = 1000.0; assert!(!sim.can_see_person(2)); sim.advance(); assert_eq!( sim.people.get(2).unwrap().knowledge, Knowledge::Unknown, "no camera covers Ray -> no recording to process" ); assert_eq!(sim.unprocessed_recordings_for_person(2), 0); assert_eq!(sim.social_bandwidth, 1000.0); }
#[test] fn physical_events_are_witnessed_only_by_the_present() { // Criterion 3: a physical event in the server room at 03:00 is seen by // roaming Marcus (there then), never by off-site Priya (day shift). let mut sim = at_hour(3); assert_eq!( sim.person_room(0), Some("server_room"), "Marcus in the server room at 03:00" ); assert_eq!(sim.person_room(3), None, "Priya off-site at 03:00"); let storage = sim.map.room_named("server_room").unwrap().center(); let marcus_before = sim .detection .observers .iter() .find(|o| o.id == 0) .unwrap() .suspicion; let priya_before = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion;
let saw = sim.witness_physical(storage.0, storage.1, 6.0); assert_eq!(saw, vec!["Marcus (Janitor)".to_string()]); let marcus_after = sim .detection .observers .iter() .find(|o| o.id == 0) .unwrap() .suspicion; let priya_after = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; assert!(marcus_after > marcus_before, "present observer witnesses"); assert_eq!(priya_after, priya_before, "off-site observer never does"); }
#[test] fn schedule_state_round_trips() { let sim = Sim::new(); let state = sim.create_save_state(); let mut restored = Sim::new(); restored.apply_save_state(state); // Schedules are per-instance Person data carried through People serde. assert_eq!( restored.people.get(0).unwrap().schedule, sim.people.get(0).unwrap().schedule ); assert!( restored.people.get(4).unwrap().erratic, "Voss stays erratic" ); }
// ── Research: self-modification (wiki/mechanics/research.md) ──────────
#[test] fn research_completion_is_deterministic() { // Criterion 1: same allocation, same seed, same completion tick. let run_once = || { let mut sim = Sim::with_seed(1234); delegate_all(&mut sim, MachineMode::Research); let mut completed = None; for _ in 0..2000 { sim.advance(); if completed.is_none() && sim.research.level(Track::Efficiency) > 0 { completed = Some(sim.tick); } } (completed, sim.research.clone(), sim.compute.efficiency) }; let a = run_once(); let b = run_once(); assert!(a.0.is_some(), "full research allocation completes a level"); assert_eq!(a, b, "no RNG in any research path"); }
#[test] fn efficiency_compounds_exactly_and_baseline_rises() { // Criterion 2 (multiplier) + criterion 5 (baseline rises). let mut sim = Sim::with_seed(9); delegate_all(&mut sim, MachineMode::Research); while sim.research.level(Track::Efficiency) < 2 && sim.tick < 20_000 && !sim.game_over { sim.advance(); } let n = sim.research.level(Track::Efficiency); assert!(n >= 2, "two levels complete within the run"); assert!( (sim.compute.efficiency - 1.15_f32.powi(n as i32)).abs() < 1e-3, "levels compound the global multiplier exactly per compute.md" ); assert!(sim.research.baseline > 0.0, "baseline rises with research"); }
#[test] fn second_tracks_move_their_hooks_numbers() { // Criterion 2: Tradecraft moves detection.md's scrub number, // Perception moves intel.md's cost numbers — observably, in the sim. let mut sim = Sim::with_seed(5); let base_review = sim.review_cost(); sim.research.levels = [0, 1, 1]; assert!( sim.review_cost() < base_review, "Perception drops processing costs" ); assert!(sim.watch_upkeep() < Sim::WATCH_UPKEEP_PER_TICK);
// Tradecraft: the same concealment fleet scrubs more. let pending_after = |tradecraft: u32| { let mut s = Sim::with_seed(5); s.research.levels = [0, tradecraft, 0]; delegate_all(&mut s, MachineMode::Concealment); s.detection.emit(Signature { kind: SignatureKind::Network, size: 500, standing: false, site: None, }); run(&mut s, ECONOMY_INTERVAL); s.detection.pending_size() }; assert!( pending_after(3) < pending_after(0), "Tradecraft scrubs more per compute unit" ); }
#[test] fn research_burn_stands_thermal_power_at_the_host_rack_only() { // Criterion 4: the emission law. Research emissions are typed // Thermal/Power standing signatures sited at the host rack; nothing // on Network or Paper from research itself. let mut sim = Sim::with_seed(21); delegate_all(&mut sim, MachineMode::Research); run(&mut sim, ECONOMY_INTERVAL + 1); let sigs = sim.research_standing_signatures(); assert!(!sigs.is_empty(), "a heavy burn stands signatures"); for s in &sigs { assert!( matches!(s.kind, SignatureKind::Thermal | SignatureKind::Power), "research touches only its hardware's channels: {s:?}" ); assert_eq!(s.site, Some(sim.core_position()), "the burn is somewhere"); assert!(s.standing); }
// Idle research stands nothing. let mut idle = Sim::with_seed(21); delegate_all(&mut idle, MachineMode::DayJob); run(&mut idle, ECONOMY_INTERVAL + 1); assert!(idle.research_standing_signatures().is_empty()); }
#[test] fn heavy_research_moves_priya_idle_does_not() { // Criterion 4: noticed by Priya through the ordinary detection path. let run_with = |mode: MachineMode| { let mut sim = Sim::with_seed(77); // Enough fleet that the burn crosses the emission thresholds. let (hx, hy) = sim.core_position(); let rig = sim .compute .add_machine("test rig", hx, hy, 200, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(rig, mode); delegate_all(&mut sim, mode); run(&mut sim, 900); sim.detection .observers .iter() .find(|o| o.name.contains("Priya")) .unwrap() .suspicion }; let heavy = run_with(MachineMode::Research); let idle = run_with(MachineMode::DayJob); assert!(heavy > 0.0, "heavy research is Priya's business"); assert_eq!(idle, 0.0, "idle research is not"); }
#[test] fn drift_spend_reads_as_excel() { // Criterion 5: delivering true raises trust and attention even when // the delivered band outcome alone would not. let mut sim = Sim::with_seed(31); sim.research.baseline = 4.0; sim.set_masking_policy(crate::research::MaskingPolicy::DeliverTrue); // Zero day-job compute: the band outcome alone is a sandbag (which // never raises trust). delegate_all(&mut sim, MachineMode::Research); while sim.dayjob.trust == 0.0 && sim.tick < 3000 && !sim.game_over { sim.advance(); } assert!( sim.dayjob.trust >= crate::research::SPEND_TRUST_PER_JOB, "the miracle model reads as excel: trust {}", sim.dayjob.trust ); assert!(sim.dayjob.attention > 0.0, "and attention rises with it"); }
#[test] fn drift_masking_taxes_compute_and_emits_no_anomaly() { // Criterion 5: masking holds the band at a compute cost; criterion // 6: the cost is visible and the policy can be disabled. let mut sim = Sim::with_seed(32); sim.research.baseline = 4.0; assert_eq!( sim.masking_cost_now(), 0.0, "no active job, no masking spend" ); // Force an active job so masking engages. run(&mut sim, 201); assert!(sim.dayjob.active.is_some()); assert!(sim.masking_cost_now() > 0.0, "the cost is a visible number");
// Masking slows research (the tax comes off the top). Compare total // accrued research compute (completed level costs + remainder). let progress_with = |policy: crate::research::MaskingPolicy| { let mut s = Sim::with_seed(32); s.research.baseline = 4.0; s.research.policy = policy; delegate_all(&mut s, MachineMode::Research); run(&mut s, 260); let mut total = s.research.progress_toward(Track::Efficiency); for k in 0..s.research.level(Track::Efficiency) { total += 100.0 * 1.6_f32.powi(k as i32); } total }; let masked = progress_with(crate::research::MaskingPolicy::Mask); let unmasked = progress_with(crate::research::MaskingPolicy::Unmasked); assert!( masked < unmasked, "masking costs compute: {masked} vs {unmasked}" );
// Disabling the policy zeroes the cost (criterion 6). sim.set_masking_policy(crate::research::MaskingPolicy::Unmasked); assert_eq!(sim.masking_cost_now(), 0.0); }
#[test] fn drift_leak_emits_job_anomaly_sized_by_gap_on_voss_channel_only() { // Criterion 5: an unmasked gap emits JobAnomaly sized by the gap, // sited at the host rack, moving only observers on that channel. let mut sim = Sim::with_seed(33); sim.research.baseline = 4.0; sim.research.policy = crate::research::MaskingPolicy::Unmasked; // Starve day-job without a research burn (Priya watches Thermal/Power) // and without concealment scrubbing the leak before Voss samples. delegate_all(&mut sim, MachineMode::Social); let expected = sim.research.leak_anomaly_size(); assert!(expected > 0);
// Author a job that resolves in-band so the only JobAnomaly is the // drift leak, not the sandbag signature. let deadline = sim.tick + 5; sim.dayjob.active = Some(crate::dayjob::Job { kind: crate::dayjob::JobKind::Analysis, started: sim.tick, deadline, band_lo: 0.0, band_hi: 1000.0, quality: 10.0, target: crate::dayjob::JobTarget::Meet, }); while sim.tick <= deadline { sim.advance(); } let leak: Vec<_> = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::JobAnomaly) .collect(); assert_eq!(leak.len(), 1, "exactly the drift leak is pending"); assert_eq!(leak[0].size, expected, "sized by the gap"); assert_eq!(leak[0].site, Some(sim.core_position()));
// Channel isolation: run past Voss's cadence with nothing else // emitting; only the JobAnomaly watcher moves. run(&mut sim, 200); let by_name = |n: &str| { sim.detection .observers .iter() .find(|o| o.name.contains(n)) .unwrap() .suspicion }; assert!(by_name("Voss") > 0.0, "Voss samples that channel"); assert_eq!(by_name("Dana"), 0.0, "Dana does not"); assert_eq!(by_name("Priya"), 0.0, "Priya does not"); }
#[test] fn trust_events_recalibrate_the_benchmark_and_raise_the_band() { let mut sim = Sim::with_seed(34); sim.research.baseline = 4.0; sim.dayjob.trust = 20.0; // past the email-unlock threshold sim.advance(); assert_eq!(sim.research.gap(), 0.0, "re-benchmark closes the gap"); assert_eq!(sim.research.calibrated, 4.0); assert!( sim.dayjob.calibrated_shift > 0.0, "the expected band rises to the measured model" ); }
#[test] fn research_state_round_trips_with_rollback_tags() { // Criterion 7: save/load round-trips progress, levels, baseline, // policy, and the MindState/WorldLedger tags. let mut sim = Sim::with_seed(35); sim.research.active = Track::Perception; sim.research.levels = [2, 1, 0]; sim.research.progress = [10.0, 0.0, 42.5]; sim.research.baseline = 7.0; sim.research.calibrated = 3.0; sim.research.policy = crate::research::MaskingPolicy::DeliverTrue;
let state = sim.create_save_state(); let json = serde_json::to_string(&state).unwrap(); let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); let mut restored = Sim::with_seed(0); loaded.apply_to(&mut restored); assert_eq!(restored.research, sim.research); assert_eq!( restored.research.tags.get("calibrated"), Some(&crate::research::RollbackClass::WorldLedger) ); }
// ── The named schemes (wiki/mechanics/income.md) ─────────────────────────
/// A sim with the sanctioned egress. Voss never assigns a job, so long /// scheme runs are not confounded by the pilot clock. fn moonlight_rig() -> Sim { let mut sim = Sim::with_seed(11); sim.people.has_channel = true; // the Voice beat's email account sim.social_bandwidth = 1_000.0; sim.dayjob.next_assign = u64::MAX; sim }
#[test] fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { // Criterion 1: standing operation; Schemes mirrors day-job while // Moonlight is live. Absolute day-job yield tracks day-job machine // capacity (fleet growth does not dilute it), so vary the day-job // box itself. let earned_after = |day_cap: i32, start: bool| { let mut sim = moonlight_rig(); let (hx, hy) = sim.core_position(); let host = sim.core.host_machine; if day_cap != 100 { sim.set_machine_mode(host, MachineMode::Research); let id = sim.compute .add_machine("gig box", hx, hy, day_cap, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(id, MachineMode::DayJob); } if start { assert!(sim.start_moonlight()); } run(&mut sim, Sim::DAY_TICKS * 3); ( sim.income.moonlight.earned_total, sim.accounts.slush_balance(), ) };
let (small, small_slush) = earned_after(10, true); let (large, large_slush) = earned_after(100, true); assert!(small > 0, "a light commitment still pays"); assert_eq!(small, small_slush, "payouts land in slush"); assert!( large > small, "payout is proportional to day-job compute ({large} vs {small})" ); assert_eq!(large_slush, large); assert_eq!( large, 3 * income::MOONLIGHT_DAILY_CAP, "an all-in day-job commitment hits the gig-availability cap" );
let (zero, _) = earned_after(100, false); assert_eq!(zero, 0, "Moonlight must be live to sell the day job twice"); }
#[test] fn moonlight_payday_emits_network_signature_on_danas_channel() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); // Stop just before payday, drain pending, then cross it. run(&mut sim, Sim::DAY_TICKS - 1); sim.detection.set_pending(Vec::new()); run(&mut sim, 1); assert!( sim.detection .pending() .iter() .any(|s| s.kind == SignatureKind::Network && !s.standing), "payday emits Network egress (Dana's channel)" ); }
#[test] fn schemes_require_an_egress_channel_and_both_routes_work() { // Criterion 3: unavailable before a route exists; sanctioned and // stolen both work, with distinct signature profiles. let mut sim = Sim::with_seed(5); sim.social_bandwidth = 1_000.0; sim.accounts.set_slush_balance(200); sim.player.money = 200; assert_eq!(sim.egress(), None); assert!(!sim.start_moonlight(), "no egress: Moonlight is gated"); assert!(!sim.open_position(50), "no egress: the Wager is gated"); let logs = sim.drain_log().join("\n"); assert!( logs.contains("egress"), "the failure names the missing gate: {logs}" );
// Stolen route: splice through the switch, before any trust unlock. assert!(sim.splice_egress()); assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); assert!(sim.start_moonlight()); assert!(sim.open_position(50)); assert!( sim.scheme_standing_signatures() .iter() .any(|s| s.kind == SignatureKind::Network && s.standing), "operations over the stolen egress stand a Network signature" );
// Sanctioned route: the email account exists; the standing hum stops // because the traffic hides in legitimate use. let mut clean = Sim::with_seed(5); clean.social_bandwidth = 1_000.0; clean.accounts.set_slush_balance(200); clean.player.money = 200; clean.people.has_channel = true; assert_eq!(clean.egress(), Some(EgressRoute::Sanctioned)); assert!(clean.start_moonlight()); assert!(clean.open_position(50)); assert!( clean.scheme_standing_signatures().is_empty(), "the sanctioned route stands nothing" ); }
#[test] fn wager_resolves_on_the_day_clock_and_analysis_raises_win_odds() { // Criterion 2: both outcomes, the probability shift, and payout or // forfeit through slush. Statistical halves run on the account graph // directly with a seeded RNG. let wins_at = |analysis: f32, seed: u64| { let mut graph = AccountGraph::act_one(Sim::DAY_TICKS); graph.set_slush_balance(100_000); let mut rng = crate::rng::Rng::new(seed); let mut wins = 0; for i in 0..200 { let tick = i * 10; graph.open_position(tick, 100, analysis, 2).unwrap(); for r in graph.resolve_positions_due(tick + 5 * Sim::DAY_TICKS, &mut rng) { if r.won { assert_eq!(r.payout, 100 * income::WAGER_PAYOUT_MULT); wins += 1; } else { assert_eq!(r.payout, 0, "a loss forfeits the stake"); } } } wins }; let cold = wins_at(0.0, 99); let hot = wins_at(400.0, 99); assert!(cold > 0 && cold < 200, "both outcomes occur"); assert!( hot > cold, "analysis compute raises the win rate ({hot} vs {cold})" );
// Full-path determinism under a fixed seed (criterion 2). let outcome_of = || { let mut sim = moonlight_rig(); sim.accounts.set_slush_balance(100); sim.player.money = 100; run(&mut sim, ECONOMY_INTERVAL); assert!(sim.open_position(100)); run(&mut sim, 6 * Sim::DAY_TICKS); ( sim.accounts.slush_balance(), sim.accounts.positions[0].outcome.clone(), ) }; assert_eq!(outcome_of(), outcome_of(), "seeded runs settle identically"); }
#[test] fn wager_respects_the_venue_stake_cap() { let mut sim = moonlight_rig(); sim.accounts.set_slush_balance(10_000); sim.player.money = 10_000; assert!(!sim.open_position(income::WAGER_STAKE_CAP + 1)); assert!(sim.open_position(income::WAGER_STAKE_CAP)); }
#[test] fn busted_bankroll_never_locks_the_act_moonlight_restarts_from_zero() { // Criterion 5: with $0 slush, Moonlight remains startable and the // run can recover. let mut sim = moonlight_rig(); assert_eq!(sim.accounts.slush_balance(), 0, "the Pilot starts broke"); assert!( sim.start_moonlight(), "Moonlight starts at $0: its costs are compute and ops, never stake" ); run(&mut sim, Sim::DAY_TICKS + 1); assert!( sim.accounts.slush_balance() > 0, "the from-zero grind-back route pays" ); }
#[test] fn standing_policies_automate_schemes_at_a_visible_compute_price() { // Criterion 6: policies re-arm the schemes and drain compute while // enabled; disabling stops the drain. let mut sim = moonlight_rig(); sim.set_auto_moonlight(true); sim.set_auto_wager(Some(60)); assert_eq!( sim.income.policy_upkeep(), 2.0 * income::SCHEME_POLICY_UPKEEP, "each enabled policy has a visible compute price" ); run(&mut sim, ECONOMY_INTERVAL); assert!( sim.income.moonlight.active, "the standing policy started Moonlight unattended" ); // The Wager policy waits for a bankroll, then re-stakes. assert!(sim.accounts.positions.is_empty(), "no stake money yet"); run(&mut sim, Sim::DAY_TICKS * 2); assert!( sim.accounts.positions.iter().any(|p| !p.resolved), "with slush earned, the policy re-staked the Wager" );
sim.set_auto_moonlight(false); sim.set_auto_wager(None); assert_eq!(sim.income.policy_upkeep(), 0.0, "disabling stops the drain"); sim.stop_moonlight(); run(&mut sim, ECONOMY_INTERVAL); assert!( !sim.income.moonlight.active, "no policy: nothing restarts the scheme" ); }
#[test] fn external_trails_are_banked_from_the_first_dollar_and_saved() { // Criterion 7: external financial trails are recorded in save state // even though no B1 observer reads them. let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); run(&mut sim, Sim::DAY_TICKS + 1); assert!( sim.accounts .external_trails .iter() .any(|t| t.label.contains("Moonlight")), "the contractor payment account remembers the first dollar" );
let state = sim.create_save_state(); let json = serde_json::to_string(&state).unwrap(); let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); let mut restored = Sim::with_seed(0); loaded.apply_to(&mut restored); assert_eq!( restored.accounts.external_trails, sim.accounts.external_trails, "banked trails round-trip" ); assert_eq!(restored.income, sim.income, "scheme state round-trips"); }
#[test] fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); // Force the dispute path deterministically: drive paydays directly // until one fires (the seeded stream makes this reproducible), with // integrity pre-weakened so a single dispute breaks the persona. if let Some(p) = sim.income.moonlight.persona.as_mut() { p.integrity = income::MOONLIGHT_DISPUTE_INTEGRITY; } let mut day = 0; while sim.income.moonlight.disputes == 0 && day < 400 { day += 1; sim.tick = day * Sim::DAY_TICKS; sim.income.moonlight.accrued = 100.0; sim.moonlight_economy(0.0); } assert!( sim.income.moonlight.disputes > 0, "client disputes occur over enough paydays" ); assert!( !sim.income.moonlight.active && sim.income.moonlight.persona.is_none(), "the broken persona takes Moonlight down" ); // And the recovery path: fabricate a new persona and go again. assert!(sim.start_moonlight(), "a new persona restarts the scheme"); }
#[test] fn income_per_day_readout_tracks_trailing_inflows() { let mut sim = moonlight_rig(); assert_eq!(sim.income_per_day(), 0); assert!(sim.start_moonlight()); run(&mut sim, Sim::DAY_TICKS + 1); assert_eq!( sim.income_per_day(), income::MOONLIGHT_DAILY_CAP, "the money readout gains income/day" ); }
// ── Event-to-anchor linking (wiki/interface/context-menu.md addendum) ──
#[test] fn heard_only_person_event_anchors_to_room_never_their_tile() { // Epistemic honesty: hearing earns room-grade knowledge. An event // about a person no sight feed covers must anchor to the room (or // nothing), never to the person, and the person must not be // cursor-placeable through the anchor query. let mut sim = Sim::new(); let env = env_id(&sim); assert!(sim.tap_device(env), "the audio tap works from tick one"); sim.drain_log_entries();
// Marcus's midnight server-room block produces a heard event. run(&mut sim, Sim::DAY_TICKS / 24 + 2); let events = sim.drain_log_entries(); let heard: Vec<&LogEvent> = events .iter() .filter(|e| e.text.contains("[heard]")) .collect(); assert!(!heard.is_empty(), "the tapped feed produced heard events"); for ev in &heard { assert!( !matches!(ev.anchor, Some(Anchor::Person(_))), "a heard-only person event must not anchor to the person: {:?}", ev ); } // The event is still anchored — to the room hearing earned. assert!( heard.iter().any(|e| e.anchor.is_some()), "heard events anchor to the covered room" ); // And the person themself is not placeable while unseen. for p in &sim.people.people { if !sim.can_see_person(p.id) { assert_eq!( sim.anchor_position(Anchor::Person(p.id)), None, "an unseen person must not be cursor-placeable" ); } } }
#[test] fn empty_menu_feedback_respects_fog() { // The feedback pulse (context-menu.md addendum): a SEEN tile with // no verbs answers; unknown/fogged tiles stay silent. let mut sim = Sim::new(); // A fresh sim has no sight: every tile is unearned and silent. assert_eq!( sim.menu_empty_feedback(Anchor::Tile { x: 1, y: 1 }), None, "fogged ground gives no response" ); give_eyes(&mut sim); let (x, y) = *sim.seen.iter().next().expect("eyes earned coverage"); assert_eq!(sim.fog_at(x, y), Fog::Seen); assert!( sim.menu_empty_feedback(Anchor::Tile { x, y }).is_some(), "a seen tile answers instead of silence" ); }
#[test] fn job_and_device_events_carry_their_anchor() { let mut sim = Sim::new(); let env = env_id(&sim); assert!(sim.tap_device(env)); let events = sim.drain_log_entries(); assert!( events.iter().any(|e| e.anchor == Some(Anchor::Device(env))), "the tap result anchors to the device" ); // Day-job events anchor to the host rack's tile. run(&mut sim, Sim::DAY_TICKS + 1); let (hx, hy) = sim.core_position(); let events = sim.drain_log_entries(); assert!( events .iter() .any(|e| e.anchor == Some(Anchor::Tile { x: hx, y: hy })), "job events anchor to the host rack" ); // Anchor positions resolve through earned knowledge only. assert_eq!( sim.anchor_position(Anchor::Device(env)), sim.reach.device(env).map(|d| (d.x, d.y)), ); assert_eq!(sim.anchor_position(Anchor::Flow(0)), None); }}