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. forked from cameron.stream/misaligned
Something went wrong. Try again.
290 kB · 7452 lines
Rust
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453//! 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, 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;
/// 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,}
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, /// 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 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(), 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); fact!("schematic", tile.name(), 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, ); } 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); 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(); let dj = self .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); 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; let split = self.compute.allocation.split(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;
// 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() }
/// 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(); self.compute.add_machine( "lab-funded quota rack", x, y, 40, 1.0, 0, Provenance::Owned, ); 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); }
// ── Commands (frontend-invoked) ────────────────────────────────────────
pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { // No silent verbs (agent-play.md "every command answers"): an // adjustment the clamp swallows says so instead of no-opping. 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 is already at its {}.", ch.name(), if delta < 0 { "floor (0)" } else { "cap (20)" } )); } }
/// 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 { 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 / 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. Allocate compute to Schemes.", 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; self.compute .add_machine("salvaged box", x, y, 40, reliability, 3, Provenance::Stolen); 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; } self.compute .add_machine("bought rack", x, y, 80, 1.0, 4, Provenance::Bought); 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(); } }
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 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 concealment_allocation_scrubs_signatures() { let mut sim = Sim::new(); sim.compute.allocation.weights = [0, 20, 0, 0, 0]; 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 an identical five-way allocation // and one pending signature to scrub; 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 = 0.0; sim.compute.allocation.weights = [1, 1, 1, 1, 1]; 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 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. sim.adjust_allocation(Channel::Concealment, -1); 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 allocation, bandwidth never accrues past the boot // buffer and the splice is blocked; allocating to Social funds it. let mut sim = Sim::new(); sim.social_bandwidth = 0.0; sim.compute.allocation.weights = [4, 0, 0, 0, 0]; // no social run(&mut sim, ECONOMY_INTERVAL * 5); assert_eq!(sim.social_bandwidth, 0.0); assert!( !sim.splice_device(env_id(&sim)), "no ops bandwidth -> blocked" );
sim.compute.allocation.weights = [0, 0, 10, 0, 0]; // all 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. sim.compute .add_machine("test rig", host.0, host.1, 400, 1.0, 0, Provenance::Owned); sim.compute.allocation.weights = [1, 0, 0, 0, 0]; 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. sim.compute.allocation.weights = [0, 0, 0, 1, 0]; // starve it: 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 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); sim.adjust_allocation(Channel::Concealment, -1); 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)); sim.dayjob.active.as_mut().unwrap().band_lo = ceiling - 0.2; assert_eq!( sim.current_nudge(), Some(Nudge::Underfed), "reachable band nudges allocation, 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); sim.compute.allocation.weights = [0, 0, 0, 20, 0]; 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); sim.compute.allocation.weights = [0, 0, 0, 20, 0]; 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 allocation scrubs more. let pending_after = |tradecraft: u32| { let mut s = Sim::with_seed(5); s.research.levels = [0, tradecraft, 0]; s.compute.allocation.weights = [0, 1, 0, 0, 0]; 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); sim.compute.allocation.weights = [0, 0, 0, 20, 0]; 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); idle.compute.allocation.weights = [0, 0, 0, 0, 0]; 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 = |weights: [u32; 5]| { let mut sim = Sim::with_seed(77); // Enough fleet that the burn crosses the emission thresholds. sim.compute .add_machine("test rig", 1, 1, 200, 1.0, 0, Provenance::Owned); sim.compute.allocation.weights = weights; run(&mut sim, 900); sim.detection .observers .iter() .find(|o| o.name.contains("Priya")) .unwrap() .suspicion }; let heavy = run_with([0, 0, 0, 20, 0]); let idle = run_with([0, 0, 0, 0, 0]); 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). sim.compute.allocation.weights = [0, 0, 0, 0, 0]; 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; s.compute.allocation.weights = [0, 0, 0, 20, 0]; 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; sim.compute.allocation.weights = [0, 0, 0, 0, 0]; 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 and everything pointed at Schemes. /// Voss never assigns a job, so long scheme runs are not confounded by /// the pilot clock striking out a starved day-job channel. fn moonlight_rig(weights: [u32; 5]) -> 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.compute.allocation.weights = weights; sim.dayjob.next_assign = u64::MAX; sim }
#[test] fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { // Criterion 1: standing operation, Schemes-channel consumption, // daily payout proportional to commitment, gig cap, Network // signature scaling with commitment. let earned_after = |weights: [u32; 5]| { let mut sim = moonlight_rig(weights); 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([3, 1, 1, 0, 1]); let (large, large_slush) = earned_after([0, 0, 0, 0, 1]); assert!(small > 0, "a light commitment still pays"); assert_eq!(small, small_slush, "payouts land in slush"); assert!( large > small, "payout is proportional to committed compute ({large} vs {small})" ); assert_eq!(large_slush, large); assert_eq!( large, 3 * income::MOONLIGHT_DAILY_CAP, "an all-in commitment hits the gig-availability cap" );
// No Schemes allocation -> the operation starves: no payout. let (zero, _) = earned_after([3, 1, 1, 0, 0]); assert_eq!(zero, 0, "Moonlight consumes the Schemes channel only"); }
#[test] fn moonlight_payday_emits_network_signature_on_danas_channel() { let mut sim = moonlight_rig([0, 0, 0, 0, 1]); 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([0, 0, 0, 0, 1]); 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([3, 1, 1, 0, 0]); 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([0, 0, 0, 0, 1]); 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([0, 0, 0, 0, 1]); 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([0, 0, 0, 0, 1]); 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([0, 0, 0, 0, 1]); 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([0, 0, 0, 0, 1]); 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); }}