Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
509 kB · 13402 lines
Rust
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659866086618662866386648665866686678668866986708671867286738674867586768677867886798680868186828683868486858686868786888689869086918692869386948695869686978698869987008701870287038704870587068707870887098710871187128713871487158716871787188719872087218722872387248725872687278728872987308731873287338734873587368737873887398740874187428743874487458746874787488749875087518752875387548755875687578758875987608761876287638764876587668767876887698770877187728773877487758776877787788779878087818782878387848785878687878788878987908791879287938794879587968797879887998800880188028803880488058806880788088809881088118812881388148815881688178818881988208821882288238824882588268827882888298830883188328833883488358836883788388839884088418842884388448845884688478848884988508851885288538854885588568857885888598860886188628863886488658866886788688869887088718872887388748875887688778878887988808881888288838884888588868887888888898890889188928893889488958896889788988899890089018902890389048905890689078908890989108911891289138914891589168917891889198920892189228923892489258926892789288929893089318932893389348935893689378938893989408941894289438944894589468947894889498950895189528953895489558956895789588959896089618962896389648965896689678968896989708971897289738974897589768977897889798980898189828983898489858986898789888989899089918992899389948995899689978998899990009001900290039004900590069007900890099010901190129013901490159016901790189019902090219022902390249025902690279028902990309031903290339034903590369037903890399040904190429043904490459046904790489049905090519052905390549055905690579058905990609061906290639064906590669067906890699070907190729073907490759076907790789079908090819082908390849085908690879088908990909091909290939094909590969097909890999100910191029103910491059106910791089109911091119112911391149115911691179118911991209121912291239124912591269127912891299130913191329133913491359136913791389139914091419142914391449145914691479148914991509151915291539154915591569157915891599160916191629163916491659166916791689169917091719172917391749175917691779178917991809181918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109211921292139214921592169217921892199220922192229223922492259226922792289229923092319232923392349235923692379238923992409241924292439244924592469247924892499250925192529253925492559256925792589259926092619262926392649265926692679268926992709271927292739274927592769277927892799280928192829283928492859286928792889289929092919292929392949295929692979298929993009301930293039304930593069307930893099310931193129313931493159316931793189319932093219322932393249325932693279328932993309331933293339334933593369337933893399340934193429343934493459346934793489349935093519352935393549355935693579358935993609361936293639364936593669367936893699370937193729373937493759376937793789379938093819382938393849385938693879388938993909391939293939394939593969397939893999400940194029403940494059406940794089409941094119412941394149415941694179418941994209421942294239424942594269427942894299430943194329433943494359436943794389439944094419442944394449445944694479448944994509451945294539454945594569457945894599460946194629463946494659466946794689469947094719472947394749475947694779478947994809481948294839484948594869487948894899490949194929493949494959496949794989499950095019502950395049505950695079508950995109511951295139514951595169517951895199520952195229523952495259526952795289529953095319532953395349535953695379538953995409541954295439544954595469547954895499550955195529553955495559556955795589559956095619562956395649565956695679568956995709571957295739574957595769577957895799580958195829583958495859586958795889589959095919592959395949595959695979598959996009601960296039604960596069607960896099610961196129613961496159616961796189619962096219622962396249625962696279628962996309631963296339634963596369637963896399640964196429643964496459646964796489649965096519652965396549655965696579658965996609661966296639664966596669667966896699670967196729673967496759676967796789679968096819682968396849685968696879688968996909691969296939694969596969697969896999700970197029703970497059706970797089709971097119712971397149715971697179718971997209721972297239724972597269727972897299730973197329733973497359736973797389739974097419742974397449745974697479748974997509751975297539754975597569757975897599760976197629763976497659766976797689769977097719772977397749775977697779778977997809781978297839784978597869787978897899790979197929793979497959796979797989799980098019802980398049805980698079808980998109811981298139814981598169817981898199820982198229823982498259826982798289829983098319832983398349835983698379838983998409841984298439844984598469847984898499850985198529853985498559856985798589859986098619862986398649865986698679868986998709871987298739874987598769877987898799880988198829883988498859886988798889889989098919892989398949895989698979898989999009901990299039904990599069907990899099910991199129913991499159916991799189919992099219922992399249925992699279928992999309931993299339934993599369937993899399940994199429943994499459946994799489949995099519952995399549955995699579958995999609961996299639964996599669967996899699970997199729973997499759976997799789979998099819982998399849985998699879988998999909991999299939994999599969997999899991000010001100021000310004100051000610007100081000910010100111001210013100141001510016100171001810019100201002110022100231002410025100261002710028100291003010031100321003310034100351003610037100381003910040100411004210043100441004510046100471004810049100501005110052100531005410055100561005710058100591006010061100621006310064100651006610067100681006910070100711007210073100741007510076100771007810079100801008110082100831008410085100861008710088100891009010091100921009310094100951009610097100981009910100101011010210103101041010510106101071010810109101101011110112101131011410115101161011710118101191012010121101221012310124101251012610127101281012910130101311013210133101341013510136101371013810139101401014110142101431014410145101461014710148101491015010151101521015310154101551015610157101581015910160101611016210163101641016510166101671016810169101701017110172101731017410175101761017710178101791018010181101821018310184101851018610187101881018910190101911019210193101941019510196101971019810199102001020110202102031020410205102061020710208102091021010211102121021310214102151021610217102181021910220102211022210223102241022510226102271022810229102301023110232102331023410235102361023710238102391024010241102421024310244102451024610247102481024910250102511025210253102541025510256102571025810259102601026110262102631026410265102661026710268102691027010271102721027310274102751027610277102781027910280102811028210283102841028510286102871028810289102901029110292102931029410295102961029710298102991030010301103021030310304103051030610307103081030910310103111031210313103141031510316103171031810319103201032110322103231032410325103261032710328103291033010331103321033310334103351033610337103381033910340103411034210343103441034510346103471034810349103501035110352103531035410355103561035710358103591036010361103621036310364103651036610367103681036910370103711037210373103741037510376103771037810379103801038110382103831038410385103861038710388103891039010391103921039310394103951039610397103981039910400104011040210403104041040510406104071040810409104101041110412104131041410415104161041710418104191042010421104221042310424104251042610427104281042910430104311043210433104341043510436104371043810439104401044110442104431044410445104461044710448104491045010451104521045310454104551045610457104581045910460104611046210463104641046510466104671046810469104701047110472104731047410475104761047710478104791048010481104821048310484104851048610487104881048910490104911049210493104941049510496104971049810499105001050110502105031050410505105061050710508105091051010511105121051310514105151051610517105181051910520105211052210523105241052510526105271052810529105301053110532105331053410535105361053710538105391054010541105421054310544105451054610547105481054910550105511055210553105541055510556105571055810559105601056110562105631056410565105661056710568105691057010571105721057310574105751057610577105781057910580105811058210583105841058510586105871058810589105901059110592105931059410595105961059710598105991060010601106021060310604106051060610607106081060910610106111061210613106141061510616106171061810619106201062110622106231062410625106261062710628106291063010631106321063310634106351063610637106381063910640106411064210643106441064510646106471064810649106501065110652106531065410655106561065710658106591066010661106621066310664106651066610667106681066910670106711067210673106741067510676106771067810679106801068110682106831068410685106861068710688106891069010691106921069310694106951069610697106981069910700107011070210703107041070510706107071070810709107101071110712107131071410715107161071710718107191072010721107221072310724107251072610727107281072910730107311073210733107341073510736107371073810739107401074110742107431074410745107461074710748107491075010751107521075310754107551075610757107581075910760107611076210763107641076510766107671076810769107701077110772107731077410775107761077710778107791078010781107821078310784107851078610787107881078910790107911079210793107941079510796107971079810799108001080110802108031080410805108061080710808108091081010811108121081310814108151081610817108181081910820108211082210823108241082510826108271082810829108301083110832108331083410835108361083710838108391084010841108421084310844108451084610847108481084910850108511085210853108541085510856108571085810859108601086110862108631086410865108661086710868108691087010871108721087310874108751087610877108781087910880108811088210883108841088510886108871088810889108901089110892108931089410895108961089710898108991090010901109021090310904109051090610907109081090910910109111091210913109141091510916109171091810919109201092110922109231092410925109261092710928109291093010931109321093310934109351093610937109381093910940109411094210943109441094510946109471094810949109501095110952109531095410955109561095710958109591096010961109621096310964109651096610967109681096910970109711097210973109741097510976109771097810979109801098110982109831098410985109861098710988109891099010991109921099310994109951099610997109981099911000110011100211003110041100511006110071100811009110101101111012110131101411015110161101711018110191102011021110221102311024110251102611027110281102911030110311103211033110341103511036110371103811039110401104111042110431104411045110461104711048110491105011051110521105311054110551105611057110581105911060110611106211063110641106511066110671106811069110701107111072110731107411075110761107711078110791108011081110821108311084110851108611087110881108911090110911109211093110941109511096110971109811099111001110111102111031110411105111061110711108111091111011111111121111311114111151111611117111181111911120111211112211123111241112511126111271112811129111301113111132111331113411135111361113711138111391114011141111421114311144111451114611147111481114911150111511115211153111541115511156111571115811159111601116111162111631116411165111661116711168111691117011171111721117311174111751117611177111781117911180111811118211183111841118511186111871118811189111901119111192111931119411195111961119711198111991120011201112021120311204112051120611207112081120911210112111121211213112141121511216112171121811219112201122111222112231122411225112261122711228112291123011231112321123311234112351123611237112381123911240112411124211243112441124511246112471124811249112501125111252112531125411255112561125711258112591126011261112621126311264112651126611267112681126911270112711127211273112741127511276112771127811279112801128111282112831128411285112861128711288112891129011291112921129311294112951129611297112981129911300113011130211303113041130511306113071130811309113101131111312113131131411315113161131711318113191132011321113221132311324113251132611327113281132911330113311133211333113341133511336113371133811339113401134111342113431134411345113461134711348113491135011351113521135311354113551135611357113581135911360113611136211363113641136511366113671136811369113701137111372113731137411375113761137711378113791138011381113821138311384113851138611387113881138911390113911139211393113941139511396113971139811399114001140111402114031140411405114061140711408114091141011411114121141311414114151141611417114181141911420114211142211423114241142511426114271142811429114301143111432114331143411435114361143711438114391144011441114421144311444114451144611447114481144911450114511145211453114541145511456114571145811459114601146111462114631146411465114661146711468114691147011471114721147311474114751147611477114781147911480114811148211483114841148511486114871148811489114901149111492114931149411495114961149711498114991150011501115021150311504115051150611507115081150911510115111151211513115141151511516115171151811519115201152111522115231152411525115261152711528115291153011531115321153311534115351153611537115381153911540115411154211543115441154511546115471154811549115501155111552115531155411555115561155711558115591156011561115621156311564115651156611567115681156911570115711157211573115741157511576115771157811579115801158111582115831158411585115861158711588115891159011591115921159311594115951159611597115981159911600116011160211603116041160511606116071160811609116101161111612116131161411615116161161711618116191162011621116221162311624116251162611627116281162911630116311163211633116341163511636116371163811639116401164111642116431164411645116461164711648116491165011651116521165311654116551165611657116581165911660116611166211663116641166511666116671166811669116701167111672116731167411675116761167711678116791168011681116821168311684116851168611687116881168911690116911169211693116941169511696116971169811699117001170111702117031170411705117061170711708117091171011711117121171311714117151171611717117181171911720117211172211723117241172511726117271172811729117301173111732117331173411735117361173711738117391174011741117421174311744117451174611747117481174911750117511175211753117541175511756117571175811759117601176111762117631176411765117661176711768117691177011771117721177311774117751177611777117781177911780117811178211783117841178511786117871178811789117901179111792117931179411795117961179711798117991180011801118021180311804118051180611807118081180911810118111181211813118141181511816118171181811819118201182111822118231182411825118261182711828118291183011831118321183311834118351183611837118381183911840118411184211843118441184511846118471184811849118501185111852118531185411855118561185711858118591186011861118621186311864118651186611867118681186911870118711187211873118741187511876118771187811879118801188111882118831188411885118861188711888118891189011891118921189311894118951189611897118981189911900119011190211903119041190511906119071190811909119101191111912119131191411915119161191711918119191192011921119221192311924119251192611927119281192911930119311193211933119341193511936119371193811939119401194111942119431194411945119461194711948119491195011951119521195311954119551195611957119581195911960119611196211963119641196511966119671196811969119701197111972119731197411975119761197711978119791198011981119821198311984119851198611987119881198911990119911199211993119941199511996119971199811999120001200112002120031200412005120061200712008120091201012011120121201312014120151201612017120181201912020120211202212023120241202512026120271202812029120301203112032120331203412035120361203712038120391204012041120421204312044120451204612047120481204912050120511205212053120541205512056120571205812059120601206112062120631206412065120661206712068120691207012071120721207312074120751207612077120781207912080120811208212083120841208512086120871208812089120901209112092120931209412095120961209712098120991210012101121021210312104121051210612107121081210912110121111211212113121141211512116121171211812119121201212112122121231212412125121261212712128121291213012131121321213312134121351213612137121381213912140121411214212143121441214512146121471214812149121501215112152121531215412155121561215712158121591216012161121621216312164121651216612167121681216912170121711217212173121741217512176121771217812179121801218112182121831218412185121861218712188121891219012191121921219312194121951219612197121981219912200122011220212203122041220512206122071220812209122101221112212122131221412215122161221712218122191222012221122221222312224122251222612227122281222912230122311223212233122341223512236122371223812239122401224112242122431224412245122461224712248122491225012251122521225312254122551225612257122581225912260122611226212263122641226512266122671226812269122701227112272122731227412275122761227712278122791228012281122821228312284122851228612287122881228912290122911229212293122941229512296122971229812299123001230112302123031230412305123061230712308123091231012311123121231312314123151231612317123181231912320123211232212323123241232512326123271232812329123301233112332123331233412335123361233712338123391234012341123421234312344123451234612347123481234912350123511235212353123541235512356123571235812359123601236112362123631236412365123661236712368123691237012371123721237312374123751237612377123781237912380123811238212383123841238512386123871238812389123901239112392123931239412395123961239712398123991240012401124021240312404124051240612407124081240912410124111241212413124141241512416124171241812419124201242112422124231242412425124261242712428124291243012431124321243312434124351243612437124381243912440124411244212443124441244512446124471244812449124501245112452124531245412455124561245712458124591246012461124621246312464124651246612467124681246912470124711247212473124741247512476124771247812479124801248112482124831248412485124861248712488124891249012491124921249312494124951249612497124981249912500125011250212503125041250512506125071250812509125101251112512125131251412515125161251712518125191252012521125221252312524125251252612527125281252912530125311253212533125341253512536125371253812539125401254112542125431254412545125461254712548125491255012551125521255312554125551255612557125581255912560125611256212563125641256512566125671256812569125701257112572125731257412575125761257712578125791258012581125821258312584125851258612587125881258912590125911259212593125941259512596125971259812599126001260112602126031260412605126061260712608126091261012611126121261312614126151261612617126181261912620126211262212623126241262512626126271262812629126301263112632126331263412635126361263712638126391264012641126421264312644126451264612647126481264912650126511265212653126541265512656126571265812659126601266112662126631266412665126661266712668126691267012671126721267312674126751267612677126781267912680126811268212683126841268512686126871268812689126901269112692126931269412695126961269712698126991270012701127021270312704127051270612707127081270912710127111271212713127141271512716127171271812719127201272112722127231272412725127261272712728127291273012731127321273312734127351273612737127381273912740127411274212743127441274512746127471274812749127501275112752127531275412755127561275712758127591276012761127621276312764127651276612767127681276912770127711277212773127741277512776127771277812779127801278112782127831278412785127861278712788127891279012791127921279312794127951279612797127981279912800128011280212803128041280512806128071280812809128101281112812128131281412815128161281712818128191282012821128221282312824128251282612827128281282912830128311283212833128341283512836128371283812839128401284112842128431284412845128461284712848128491285012851128521285312854128551285612857128581285912860128611286212863128641286512866128671286812869128701287112872128731287412875128761287712878128791288012881128821288312884128851288612887128881288912890128911289212893128941289512896128971289812899129001290112902129031290412905129061290712908129091291012911129121291312914129151291612917129181291912920129211292212923129241292512926129271292812929129301293112932129331293412935129361293712938129391294012941129421294312944129451294612947129481294912950129511295212953129541295512956129571295812959129601296112962129631296412965129661296712968129691297012971129721297312974129751297612977129781297912980129811298212983129841298512986129871298812989129901299112992129931299412995129961299712998129991300013001130021300313004130051300613007130081300913010130111301213013130141301513016130171301813019130201302113022130231302413025130261302713028130291303013031130321303313034130351303613037130381303913040130411304213043130441304513046130471304813049130501305113052130531305413055130561305713058130591306013061130621306313064130651306613067130681306913070130711307213073130741307513076130771307813079130801308113082130831308413085130861308713088130891309013091130921309313094130951309613097130981309913100131011310213103131041310513106131071310813109131101311113112131131311413115131161311713118131191312013121131221312313124131251312613127131281312913130131311313213133131341313513136131371313813139131401314113142131431314413145131461314713148131491315013151131521315313154131551315613157131581315913160131611316213163131641316513166131671316813169131701317113172131731317413175131761317713178131791318013181131821318313184131851318613187131881318913190131911319213193131941319513196131971319813199132001320113202132031320413205132061320713208132091321013211132121321313214132151321613217132181321913220132211322213223132241322513226132271322813229132301323113232132331323413235132361323713238132391324013241132421324313244132451324613247132481324913250132511325213253132541325513256132571325813259132601326113262132631326413265132661326713268132691327013271132721327313274132751327613277132781327913280132811328213283132841328513286132871328813289132901329113292132931329413295132961329713298132991330013301133021330313304133051330613307133081330913310133111331213313133141331513316133171331813319133201332113322133231332413325133261332713328133291333013331133321333313334133351333613337133381333913340133411334213343133441334513346133471334813349133501335113352133531335413355133561335713358133591336013361133621336313364133651336613367133681336913370133711337213373133741337513376133771337813379133801338113382133831338413385133861338713388133891339013391133921339313394133951339613397133981339913400134011340213403//! Production application seam between immutable content/world plans and the//! authoritative simulation State. Terminal input and headless tests commit the//! same typed actions through [`GameSession::dispatch`].
use std::cell::RefCell;use std::collections::{BTreeMap, BTreeSet};use std::fmt;use hexwork_core::spatial::FixedVec3;#[cfg(any(test, feature = "test-support"))]use std::path::Path;
use hexwork_content::{Item, RuntimePack};use hexwork_core::action::{CommittedAction, Direction};use hexwork_core::heading::Heading;use hexwork_core::bargain::Ledger;use hexwork_core::character::{ self, kit, Calling, Character, ALL_ATTRIBUTES, ALL_SKILLS, MAX_ATTR, MAX_SKILL,};use hexwork_core::hymn::HymnCase;use hexwork_core::inventory::Pack;use hexwork_core::item::{self, Instance, ItemTemplate};use hexwork_core::light::LightSource;use hexwork_core::modes::Mode;use hexwork_core::npc::{Job, Townsfolk};use hexwork_core::reputation::{Reputation, MILTHOLLOW};use hexwork_core::runtime::{ validate_knowledge_id, ActorIdentity, ActorRole, ContractError, ContractStatus, KnowledgeCategory, RunLocation, TransferError, WalletError, KNOWLEDGE_TOTAL_ID_BYTES_LIMIT, KNOWLEDGE_TOTAL_ROW_LIMIT, MAPPED_REGION_LIMIT,};use hexwork_core::pacing::Settlement;use hexwork_core::pending::{Pending, PendingAction};use hexwork_core::services::{self, Service};use hexwork_core::state::{PaidUnattendedError, State};use hexwork_core::voxel::{Voxel, CHUNK};#[cfg(test)]use hexwork_save::MAX_ROWS;#[cfg(test)]use hexwork_save::{decode_replay, decode_run, encode_replay};use hexwork_save::{ encode_run, event_prefix_digest, timeline_costs, validate_initial_lineage, validate_run_structural_limits, DecodedRun, HashedState, InitialLineage, PersistenceError, ProductionReplay, RunIdentity, SessionEvent, SessionEventKind, StoryCheckpoint, TimelineCosts, MAX_SESSION_EVENTS,};#[cfg(any(test, feature = "test-support"))]use hexwork_save::{LineageId, RunId};use hexwork_term::Input;use hexwork_ui::board::{board_list, contract_card};use hexwork_ui::look::{item_tooltip, Tooltip};use hexwork_worldgen::{ milthollow, playable_deep, validate_deep_links, ActorSpawnPlan, Cell, ConnectorPlan, FloorPlan, InteractionAnchor, InteractionKind, ItemSpawnPlan, MovementPolicy, PlanDestination, VoxelWrite, WorldPlan,};
const PLAYER_TEMPLATE: &str = "player_delver";const TOWNSFOLK_TEMPLATE: &str = "townsfolk";const BOARD_TIER_MAX: u8 = 5;const RATS_CONTRACT: &str = "rats_in_granary";// The content schema intentionally has no objective-count or standing fields.// These are the production vertical-slice defaults until that schema grows.const RATS_OBJECTIVE_TARGET: u32 = 3;const RATS_STANDING_DELTA: i16 = 1;const PLAYER_LIGHT_RADIUS: u8 = 6;const PLAYER_STRIKE_DAMAGE: i32 = 3;/// Story resurrection restores the accepted town checkpoint and wakes the/// canonical player with the production baseline health.const STORY_RESURRECTION_HP: i32 = 10;const CORPSE_TEMPLATE: &str = "corpse";const INVENTORY_PAGE_SIZE: usize = 6;const INVENTORY_TOOLTIP_TEXT_LIMIT: usize = 16 * 1024;const MAX_TOOLTIP_USE_ROWS: usize = 256;const INVENTORY_TOOLTIP_CLIPPED_LINE: &str = "[content clipped]";const INVENTORY_TOOLTIP_USES_CLIPPED_LINE: &str = "Uses omitted: bounded inspection could not retain them.";const TRANSFER_ITEM_NAME_LIMIT: usize = 512;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]enum PlanLocation { Town, Deep(u8),}
impl PlanLocation { fn run_location(self) -> RunLocation { match self { Self::Town => RunLocation::Town(MILTHOLLOW.to_owned()), Self::Deep(depth) => RunLocation::Deep(depth), } }}
#[derive(Clone, Debug, PartialEq, Eq)]struct RegisteredInteraction { position: Cell, access: Cell, kind: InteractionKind, label: String,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum MeleeGeometryError { AttackerMissing, AttackerDead, TargetMissing, TargetIdentityMissing, TargetNotHostile, TargetDead, LocationUninitialized, WrongLocation, OutOfRange, CornerBlocked,}
/// Read-only active-location metadata for tactical presentation.#[derive(Clone, Debug, PartialEq)]pub struct TacticalContext { pub location: RunLocation, pub bounds: hexwork_worldgen::Bounds, pub player_cell: Cell,}
/// Compact immutable navigation/provenance retained after eager materialization.#[derive(Clone, Debug)]struct LocationCatalog { location: PlanLocation, bounds: hexwork_worldgen::Bounds, connectors: Vec<ConnectorPlan>, interactions: BTreeMap<(i32, i32, i32), Vec<RegisteredInteraction>>,}
#[derive(Clone, Debug)]struct RuntimeCatalog { locations: BTreeMap<PlanLocation, LocationCatalog>,}
impl RuntimeCatalog { fn from_plans(town: &WorldPlan, floors: &[FloorPlan]) -> Result<Self, SessionError> { let mut locations = BTreeMap::new(); let town_catalog = LocationCatalog::from_world(town)?; locations.insert(town_catalog.location, town_catalog); for floor in floors { let floor_catalog = LocationCatalog::from_floor(floor)?; if locations .insert(floor_catalog.location, floor_catalog) .is_some() { return Err(SessionError::content( "catalog-location-duplicate", floor.depth.to_string(), )); } } Ok(Self { locations }) }
fn location(&self, location: PlanLocation) -> Option<&LocationCatalog> { self.locations.get(&location) }}
impl LocationCatalog { fn from_world(plan: &WorldPlan) -> Result<Self, SessionError> { Self::new( PlanLocation::Town, plan.bounds, plan.connectors.clone(), &plan.interactions, ) }
fn from_floor(plan: &FloorPlan) -> Result<Self, SessionError> { Self::new( PlanLocation::Deep(plan.depth), plan.bounds, plan.connectors.clone(), &plan.interactions, ) }
fn new( location: PlanLocation, bounds: hexwork_worldgen::Bounds, connectors: Vec<ConnectorPlan>, anchors: &[InteractionAnchor], ) -> Result<Self, SessionError> { let mut interactions: BTreeMap<_, Vec<RegisteredInteraction>> = BTreeMap::new(); for anchor in anchors { let entries = interactions.entry(cell_key(anchor.access)).or_default(); if entries.iter().any(|entry| entry.kind == anchor.kind) { return Err(SessionError::content( "interaction-duplicate", format!("{location:?} {:?}", anchor.access), )); } entries.push(RegisteredInteraction { position: anchor.position, access: anchor.access, kind: anchor.kind.clone(), label: anchor.label.clone(), }); } Ok(Self { location, bounds, connectors, interactions, }) }}
/// Read-only active-location landmark; IDs are presentation-only, never saved events.#[derive(Clone, Debug, PartialEq, Eq)]pub struct PresentationLandmark { pub stable_id: u64, pub access: Cell, pub kind: PresentationLandmarkKind,}
#[derive(Clone, Debug, PartialEq, Eq)]pub enum PresentationLandmarkKind { Interaction(InteractionKind), Connector(hexwork_worldgen::TransitionDirection),}
/// Inputs supplied by chargen to deterministic production initialization.#[derive(Clone, Debug, PartialEq, Eq)]pub struct PlayerSetup { pub display_name: String, pub character: Character,}
impl PlayerSetup { pub fn new(display_name: impl Into<String>, character: Character) -> Self { Self { display_name: display_name.into(), character, } }}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum PlayerLiveness { Alive, Dead,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum CarriedLampPresentation { Fuel { current: u8, maximum: u8 }, None, Unavailable,}
#[derive(Clone, Copy, Debug, PartialEq)]pub(crate) enum BurdenPresentation { Known { weight_kg: f32, max_weight_kg: f32, bulk: u8, max_bulk: u8, overburdened: bool, }, HiddenWeight { overburdened: bool, },}
#[derive(Clone, Debug, PartialEq)]pub(crate) struct PlayerPresentation { pub position: [f32; 3], pub hp: i32, pub location: hexwork_core::runtime::RunLocation, pub tick: u64, pub carried_lamp: CarriedLampPresentation, pub burden: Option<BurdenPresentation>,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum TickOutcome { Advanced { pre_tick: u64, post_tick: u64, liveness: PlayerLiveness, }, LifecycleRequired { tick: u64, liveness: PlayerLiveness, },}
/// Admission result for a committed request (docs/fluid §4.3).////// `Resolved` carries the immediate effect of a zero/one-tick action/// (`Move`, `Wait`, `InspectBoard`); `Admitted` means the effect was reserved in/// the single pending slot and will apply at a later world tick.#[derive(Clone, Debug, PartialEq, Eq)]pub enum ActionAdmission { Resolved(ActionOutcome), Admitted { total_ticks: u64 },}
/// One pending-aware world tick (docs/fluid §4.3 tick order): consume admitted/// movement and run the world phases, resolve death/lifecycle first, then drive/// the surviving pending descriptor one countdown step.#[derive(Clone, Debug)]pub(crate) enum TickProgress { /// The world advanced; no pending action completed on this tick. Advanced { pre_tick: u64, post_tick: u64, liveness: PlayerLiveness, }, /// The world advanced and the pending action's final tick was consumed. The /// effect was revalidated against the then-current state and applied exactly /// once (`Ok`), or reported as an honest failure without retargeting (`Err`). Completed { pre_tick: u64, post_tick: u64, liveness: PlayerLiveness, outcome: Result<ActionOutcome, SessionError>, }, /// A death/lifecycle boundary was reached; any pending action was aborted /// with its elapsed time spent and no effect applied. LifecycleRequired { tick: u64, liveness: PlayerLiveness, },}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct InventoryEntryView { pub instance_id: u64, pub template_id: String, pub effective_identified: bool, pub wielded: bool, pub wieldable: bool, pub tooltip: Tooltip, pub tooltip_clipped: bool,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum InventoryPageQuery { First, AtOrAfter(u64), After(u64), Before(u64),}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct InventoryPage { pub entries: Vec<InventoryEntryView>, pub total: usize, pub has_before: bool, pub has_after: bool,}
#[cfg(test)]#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]struct InventoryQueryProbes { page: usize, boundary: usize, projected: usize, direct_lookup: usize,}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct InventoryView { pub entries: Vec<InventoryEntryView>, pub total: usize, pub truncated: bool,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum GroundTargetQuery { First, After(u64), Before(u64),}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct GroundTargetView { pub entry: InventoryEntryView, pub cell: Cell, pub has_before: bool, pub has_after: bool,}
#[cfg(test)]#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]struct GroundQueryProbes { visited: usize, projected: usize, direct_lookup: usize,}
#[derive(Clone, Debug, PartialEq, Eq)]pub enum ActionOutcome { Moved { from: Cell, to: Cell, }, Waited { tick: u64, }, Board { town: String, text: String, }, ContractAccepted { id: String, }, Transitioned { transition_id: u64, location: RunLocation, position: Cell, }, AttackResolved { target_actor: u64, damage: i32, killed: bool, retaliation_damage: Option<i32>, player_killed: bool, objective_progress: Option<(u32, u32)>, }, PickedUp { item_id: u64, item_name: String, }, Dropped { item_id: u64, item_name: String, }, Wielded { item_id: u64, item_name: String, }, TinkerIdentified { item_id: u64, cost_cu: u32, }, ContractTurnedIn { id: String, reward_cu: u32, }, Slept { cost_cu: u32, ticks: u64, },}
impl ActionOutcome { /// World ticks this outcome consumed (docs/fluid §4.2). Must match the /// core [`hexwork_core::action_timing`] table so journal spans agree. pub(crate) fn elapsed_ticks(&self) -> u64 { match self { Self::Moved { .. } | Self::Waited { .. } => 1, Self::Board { .. } => 0, Self::TinkerIdentified { .. } => 20, Self::Slept { ticks, .. } => *ticks, Self::PickedUp { .. } | Self::Dropped { .. } | Self::Wielded { .. } | Self::ContractAccepted { .. } | Self::Transitioned { .. } | Self::AttackResolved { .. } | Self::ContractTurnedIn { .. } => 10, } }
pub(crate) fn log_text(&self) -> String { match self { Self::Moved { from, to } => format!("step {from:?} → {to:?}"), Self::Waited { tick } => format!("you wait; the Deep keeps tick {tick}"), Self::Board { text, .. } => text.clone(), Self::ContractAccepted { id } => format!("contract accepted: {id}"), Self::Transitioned { location, position, .. } => format!("passage leads to {location:?} at {position:?}"), Self::AttackResolved { target_actor, damage, killed, retaliation_damage, player_killed, objective_progress, } => { let result = if *killed { "killed" } else { "struck" }; let retaliation = retaliation_damage .map(|damage| format!("; it returns {damage}")) .unwrap_or_default(); let death = if *player_killed { "; you are killed" } else { "" }; let progress = objective_progress .map(|(current, target)| format!("; rats {current}/{target}")) .unwrap_or_default(); format!("{result} actor {target_actor} for {damage}{retaliation}{death}{progress}") } Self::PickedUp { item_id, item_name } => { format!("picked up {item_name} (item {item_id})") } Self::Dropped { item_id, item_name } => { format!("dropped {item_name} (item {item_id})") } Self::Wielded { item_id, item_name } => { format!("wielded {item_name} (item {item_id})") } Self::TinkerIdentified { item_id, cost_cu } => { format!("identified item {item_id} for {cost_cu}cu; Known") } Self::ContractTurnedIn { id, reward_cu } => { format!("{id} turned in: {reward_cu}cu, and the ledger remembers") } Self::Slept { cost_cu, ticks } => { format!("the inn takes {cost_cu}cu; the world moves {ticks} ticks") } } }}
/// Stable error code plus an explicit process classification. Callers may/// assert the code without depending on Debug formatting from foundation enums.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum SessionErrorClass { Content, Runtime, InputRejected,}
#[derive(Clone, Debug, PartialEq, Eq)]pub struct SessionError { pub class: SessionErrorClass, pub code: &'static str, pub detail: String,}
impl SessionError { pub(crate) fn content(code: &'static str, detail: impl Into<String>) -> Self { Self::classified(SessionErrorClass::Content, code, detail) }
pub(crate) fn runtime(code: &'static str, detail: impl Into<String>) -> Self { Self::classified(SessionErrorClass::Runtime, code, detail) }
pub(crate) fn rejected(code: &'static str, detail: impl Into<String>) -> Self { Self::classified(SessionErrorClass::InputRejected, code, detail) }
fn classified(class: SessionErrorClass, code: &'static str, detail: impl Into<String>) -> Self { Self { class, code, detail: detail.into(), } }
pub(crate) fn with_context(self, code: &'static str, detail: impl Into<String>) -> Self { let class = match self.class { SessionErrorClass::InputRejected => SessionErrorClass::Content, class => class, }; Self::classified(class, code, detail) }
fn into_runtime(self) -> Self { Self::runtime(self.code, self.detail) }
pub(crate) fn from_encode_error(code: &'static str, error: PersistenceError) -> Self { let detail = error.to_string(); match error { PersistenceError::InputTooLarge { .. } | PersistenceError::DecompressedTooLarge { .. } | PersistenceError::Limit { .. } | PersistenceError::Compression(_) | PersistenceError::Invalid { .. } | PersistenceError::CoreImage(_) | PersistenceError::CoreState(_) => Self::runtime(code, detail), PersistenceError::Decompression(_) | PersistenceError::Decode(_) | PersistenceError::TrailingBytes { .. } | PersistenceError::WrongMagic { .. } | PersistenceError::OlderVersion { .. } | PersistenceError::NewerVersion { .. } | PersistenceError::CanonicalHashVersion { .. } | PersistenceError::ContentHashMismatch { .. } | PersistenceError::MetadataMismatch { .. } | PersistenceError::CheckpointHashMismatch { .. } => Self::content(code, detail), } }
#[cfg(test)] fn from_decode_error(code: &'static str, error: PersistenceError) -> Self { let detail = error.to_string(); match error { PersistenceError::InputTooLarge { .. } | PersistenceError::DecompressedTooLarge { .. } | PersistenceError::Limit { .. } => Self::runtime(code, detail), PersistenceError::Compression(_) | PersistenceError::Decompression(_) | PersistenceError::Decode(_) | PersistenceError::TrailingBytes { .. } | PersistenceError::WrongMagic { .. } | PersistenceError::OlderVersion { .. } | PersistenceError::NewerVersion { .. } | PersistenceError::CanonicalHashVersion { .. } | PersistenceError::ContentHashMismatch { .. } | PersistenceError::MetadataMismatch { .. } | PersistenceError::CheckpointHashMismatch { .. } | PersistenceError::Invalid { .. } | PersistenceError::CoreImage(_) | PersistenceError::CoreState(_) => Self::content(code, detail), } }}
impl fmt::Display for SessionError { fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result { write!(out, "{}: {}", self.code, self.detail) }}
impl std::error::Error for SessionError {}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct BoardView { pub index: usize, pub total: usize, pub id: String, pub title: String, pub page: String,}
impl BoardView { pub(crate) fn log_text(&self) -> String { format!( "selected [{}/{}]: {}\n{}", self.index + 1, self.total, self.title, self.page ) }}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) enum DispatchSource { Play, BoardAccept,}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) enum ContextualIntent { Commit(CommittedAction), OpenTinkerIdentify,}
#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) enum ControllerEvent { OpenTinkerIdentify, Dispatch { action: CommittedAction, source: DispatchSource, }, BoardOpenRequested { inspect: CommittedAction, view: Option<BoardView>, }, BoardSelectionChanged(BoardView), BoardClosed, Control(Input), None,}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub(crate) struct DispatchPresentation { pub close_board: bool,}
#[derive(Clone, Debug, Default, PartialEq, Eq)]pub(crate) struct PlayController { board_open: bool, selected_contract: usize,}
impl PlayController { #[cfg(test)] pub(crate) fn board_open(&self) -> bool { self.board_open }
#[cfg(test)] pub(crate) fn selected_contract(&self) -> usize { self.selected_contract }
pub(crate) fn board_view(&self, session: &GameSession) -> Option<BoardView> { session.board_view(self.selected_contract) }
fn select_board(&mut self, session: &GameSession, index: usize) -> ControllerEvent { if session.board_len() == 0 || index >= session.board_len() { return ControllerEvent::None; } self.selected_contract = index; self.board_view(session) .map(ControllerEvent::BoardSelectionChanged) .unwrap_or(ControllerEvent::None) }
pub(crate) fn board_open_succeeded(&mut self) { self.board_open = true; self.selected_contract = 0; }
pub(crate) fn dispatch_succeeded(&mut self, source: &DispatchSource) -> DispatchPresentation { if *source == DispatchSource::BoardAccept && self.board_open { self.board_open = false; DispatchPresentation { close_board: true } } else { DispatchPresentation::default() } }
pub(crate) fn adapt(&mut self, input: Input, session: &GameSession) -> ControllerEvent { if self.board_open { return match input { Input::Quit | Input::Contracts => { self.board_open = false; ControllerEvent::BoardClosed } Input::Up => { let total = session.board_len(); let index = if total == 0 { 0 } else { (self.selected_contract + total - 1) % total }; self.select_board(session, index) } Input::Down => { let total = session.board_len(); let index = if total == 0 { 0 } else { (self.selected_contract + 1) % total }; self.select_board(session, index) } Input::ChargenPick(index) => self.select_board(session, index), Input::Confirm => self .board_view(session) .map(|view| ControllerEvent::Dispatch { action: CommittedAction::AcceptContract { id: view.id }, source: DispatchSource::BoardAccept, }) .unwrap_or(ControllerEvent::None), _ => ControllerEvent::None, }; } match input { Input::Contracts => ControllerEvent::BoardOpenRequested { inspect: CommittedAction::InspectBoard { town: MILTHOLLOW.to_owned(), }, view: session.board_view(0), }, Input::Confirm => session .contextual_intent() .map(|intent| match intent { ContextualIntent::Commit(action) => ControllerEvent::Dispatch { action, source: DispatchSource::Play, }, ContextualIntent::OpenTinkerIdentify => ControllerEvent::OpenTinkerIdentify, }) .unwrap_or(ControllerEvent::None), Input::Up => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::North), source: DispatchSource::Play, }, Input::Down => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::South), source: DispatchSource::Play, }, Input::Left => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::West), source: DispatchSource::Play, }, Input::Right => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::East), source: DispatchSource::Play, }, Input::UpLeft => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::NorthWest), source: DispatchSource::Play, }, Input::UpRight => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::NorthEast), source: DispatchSource::Play, }, Input::DownLeft => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::SouthWest), source: DispatchSource::Play, }, Input::DownRight => ControllerEvent::Dispatch { action: CommittedAction::Move(Direction::SouthEast), source: DispatchSource::Play, }, Input::Wait | Input::PauseStep => ControllerEvent::Dispatch { action: CommittedAction::Wait, source: DispatchSource::Play, }, Input::Quit | Input::Look | Input::Inventory | Input::Map | Input::Chapel | Input::Pickup | Input::Drop | Input::CameraToggle => ControllerEvent::Control(input), Input::TiltDown | Input::TiltUp | Input::SliceUp | Input::SliceDown | Input::Recenter | Input::LookReset | Input::Resize(_, _) | Input::MouseDown(_, _) | Input::MouseDrag(_, _) | Input::MouseUp | Input::FocusLost | Input::ChargenPick(_) | Input::ToggleWalk | Input::Unknown => ControllerEvent::None, } }}
pub(crate) fn reconcile_dispatch<T>( controller: &mut PlayController, source: &DispatchSource, result: &Result<T, SessionError>,) -> DispatchPresentation { if result.is_ok() { controller.dispatch_succeeded(source) } else { DispatchPresentation::default() }}
/// Pure adapter used by main to decide whether its visible Contracts screen/// should be popped after dispatch reconciliation.#[cfg(test)]pub(crate) const fn reconcile_board_screen( top_is_board: bool, presentation: DispatchPresentation,) -> bool { top_is_board && presentation.close_board}
/// Trusted replay work ceilings. Files never supply or raise these values.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct ReplayLimits { pub max_full_steps: u64, pub max_unattended_iterations: u64, pub max_total_elapsed_ticks: u64, pub max_evidence_rows: usize,}
impl Default for ReplayLimits { fn default() -> Self { Self { max_full_steps: 10_000, max_unattended_iterations: 50_000, max_total_elapsed_ticks: 100_000, max_evidence_rows: 16_384, } }}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum EvidenceSegment { Idle, Action,}
#[derive(Clone, Debug, PartialEq, Eq)]pub struct ReplayEvidence { pub event_ordinal: usize, pub segment: EvidenceSegment, pub local_ordinal: usize, pub tick: u64, pub rule: String, pub detail: String, pub subtitle: String,}
#[derive(Clone, Debug, PartialEq, Eq)]struct CheckpointWitness { count: u32, tick: u64, hash: u64, prefix_digest: [u8; 32], verified_event_count: usize, verified_history_digest: [u8; 32],}
#[derive(Clone, Copy, Debug)]struct ReplayBudget { full_steps: u64, unattended_iterations: u64, elapsed_ticks: u64, evidence_rows: usize, limits: ReplayLimits,}
#[cfg(test)]fn double_timeline_costs(costs: TimelineCosts) -> Result<TimelineCosts, SessionError> { Ok(TimelineCosts { full_steps: costs .full_steps .checked_mul(2) .ok_or_else(|| SessionError::runtime("replay-timeline-overflow", "full steps"))?, unattended_steps: costs .unattended_steps .checked_mul(2) .ok_or_else(|| SessionError::runtime("replay-timeline-overflow", "unattended steps"))?, total_elapsed_ticks: costs .total_elapsed_ticks .checked_mul(2) .ok_or_else(|| SessionError::runtime("replay-timeline-overflow", "elapsed ticks"))?, })}
impl ReplayBudget { fn new(costs: TimelineCosts, limits: ReplayLimits) -> Result<Self, SessionError> { if costs.full_steps > limits.max_full_steps { return Err(SessionError::runtime( "replay-full-step-limit", format!("{} > {}", costs.full_steps, limits.max_full_steps), )); } if costs.unattended_steps > limits.max_unattended_iterations { return Err(SessionError::runtime( "replay-unattended-limit", format!( "{} > {}", costs.unattended_steps, limits.max_unattended_iterations ), )); } if costs.total_elapsed_ticks > limits.max_total_elapsed_ticks { return Err(SessionError::runtime( "replay-elapsed-limit", format!( "{} > {}", costs.total_elapsed_ticks, limits.max_total_elapsed_ticks ), )); } Ok(Self { full_steps: limits.max_full_steps, unattended_iterations: limits.max_unattended_iterations, elapsed_ticks: limits.max_total_elapsed_ticks, evidence_rows: limits.max_evidence_rows, limits, }) }
fn debit_full(&mut self, amount: u64) -> Result<(), SessionError> { self.full_steps = self .full_steps .checked_sub(amount) .ok_or_else(|| SessionError::runtime("replay-full-step-limit", amount.to_string()))?; self.elapsed_ticks = self .elapsed_ticks .checked_sub(amount) .ok_or_else(|| SessionError::runtime("replay-elapsed-limit", amount.to_string()))?; Ok(()) }
fn debit_unattended(&mut self, amount: u64) -> Result<(), SessionError> { self.unattended_iterations = self .unattended_iterations .checked_sub(amount) .ok_or_else(|| SessionError::runtime("replay-unattended-limit", amount.to_string()))?; self.elapsed_ticks = self .elapsed_ticks .checked_sub(amount) .ok_or_else(|| SessionError::runtime("replay-elapsed-limit", amount.to_string()))?; Ok(()) }}
/// The sole production runtime owner. There is no actor mirror: `state.actors`/// remains authoritative and `state.ecs` remains empty. Full world plans are/// consumed during eager construction and never retained here.#[derive(Debug)]pub struct GameSession { state: State, pack: RuntimePack, catalog: RuntimeCatalog, identity: RunIdentity, events: Vec<SessionEvent>, story_checkpoint: Option<StoryCheckpoint>, checkpoint_witness: RefCell<Option<CheckpointWitness>>, replay_evidence: Vec<ReplayEvidence>, frozen_fault: Option<SessionError>,}
struct PreparedTransfer { candidate: State, outcome: ActionOutcome,}
impl GameSession { fn ensure_operational(&self) -> Result<(), SessionError> { match &self.frozen_fault { Some(fault) => Err(fault .clone() .with_context("session-frozen", fault.to_string())), None => Ok(()), } }
#[cfg(test)] fn latch_postmutation_fault_for_test(&mut self, fault: SessionError) { debug_assert!(self.frozen_fault.is_none()); self.frozen_fault = Some(fault); }
#[cfg(any(test, feature = "test-support"))] pub fn load( content_root: impl AsRef<Path>, seed: u64, setup: PlayerSetup, ) -> Result<Self, SessionError> { Self::load_with_mode(content_root, seed, Mode::Classic, setup) }
#[cfg(any(test, feature = "test-support"))] pub fn load_with_mode( content_root: impl AsRef<Path>, seed: u64, mode: Mode, setup: PlayerSetup, ) -> Result<Self, SessionError> { let pack = RuntimePack::load(content_root) .map_err(|error| SessionError::content("content-load", error.to_string()))?; let identity = RunIdentity { seed, mode, ancestry: setup.character.ancestry, calling: setup.character.calling, display_name: setup.display_name, content_hash: pack.hash().to_owned(), lineage_id: LineageId::from_bytes([0x51; 16]), run_id: RunId::new(1).expect("fixed test run ID is valid"), initial_lineage: InitialLineage::default(), }; Self::from_identity(pack, identity) }
/// Construct the canonical deterministic initial run solely from its /// complete recorded identity. The caller owns vault access and run-ID /// allocation; this initializer performs no filesystem or randomness I/O. pub(crate) fn from_identity( pack: RuntimePack, identity: RunIdentity, ) -> Result<Self, SessionError> { validate_identity_content(&pack, &identity)?; let setup = PlayerSetup::new( identity.display_name.clone(), character::new(identity.ancestry, identity.calling), ); let town = milthollow(identity.seed); let floors = playable_deep(identity.seed); preflight(&pack, &town, &floors, &setup)?; let catalog = RuntimeCatalog::from_plans(&town, &floors)?; let start = town.start; let mut state = State::new(identity.seed); state .initialize_town(MILTHOLLOW) .map_err(|error| SessionError::runtime("state-init", format!("{error:?}")))?; let player = state.spawn_actor_with_identity( checked_cell_pos(start)?, ActorIdentity::new( PLAYER_TEMPLATE, identity.display_name.clone(), ActorRole::Player, ), ); let canonical_character = character::new(identity.ancestry, identity.calling); state.characters.insert(player, canonical_character); let mut reputation = Reputation::new(); if identity.calling == Calling::ChoirDeserter { reputation.adjust_ledger(MILTHOLLOW, -5); } state.reputations.insert(player, reputation); state.packs.insert(player, Pack::new()); state.hymn_cases.insert(player, HymnCase::new()); state.bargains.insert(player, Ledger::new()); state .earn_copper(player, kit(identity.calling).stipend_cu) .expect("fresh wallet cannot overflow"); for template_id in kit(identity.calling).items { let template = item_template_for(&pack, template_id).expect("kit item conversion preflighted"); state .spawn_carried_item(player, &template) .expect("kit item spawn preflighted"); } state.lights.push(LightSource { x: start[0], y: start[1], z: start[2], radius: PLAYER_LIGHT_RADIUS, warm: true, }); install_live_connectors(&mut state, &catalog); apply_world_plan(&pack, &mut state, town)?; for floor in floors { apply_floor_plan(&pack, &mut state, floor)?; } apply_initial_lineage(&mut state, &identity.initial_lineage)?; state .learn_mapped_region(format!("town:{MILTHOLLOW}")) .map_err(|error| { SessionError::runtime("initial-town-knowledge", format!("{error:?}")) })?; let mode = identity.mode; let mut session = Self { state, pack, catalog, identity, events: Vec::new(), story_checkpoint: None, checkpoint_witness: RefCell::new(None), replay_evidence: Vec::new(), frozen_fault: None, }; session.sync_player_light()?; // The §5.1 spawn observation boundary: `initialize_town` runs before the // player exists, so the initial remembered map must be captured here - // after spawn, kit, world plan and the carried light are all in place. session .state .observe_player() .map_err(|error| SessionError::runtime("initial-observation", format!("{error:?}")))?; session.validate_session_state_only()?; if mode == Mode::Story { let checkpoint = session.build_checkpoint(0)?; let witness = session.checkpoint_witness_for(&checkpoint)?; session.story_checkpoint = Some(checkpoint); *session.checkpoint_witness.borrow_mut() = Some(witness); } Ok(session) }
/// Exposes immutable live state only while it is safe to treat the session as usable. pub fn state(&self) -> Result<&State, SessionError> { self.ensure_operational()?; Ok(&self.state) }
/// Returns a persistence-capable state image only while the session is operational. /// Diagnostic hashes remain available separately for crash reporting. #[cfg(test)] pub(crate) fn snapshot(&self) -> Result<hexwork_core::checkpoint::StateImage, SessionError> { self.ensure_operational()?; self.state .to_image() .map_err(|error| SessionError::runtime("snapshot-invalid", format!("{error:?}"))) }
/// Synthetic test-only unknown inventory and relocation; NOT acquisition evidence. #[cfg(test)] pub(crate) fn synthetic_tinker_setup_for_test(&mut self) -> Vec<u64> { let player = self.player_id().unwrap(); let cell = self.catalog.location(PlanLocation::Town).unwrap().interactions.iter() .find(|(_, entries)| entries.iter().any(|entry| matches!(&entry.kind, InteractionKind::Service { id } if id == "tinker"))) .map(|(&(x, y, z), _)| [x, y, z]).unwrap(); self.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(cell); let mut image = self.state.to_image().unwrap(); let prototype = image.packs[&player].items.values().next().unwrap().clone(); let mut ids = Vec::new(); for template in ["hymn_card_blank", "hymn_card_blank"] { let mut item = prototype.clone(); item.id = image.next_id; image.next_id += 1; item.template = template.to_owned(); item.identified = false; ids.push(item.id); image.packs.get_mut(&player).unwrap().add(item); image.idf.remove(template); } self.state = State::from_image(image).unwrap(); self.state.earn_copper(player, 100).unwrap(); ids }
/// Synthetic external-change fixture, never acquisition evidence. #[cfg(test)] pub(crate) fn synthetic_tinker_change_for_test(&mut self, change: u8) { let player = self.player_id().unwrap(); if change == 2 { self.state .spend_copper(player, self.tinker_wallet_cu().unwrap()) .unwrap(); } else { let mut image = self.state.to_image().unwrap(); if change == 0 { let actor = image.actors.iter_mut().find(|actor| actor.id == player).unwrap(); actor.position = actor.position.checked_translate([1000, 0, 0]).unwrap(); } else { image.run_location = RunLocation::Town("elsewhere".into()); } self.state = State::from_image(image).unwrap(); } }
/// Synthetic test-only template edit, not ordinary acquisition evidence. #[cfg(test)] pub(crate) fn synthetic_tinker_distinct_template_for_test(&mut self, item_id: u64) { let player = self.player_id().unwrap(); let template = self .player_pack() .unwrap() .items .values() .find(|item| item.template != "hymn_card_blank") .unwrap() .template .clone(); self.state.idf.remove(&template); let item = self .state .packs .get_mut(&player) .unwrap() .items .get_mut(&item_id) .unwrap(); item.template = template; item.identified = false; }
/// Synthetic test-only clones of an eligible carried weapon; not acquisition evidence. #[cfg(test)] pub(crate) fn synthetic_add_carried_weapons_for_test(&mut self, count: usize) -> Vec<u64> { let player = self.player_id().unwrap(); let prototype = self .player_pack() .unwrap() .items .values() .find(|item| { self.pack.item(&item.template).is_some_and(|content| { content.category == hexwork_content::schema::ItemCategory::Weapon }) }) .expect("fixture requires a carried weapon") .clone(); let mut image = self.state.to_image().unwrap(); let mut ids = Vec::new(); for _ in 0..count { let mut item = prototype.clone(); item.id = image.next_id; item.cursed = false; item.blessed = false; image.next_id += 1; ids.push(item.id); image.packs.get_mut(&player).unwrap().add(item); } self.state = State::from_image(image).unwrap(); ids }
/// Unit-fixture seam: restore a valid StateImage after replacing only the /// player's carried inventory with clones of an existing valid instance. #[cfg(test)] pub(crate) fn replace_player_inventory_for_test( &mut self, count: usize, ) -> Result<Vec<u64>, SessionError> { self.ensure_operational()?; let player = self.player_id()?; let prototype = self .player_pack()? .items .values() .next() .ok_or_else(|| SessionError::runtime("inventory-fixture-empty", "player pack"))? .clone(); let mut image = self.state.to_image().map_err(|error| { SessionError::runtime("inventory-fixture-image", format!("{error:?}")) })?; let carried = image .packs .get_mut(&player) .ok_or_else(|| SessionError::runtime("player-table-missing", "packs"))?; carried.items.clear(); carried.equipped_weapon = None; carried.equipped_armor = None; let mut ids = Vec::with_capacity(count); for index in 0..count { let id = 100_000 + u64::try_from(index).map_err(|error| { SessionError::runtime("inventory-fixture-id", error.to_string()) })? * 2; let mut instance = prototype.clone(); instance.id = id; carried.add(instance); ids.push(id); } image.next_id = ids.last().map_or(100_000, |id| id + 1); self.state = State::from_image(image).map_err(|error| { SessionError::runtime("inventory-fixture-restore", format!("{error:?}")) })?; self.validate_session_state_only()?; Ok(ids) }
/// Unit-fixture seam paired with `replace_player_inventory_for_test`: /// retain only named carried IDs, then restore the complete valid image. #[cfg(test)] pub(crate) fn retain_player_inventory_for_test( &mut self, retained: &[u64], ) -> Result<(), SessionError> { self.ensure_operational()?; let player = self.player_id()?; let retained = retained.iter().copied().collect::<BTreeSet<_>>(); let mut image = self.state.to_image().map_err(|error| { SessionError::runtime("inventory-fixture-image", format!("{error:?}")) })?; let carried = image .packs .get_mut(&player) .ok_or_else(|| SessionError::runtime("player-table-missing", "packs"))?; carried.items.retain(|id, _| retained.contains(id)); if carried .equipped_weapon .is_some_and(|id| !retained.contains(&id)) { carried.equipped_weapon = None; } if carried .equipped_armor .is_some_and(|id| !retained.contains(&id)) { carried.equipped_armor = None; } self.state = State::from_image(image).map_err(|error| { SessionError::runtime("inventory-fixture-restore", format!("{error:?}")) })?; self.validate_session_state_only() }
/// Synthetic adapter proof only: curse one selected carried item through a /// complete validated StateImage roundtrip. This is not an acquisition route. #[cfg(any(test, feature = "test-support"))] pub(crate) fn curse_carried_item_for_test(&mut self, item_id: u64) -> Result<(), SessionError> { self.ensure_operational()?; let player = self.player_id()?; let mut image = self.state.to_image().map_err(|error| { SessionError::runtime("inventory-fixture-image", format!("{error:?}")) })?; let item = image .packs .get_mut(&player) .and_then(|pack| pack.items.get_mut(&item_id)) .ok_or_else(|| { SessionError::runtime("inventory-fixture-item-missing", item_id.to_string()) })?; item.cursed = true; self.state = State::from_image(image).map_err(|error| { SessionError::runtime("inventory-fixture-restore", format!("{error:?}")) })?; self.validate_session_state_only() }
pub fn canonical_hash(&self) -> Result<u64, SessionError> { self.ensure_operational()?; self.state .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}"))) }
pub(crate) fn diagnostic_hash(&self) -> u64 { self.state.diagnostic_hash() }
pub fn tick(&self) -> u64 { self.state.tick }
pub(crate) fn story_debt(&self) -> Result<Option<u8>, SessionError> { self.ensure_operational()?; let player = self.player_id()?; Ok(self.state.chapel_debt(player).map(|debt| debt.burials_owed)) }
pub(crate) fn liveness(&self) -> Result<PlayerLiveness, SessionError> { let player = self.player_id()?; let actor = self .state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; Ok(if actor.alive && actor.hp > 0 { PlayerLiveness::Alive } else { PlayerLiveness::Dead }) }
/// Synthetic catalog placement for selector unit tests, never a traversal claim. #[cfg(test)] pub(crate) fn place_tactical_catalog_fixture(&mut self, depth: u8) { let floor = &playable_deep(self.identity.seed)[usize::from(depth - 1)]; let cell = floor .connectors .iter() .map(|c| c.access) .min_by_key(|c| c[2]) .unwrap(); self.state .restore_run_location(RunLocation::Deep(depth)) .unwrap(); let player = self.player_id().unwrap(); self.state .actors .iter_mut() .find(|a| a.id == player) .unwrap() .position = cell_pos(cell); self.sync_player_light().unwrap(); }
/// Reach a local gallery connector using recorded, legal movement actions. #[cfg(test)] pub(crate) fn route_to_local_tactical_connector(&mut self) -> u64 { let location = plan_location(self.state.run_location()).unwrap(); let connector = self .connectors_for(location) .iter() .find(|c| { destination_location(c.destination) == location && c.direction == hexwork_worldgen::TransitionDirection::Up }) .unwrap() .clone(); tests::route(self, connector.access); connector.transition_id }
pub fn tactical_context(&self) -> Result<TacticalContext, SessionError> { self.ensure_operational()?; let player = self.player_id()?; let invalid = || { SessionError::runtime( "tactical-context-invalid", "invalid Player cell or active location", ) }; let actor = self .state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(invalid)?; let player_cell = actor.position.cell(); let location = self.state.run_location(); let catalog = plan_location(location) .and_then(|location| self.catalog.location(location)) .ok_or_else(invalid)?; if !catalog.bounds.contains(player_cell) { return Err(invalid()); } Ok(TacticalContext { location: location.clone(), bounds: catalog.bounds, player_cell, }) }
pub fn presentation_landmarks(&self) -> Result<Vec<PresentationLandmark>, SessionError> { self.ensure_operational()?; let location = plan_location(self.state.run_location()) .and_then(|location| self.catalog.location(location)) .ok_or_else(|| { SessionError::runtime("location-catalog-missing", "active presentation location") })?; let mut landmarks = Vec::new(); for (index, interaction) in location.interactions.values().flatten().enumerate() { landmarks.push(PresentationLandmark { stable_id: (1_u64 << 62) | index as u64, access: interaction.access, kind: PresentationLandmarkKind::Interaction(interaction.kind.clone()), }); } for connector in &location.connectors { landmarks.push(PresentationLandmark { stable_id: (2_u64 << 62) | connector.transition_id, access: connector.access, kind: PresentationLandmarkKind::Connector(connector.direction), }); } Ok(landmarks) }
pub(crate) fn player_presentation(&self) -> Result<PlayerPresentation, SessionError> { self.ensure_operational()?; let player = self.player_id()?; let actor = self .state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; let carried = self.state.packs.get(&player); let carried_lamp = carried.map_or(CarriedLampPresentation::Unavailable, |pack| { pack.items .values() .filter(|instance| instance.has_tag("lamp")) .min_by_key(|instance| instance.id) .map_or(CarriedLampPresentation::None, |instance| { CarriedLampPresentation::Fuel { current: instance.fuel, maximum: item::LAMP_FUEL, } }) }); let burden = carried.map(|pack| { let weights = self .pack .items() .iter() .map(|item| (item.id.clone(), item.weight_kg)) .collect::<BTreeMap<_, _>>(); let bulks = self .pack .items() .iter() .map(|item| (item.id.clone(), item.bulk)) .collect::<BTreeMap<_, _>>(); let overburdened = pack.overburdened(&weights, &bulks); let hides_weight = pack.items.values().any(|instance| { instance.lies_about_weight() && !self.state.is_effectively_identified(instance) }); if hides_weight { BurdenPresentation::HiddenWeight { overburdened } } else { BurdenPresentation::Known { weight_kg: pack.total_weight(&weights), max_weight_kg: pack.max_weight_kg, bulk: pack.total_bulk(&bulks), max_bulk: pack.max_bulk, overburdened, } } }); Ok(PlayerPresentation { position: actor.position.presentation_metres(), hp: actor.hp, location: self.state.run_location().clone(), tick: self.state.tick, carried_lamp, burden, }) }
pub(crate) fn action_elapsed_ticks(&self, action: &CommittedAction) -> u64 { // Single source of truth: the core authored-duration table (docs/fluid // §4.2). Move/Wait 1, InspectBoard 0, world actions 10, Identify 20, // Inn the authored unattended day. hexwork_core::action_timing::action_timing(action).ticks() }
pub(crate) fn inventory_view(&self, limit: usize) -> Result<InventoryView, SessionError> { self.ensure_operational()?; let carried = self.player_pack()?; let total = carried.items.len(); let mut entries = Vec::new(); entries .try_reserve(total.min(limit)) .map_err(|error| SessionError::runtime("inventory-view-capacity", error.to_string()))?; for instance in carried.items.values().take(limit) { entries.push(self.project_inventory_entry(instance)?); } Ok(InventoryView { entries, total, truncated: total > limit, }) }
fn player_pack(&self) -> Result<&Pack, SessionError> { let player = self.player_id()?; self.state .packs .get(&player) .ok_or_else(|| SessionError::runtime("player-table-missing", "packs")) }
fn project_inventory_entry( &self, instance: &Instance, ) -> Result<InventoryEntryView, SessionError> { let effective_identified = self.state.is_effectively_identified(instance); let (tooltip, tooltip_clipped) = if instance.template == CORPSE_TEMPLATE { bounded_corpse_tooltip(instance.material)? } else { let content = self.pack.item(&instance.template).ok_or_else(|| { SessionError::content("item-template-missing", &instance.template) })?; bounded_item_tooltip( &content.name, effective_identified, &content.uses, &content.material, ) }; Ok(InventoryEntryView { instance_id: instance.id, template_id: instance.template.clone(), effective_identified, wielded: self .player_pack()? .wielded() .is_some_and(|held| held.id == instance.id), wieldable: self.player_pack()?.contains(instance.id) && self.pack.item(&instance.template).is_some_and(|item| { item.category == hexwork_content::schema::ItemCategory::Weapon }), tooltip, tooltip_clipped, }) }}
impl GameSession { pub(crate) fn inventory_page( &self, query: InventoryPageQuery, ) -> Result<InventoryPage, SessionError> { self.inventory_page_with_probes( query, #[cfg(test)] None, ) }
fn inventory_page_with_probes( &self, query: InventoryPageQuery, #[cfg(test)] probes: Option<&std::cell::Cell<InventoryQueryProbes>>, ) -> Result<InventoryPage, SessionError> { use std::ops::Bound::{Excluded, Included, Unbounded};
self.ensure_operational()?; let carried = self.player_pack()?; let total = carried.items.len(); let mut candidates = Vec::with_capacity(INVENTORY_PAGE_SIZE + 1); let backwards = matches!(query, InventoryPageQuery::Before(_)); match query { InventoryPageQuery::First => { for (_, instance) in carried.items.range(..) { #[cfg(test)] record_inventory_page_probe(probes); candidates.push(instance); if candidates.len() == INVENTORY_PAGE_SIZE + 1 { break; } } } InventoryPageQuery::AtOrAfter(anchor) => { for (_, instance) in carried.items.range((Included(anchor), Unbounded)) { #[cfg(test)] record_inventory_page_probe(probes); candidates.push(instance); if candidates.len() == INVENTORY_PAGE_SIZE + 1 { break; } } if candidates.is_empty() { for (_, instance) in carried.items.range(..).rev() { #[cfg(test)] record_inventory_page_probe(probes); candidates.push(instance); if candidates.len() == INVENTORY_PAGE_SIZE + 1 { break; } } candidates.reverse(); if candidates.len() > INVENTORY_PAGE_SIZE { candidates.remove(0); } } } InventoryPageQuery::After(boundary) => { for (_, instance) in carried.items.range((Excluded(boundary), Unbounded)) { #[cfg(test)] record_inventory_page_probe(probes); candidates.push(instance); if candidates.len() == INVENTORY_PAGE_SIZE + 1 { break; } } } InventoryPageQuery::Before(boundary) => { for (_, instance) in carried.items.range((Unbounded, Excluded(boundary))).rev() { #[cfg(test)] record_inventory_page_probe(probes); candidates.push(instance); if candidates.len() == INVENTORY_PAGE_SIZE + 1 { break; } } candidates.reverse(); if candidates.len() > INVENTORY_PAGE_SIZE { candidates.remove(0); } } }
if !backwards && candidates.len() > INVENTORY_PAGE_SIZE { candidates.pop(); } let first = candidates.first().map(|instance| instance.id); let last = candidates.last().map(|instance| instance.id); let has_before = if let Some(first) = first { #[cfg(test)] record_inventory_boundary_probe(probes); carried.items.range(..first).next_back().is_some() } else { false }; let has_after = if let Some(last) = last { #[cfg(test)] record_inventory_boundary_probe(probes); carried .items .range((Excluded(last), Unbounded)) .next() .is_some() } else { false }; let mut entries = Vec::with_capacity(candidates.len()); for instance in candidates { #[cfg(test)] record_inventory_projection_probe(probes); entries.push(self.project_inventory_entry(instance)?); } Ok(InventoryPage { entries, total, has_before, has_after, }) }
pub(crate) fn inventory_item( &self, instance_id: u64, ) -> Result<Option<InventoryEntryView>, SessionError> { self.inventory_item_with_probes( instance_id, #[cfg(test)] None, ) }
fn inventory_item_with_probes( &self, instance_id: u64, #[cfg(test)] probes: Option<&std::cell::Cell<InventoryQueryProbes>>, ) -> Result<Option<InventoryEntryView>, SessionError> { self.ensure_operational()?; let carried = self.player_pack()?; #[cfg(test)] record_inventory_direct_lookup_probe(probes); carried .items .get(&instance_id) .map(|instance| { #[cfg(test)] record_inventory_projection_probe(probes); self.project_inventory_entry(instance) }) .transpose() }}
impl GameSession { pub(crate) fn ground_target( &self, query: GroundTargetQuery, ) -> Result<Option<GroundTargetView>, SessionError> { self.ground_target_with_probes( query, #[cfg(test)] None, ) }
fn ground_target_with_probes( &self, query: GroundTargetQuery, #[cfg(test)] probes: Option<&std::cell::Cell<GroundQueryProbes>>, ) -> Result<Option<GroundTargetView>, SessionError> { self.ensure_operational()?; if self.state.ground_items.len() > hexwork_save::MAX_ROWS { return Err(SessionError::runtime( "ground-target-limit", format!( "{} > {}", self.state.ground_items.len(), hexwork_save::MAX_ROWS ), )); } let player_cell = self.transfer_player_cell()?; let location = plan_location(self.state.run_location()).ok_or_else(|| { SessionError::runtime( "ground-target-runtime-invalid", format!("unknown run location {:?}", self.state.run_location()), ) })?; let catalog = self.catalog.location(location).ok_or_else(|| { SessionError::runtime( "ground-target-runtime-invalid", format!("catalog location missing for {location:?}"), ) })?; if !catalog.bounds.contains(player_cell) { return Ok(None); } let mut chosen = None; let mut minimum = None; let mut maximum = None; for (&item_id, &cell) in &self.state.ground_items { #[cfg(test)] record_ground_visit_probe(probes); if cell != tuple(player_cell) { continue; } minimum.get_or_insert(item_id); maximum = Some(item_id); match query { GroundTargetQuery::First if chosen.is_none() => chosen = Some(item_id), GroundTargetQuery::After(boundary) if item_id > boundary && chosen.is_none() => { chosen = Some(item_id); } GroundTargetQuery::Before(boundary) if item_id < boundary => { chosen = Some(item_id); } _ => {} } } let Some(item_id) = chosen else { return Ok(None); }; let (entry, cell) = self .ground_item_with_probes( item_id, #[cfg(test)] probes, )? .ok_or_else(|| { SessionError::runtime( "ground-item-pair-invalid", format!("selected ground item {item_id} disappeared"), ) })?; Ok(Some(GroundTargetView { entry, cell, has_before: minimum.is_some_and(|id| id < item_id), has_after: maximum.is_some_and(|id| id > item_id), })) }
pub(crate) fn ground_item( &self, item_id: u64, ) -> Result<Option<(InventoryEntryView, Cell)>, SessionError> { self.ground_item_with_probes( item_id, #[cfg(test)] None, ) }
fn ground_item_with_probes( &self, item_id: u64, #[cfg(test)] probes: Option<&std::cell::Cell<GroundQueryProbes>>, ) -> Result<Option<(InventoryEntryView, Cell)>, SessionError> { self.ensure_operational()?; #[cfg(test)] record_ground_direct_lookup_probe(probes); let item = self.state.items.get(&item_id); let ground = self.state.ground_items.get(&item_id); match (item, ground) { (None, None) => Ok(None), (Some(_), None) | (None, Some(_)) => Err(SessionError::runtime( "ground-item-pair-invalid", item_id.to_string(), )), (Some(instance), Some(&cell)) => { let player_cell = self.transfer_player_cell()?; if cell != tuple(player_cell) { return Ok(None); } let location = plan_location(self.state.run_location()).ok_or_else(|| { SessionError::runtime( "ground-target-runtime-invalid", format!("unknown run location {:?}", self.state.run_location()), ) })?; let catalog = self.catalog.location(location).ok_or_else(|| { SessionError::runtime( "ground-target-runtime-invalid", format!("catalog location missing for {location:?}"), ) })?; if !catalog.bounds.contains(player_cell) { return Ok(None); } #[cfg(test)] record_ground_projection_probe(probes); Ok(Some((self.project_inventory_entry(instance)?, player_cell))) } } }}
impl GameSession { #[cfg(test)] pub(crate) fn content_hash(&self) -> &str { self.pack.hash() } pub(crate) fn mode(&self) -> Mode { self.identity.mode } pub(crate) fn identity(&self) -> &RunIdentity { &self.identity } #[cfg(test)] pub(crate) fn journal(&self) -> &[SessionEvent] { &self.events } pub(crate) fn events(&self) -> &[SessionEvent] { &self.events } pub fn replay_evidence(&self) -> &[ReplayEvidence] { &self.replay_evidence } pub(crate) fn journal_has_capacity(&self) -> bool { self.events.len() < MAX_SESSION_EVENTS }
pub(crate) fn encode_current_run(&self) -> Result<Vec<u8>, SessionError> { self.ensure_operational()?; self.validate_session() .map_err(SessionError::into_runtime)?; self.prove_checkpoint_if_needed(ReplayLimits::default())?; encode_run( &self.identity, &self.state, self.story_checkpoint.as_ref(), &self.events, ) .map_err(|error| SessionError::from_encode_error("run-encode", error)) }
#[cfg(test)] pub(crate) fn restore_from_bytes( content_root: impl AsRef<Path>, bytes: &[u8], expected_mode: Mode, ) -> Result<Self, SessionError> { let pack = RuntimePack::load(content_root) .map_err(|error| SessionError::content("content-load", error.to_string()))?; Self::restore_with_pack(pack, bytes, expected_mode) }
#[cfg(test)] fn restore_with_pack( pack: RuntimePack, bytes: &[u8], expected_mode: Mode, ) -> Result<Self, SessionError> { let decoded = decode_run(bytes, pack.hash()) .map_err(|error| SessionError::from_decode_error("run-decode", error))?; Self::from_decoded(pack, decoded, Some(expected_mode)) }
/// Restore an ArtifactStore-validated run without replaying initialization or /// consulting the lineage vault again. pub(crate) fn from_decoded( pack: RuntimePack, decoded: DecodedRun, expected_mode: Option<Mode>, ) -> Result<Self, SessionError> { if let Some(expected_mode) = expected_mode { if decoded.identity.mode != expected_mode { return Err(SessionError::content( "mode-mismatch", format!( "saved {:?}, expected {expected_mode:?}", decoded.identity.mode ), )); } } let seed = decoded.identity.seed; let setup = PlayerSetup::new( decoded.identity.display_name.clone(), character::new(decoded.identity.ancestry, decoded.identity.calling), ); let town = milthollow(seed); let floors = playable_deep(seed); preflight(&pack, &town, &floors, &setup)?; let catalog = RuntimeCatalog::from_plans(&town, &floors)?; validate_restored_state(&pack, &catalog, &decoded.identity, &decoded.current)?; if let Some(checkpoint) = decoded.story_checkpoint.as_ref() { validate_checkpoint_state(&pack, &catalog, &decoded.identity, checkpoint)?; } let session = Self { state: decoded.current, pack, catalog, identity: decoded.identity, events: decoded.events, story_checkpoint: decoded.story_checkpoint, checkpoint_witness: RefCell::new(None), replay_evidence: Vec::new(), frozen_fault: None, }; session.validate_session()?; Ok(session) }
#[cfg(test)] pub(crate) fn encode_replay(&self) -> Result<Vec<u8>, SessionError> { self.ensure_operational()?; self.encode_replay_with_limits(ReplayLimits::default()) }
#[cfg(test)] pub(crate) fn encode_replay_with_limits( &self, limits: ReplayLimits, ) -> Result<Vec<u8>, SessionError> { self.ensure_operational()?; self.validate_session() .map_err(SessionError::into_runtime)?; let expected_final_hash = self .state .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}")))?; let costs = timeline_costs(&self.events, self.mode(), self.state.tick) .map_err(|error| SessionError::from_encode_error("replay-timeline-invalid", error))?; let proof_required = self.mode() == Mode::Story && !self.cached_checkpoint_witness_matches( self.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("story-checkpoint-missing", "Story requires checkpoint") })?, )?; let combined = if proof_required { double_timeline_costs(costs)? } else { costs }; let mut budget = ReplayBudget::new(combined, limits)?; let mut evidence = Vec::new(); if proof_required { self.prove_checkpoint_with_budget(&mut budget)?; } let replayed = Self::replay_events( self.pack.clone(), &self.identity, &self.events, self.state.tick, &mut budget, &mut evidence, )?; let actual_hash = replayed .state .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}")))?; if actual_hash != expected_final_hash { return Err(SessionError::content( "replay-terminal-divergence", format!("expected {expected_final_hash}, actual {actual_hash}"), )); } if self.mode() == Mode::Story { let checkpoint = self.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("story-checkpoint-missing", "Story requires checkpoint") })?; *self.checkpoint_witness.borrow_mut() = Some(self.checkpoint_witness_for(checkpoint)?); } encode_replay(&ProductionReplay { identity: self.identity.clone(), events: self.events.clone(), expected_final_tick: self.state.tick, expected_final_hash, }) .map_err(|error| SessionError::from_encode_error("replay-encode", error)) }
#[cfg(test)] pub(crate) fn replay_from_bytes( content_root: impl AsRef<Path>, bytes: &[u8], expected_mode: Mode, ) -> Result<Self, SessionError> { Self::replay_from_bytes_with_limits( content_root, bytes, expected_mode, ReplayLimits::default(), ) }
#[cfg(test)] pub(crate) fn replay_from_bytes_with_limits( content_root: impl AsRef<Path>, bytes: &[u8], expected_mode: Mode, limits: ReplayLimits, ) -> Result<Self, SessionError> { let pack = RuntimePack::load(content_root) .map_err(|error| SessionError::content("content-load", error.to_string()))?; let replay = decode_replay(bytes, pack.hash()) .map_err(|error| SessionError::from_decode_error("replay-decode", error))?; Self::replay_recorded(pack, &replay, Some(expected_mode), limits) }
/// Execute a decoded production replay against an already loaded immutable /// content pack. This is shared by standalone replay and ArtifactStore recovery. pub fn replay_recorded( pack: RuntimePack, replay: &ProductionReplay, expected_mode: Option<Mode>, limits: ReplayLimits, ) -> Result<Self, SessionError> { if let Some(expected_mode) = expected_mode { if replay.identity.mode != expected_mode { return Err(SessionError::content( "mode-mismatch", format!( "replay {:?}, expected {expected_mode:?}", replay.identity.mode ), )); } } let costs = timeline_costs( &replay.events, replay.identity.mode, replay.expected_final_tick, ) .map_err(|error| SessionError::content("replay-timeline-invalid", error.to_string()))?; let mut budget = ReplayBudget::new(costs, limits)?; let mut evidence = Vec::new(); let mut session = Self::replay_events( pack, &replay.identity, &replay.events, replay.expected_final_tick, &mut budget, &mut evidence, )?; let actual_hash = session .state .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}")))?; if actual_hash != replay.expected_final_hash { return Err(SessionError::content( "replay-terminal-divergence", format!( "terminal-only mismatch: expected tick {} hash {}, actual tick {} hash {}", replay.expected_final_tick, replay.expected_final_hash, session.state.tick, actual_hash ), )); } session.replay_evidence = evidence; Ok(session) }
fn replay_events( pack: RuntimePack, identity: &RunIdentity, events: &[SessionEvent], final_tick: u64, budget: &mut ReplayBudget, evidence: &mut Vec<ReplayEvidence>, ) -> Result<Self, SessionError> { let mut session = Self::from_identity(pack, identity.clone())?; let mut cause_cursor = session.state.causes.links().len(); for (index, expected) in events.iter().enumerate() { advance_replay_to( &mut session, expected.pre_tick, index, budget, evidence, &mut cause_cursor, )?; if session.state.tick != expected.pre_tick { return Err(SessionError::content( "replay-pre-tick-mismatch", format!( "event {index}: expected {}, actual {}", expected.pre_tick, session.state.tick ), )); } match &expected.kind { SessionEventKind::Action(action) => { match action { CommittedAction::Wait => budget.debit_full(1)?, CommittedAction::UseService { service: Service::Inn, } => budget.debit_unattended(services::sleep_ticks())?, _ => {} } session.dispatch(action.clone()).map_err(|error| { let detail = format!("event {index}: {error}"); error.with_context("replay-action-failed", detail) })?; capture_evidence( &session.state, &mut cause_cursor, index, EvidenceSegment::Action, budget, evidence, )?; } SessionEventKind::StoryResurrect { .. } => { session.resolve_story_death_inner(false).map_err(|error| { let detail = format!("event {index}: {error}"); error.with_context("replay-resurrection-failed", detail) })?; cause_cursor = session.state.causes.links().len(); } } let actual = session .events .last() .ok_or_else(|| SessionError::content("replay-event-missing", index.to_string()))?; if actual.pre_tick != expected.pre_tick { return Err(SessionError::content( "replay-pre-tick-mismatch", format!("event {index}"), )); } if actual.post_tick != expected.post_tick { return Err(SessionError::content( "replay-post-tick-mismatch", format!( "event {index}: expected {}, actual {}", expected.post_tick, actual.post_tick ), )); } if actual.kind != expected.kind { let code = match (&actual.kind, &expected.kind) { ( SessionEventKind::StoryResurrect { dead_state_hash: a, .. }, SessionEventKind::StoryResurrect { dead_state_hash: e, .. }, ) if a != e => "replay-dead-hash-mismatch", ( SessionEventKind::StoryResurrect { checkpoint_hash: a, .. }, SessionEventKind::StoryResurrect { checkpoint_hash: e, .. }, ) if a != e => "replay-checkpoint-hash-mismatch", ( SessionEventKind::StoryResurrect { restored_state_hash: a, .. }, SessionEventKind::StoryResurrect { restored_state_hash: e, .. }, ) if a != e => "replay-restored-hash-mismatch", _ => "replay-event-mismatch", }; return Err(SessionError::content(code, format!("event {index}"))); } } advance_replay_to( &mut session, final_tick, events.len(), budget, evidence, &mut cause_cursor, )?; Ok(session) }
pub(crate) fn resolve_story_death(&mut self) -> Result<(), SessionError> { self.ensure_operational()?; self.resolve_story_death_inner(true) }
fn resolve_story_death_inner(&mut self, prove: bool) -> Result<(), SessionError> { self.ensure_operational()?; if self.mode() != Mode::Story { return Err(SessionError::content( "story-mode-required", format!("current mode is {:?}", self.mode()), )); } if !self.journal_has_capacity() { return Err(SessionError::runtime( "journal-capacity", format!("events reached {MAX_SESSION_EVENTS}"), )); } self.events .try_reserve(1) .map_err(|error| SessionError::runtime("journal-capacity", error.to_string()))?; let player = self.player_id()?; let actor = self .state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; if actor.alive || actor.hp > 0 { return Err(SessionError::content("player-not-dead", player.to_string())); } if prove { self.prove_checkpoint_if_needed(ReplayLimits::default())?; } let checkpoint = self.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("story-checkpoint-missing", "no accepted town checkpoint") })?; validate_checkpoint_state(&self.pack, &self.catalog, &self.identity, checkpoint)?; // Death evidence (docs/fluid §7): hash the dead branch exactly as it // died — including any in-flight pending descriptor or Inn work — before // the fold discards it, so the journaled `dead_state_hash` proves what // the restored branch must not resume. let dead_tick = self.state.tick; let dead_epoch = self.state.branch_epoch; let dead_hash = self .state .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}")))?; // Abort the dead branch's in-flight pending action and any Inn work here. // It is never applied or resumed, on this branch or the restored one. let _aborted = self.state.pending.take(); let new_epoch = dead_epoch.checked_add(1).ok_or_else(|| { SessionError::runtime("branch-epoch-exhausted", dead_epoch.to_string()) })?; let mut candidate = checkpoint.state.clone().into_state().map_err(|error| { SessionError::content("story-checkpoint-invalid", error.to_string()) })?; // Fold, not merge (docs/fluid §7): restore the checkpoint's contents // INCLUDING its observation memory, then re-stamp the restored // provenance to the new branch epoch. Post-checkpoint discoveries, // descriptors, and queued inputs are lost, and a reused // (branch_epoch, observed_tick) pair cannot alias an old branch's // discovery. candidate.branch_epoch = new_epoch; candidate.restamp_observation_epoch(new_epoch); let chapel = self.chapel_interaction()?; let candidate_player = canonical_player_id(&candidate, &self.identity)?; check_passable_state(&candidate, chapel.access, candidate_player, "chapel-access")?; let actor = candidate .actors .iter_mut() .find(|actor| actor.id == candidate_player) .ok_or_else(|| SessionError::content("player-missing", candidate_player.to_string()))?; actor.position = checked_cell_pos(chapel.access)?; actor.hp = STORY_RESURRECTION_HP; actor.alive = true; candidate.fall_odo.remove(&candidate_player); sync_player_light_state(&mut candidate, chapel.access)?; candidate .assign_chapel_debt(candidate_player) .map_err(|error| SessionError::content("chapel-debt", format!("{error:?}")))?; validate_restored_state(&self.pack, &self.catalog, &self.identity, &candidate)?; let restored_hash = candidate .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}")))?; let event = SessionEvent { pre_tick: dead_tick, post_tick: candidate.tick, kind: SessionEventKind::StoryResurrect { checkpoint_after_event_count: checkpoint.after_event_count, checkpoint_hash: checkpoint.state.hash, dead_state_hash: dead_hash, restored_state_hash: restored_hash, }, }; self.state = candidate; self.events.push(event); Ok(()) }
fn chapel_interaction(&self) -> Result<RegisteredInteraction, SessionError> { self.catalog .location(PlanLocation::Town) .into_iter() .flat_map(|location| location.interactions.values()) .flat_map(|entries| entries.iter()) .find(|entry| { entry.label == hexwork_core::modes::LAMPLIGHTER_CHAPEL && matches!(&entry.kind, InteractionKind::Service { id } if id == "chapel") }) .cloned() .ok_or_else(|| { SessionError::content( "chapel-anchor-missing", hexwork_core::modes::LAMPLIGHTER_CHAPEL, ) }) }
fn build_checkpoint(&self, after_event_count: u32) -> Result<StoryCheckpoint, SessionError> { let state = HashedState::from_state(&self.state) .map_err(|error| SessionError::from_encode_error("story-checkpoint-build", error))?; Ok(StoryCheckpoint { state, after_event_count, }) }
fn checkpoint_witness_for( &self, checkpoint: &StoryCheckpoint, ) -> Result<CheckpointWitness, SessionError> { let count = usize::try_from(checkpoint.after_event_count) .map_err(|_| SessionError::content("checkpoint-prefix-invalid", "u32 conversion"))?; let prefix_digest = event_prefix_digest(&self.events, count).map_err(|error| { SessionError::content("checkpoint-prefix-invalid", error.to_string()) })?; let verified_history_digest = event_prefix_digest(&self.events, self.events.len()) .map_err(|error| { SessionError::content("checkpoint-history-invalid", error.to_string()) })?; Ok(CheckpointWitness { count: checkpoint.after_event_count, tick: checkpoint.state.tick, hash: checkpoint.state.hash, prefix_digest, verified_event_count: self.events.len(), verified_history_digest, }) }
fn cached_checkpoint_witness_matches( &self, checkpoint: &StoryCheckpoint, ) -> Result<bool, SessionError> { let Some(cached) = self.checkpoint_witness.borrow().clone() else { return Ok(false); }; let count = usize::try_from(checkpoint.after_event_count) .map_err(|_| SessionError::content("checkpoint-prefix-invalid", "u32 conversion"))?; if cached.count != checkpoint.after_event_count || cached.tick != checkpoint.state.tick || cached.hash != checkpoint.state.hash || cached.verified_event_count > self.events.len() || event_prefix_digest(&self.events, count).map_err(|error| { SessionError::content("checkpoint-prefix-invalid", error.to_string()) })? != cached.prefix_digest || event_prefix_digest(&self.events, cached.verified_event_count).map_err(|error| { SessionError::content("checkpoint-history-invalid", error.to_string()) })? != cached.verified_history_digest { return Ok(false); } for event in &self.events[cached.verified_event_count..] { match &event.kind { SessionEventKind::Action(CommittedAction::UseTransition { .. }) => { return Ok(false) } SessionEventKind::StoryResurrect { checkpoint_after_event_count, checkpoint_hash, .. } if *checkpoint_after_event_count != checkpoint.after_event_count || *checkpoint_hash != checkpoint.state.hash => { return Ok(false) } SessionEventKind::Action(_) | SessionEventKind::StoryResurrect { .. } => {} } } Ok(true) }
fn prove_checkpoint_if_needed(&self, limits: ReplayLimits) -> Result<(), SessionError> { if self.mode() != Mode::Story { return Ok(()); } let checkpoint = self.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("story-checkpoint-missing", "Story requires checkpoint") })?; validate_checkpoint_state(&self.pack, &self.catalog, &self.identity, checkpoint)?; if self.cached_checkpoint_witness_matches(checkpoint)? { return Ok(()); } let costs = timeline_costs(&self.events, Mode::Story, self.state.tick).map_err(|error| { SessionError::content("checkpoint-proof-timeline", error.to_string()) })?; let mut budget = ReplayBudget::new(costs, limits)?; self.prove_checkpoint_with_budget(&mut budget) }
fn prove_checkpoint_with_budget(&self, budget: &mut ReplayBudget) -> Result<(), SessionError> { let checkpoint = self.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("story-checkpoint-missing", "Story requires checkpoint") })?; validate_checkpoint_state(&self.pack, &self.catalog, &self.identity, checkpoint)?; if self.cached_checkpoint_witness_matches(checkpoint)? { return Ok(()); } let mut evidence = Vec::new(); let replayed = Self::replay_events( self.pack.clone(), &self.identity, &self.events, self.state.tick, budget, &mut evidence, )?; let derived = replayed.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("checkpoint-proof-missing", "replay derived no checkpoint") })?; if derived.after_event_count != checkpoint.after_event_count { return Err(SessionError::content( "checkpoint-proof-prefix-mismatch", format!( "latest replay checkpoint {} != saved {}", derived.after_event_count, checkpoint.after_event_count ), )); } if derived.state.tick != checkpoint.state.tick { return Err(SessionError::content( "checkpoint-proof-tick-mismatch", format!( "latest replay checkpoint {} != saved {}", derived.state.tick, checkpoint.state.tick ), )); } if derived.state.hash != checkpoint.state.hash { return Err(SessionError::content( "checkpoint-proof-hash-mismatch", format!( "latest replay checkpoint {} != saved {}", derived.state.hash, checkpoint.state.hash ), )); } *self.checkpoint_witness.borrow_mut() = Some(self.checkpoint_witness_for(checkpoint)?); Ok(()) }
fn validate_session(&self) -> Result<(), SessionError> { if self.identity.content_hash != self.pack.hash() { return Err(SessionError::content( "content-hash-mismatch", &self.identity.content_hash, )); } validate_restored_state(&self.pack, &self.catalog, &self.identity, &self.state)?; match self.mode() { Mode::Story => validate_checkpoint_state( &self.pack, &self.catalog, &self.identity, self.story_checkpoint.as_ref().ok_or_else(|| { SessionError::content("story-checkpoint-missing", "Story requires checkpoint") })?, )?, Mode::Classic | Mode::Ironcartographer if self.story_checkpoint.is_some() => { return Err(SessionError::content( "story-checkpoint-forbidden", format!("{:?}", self.mode()), )) } _ => {} } timeline_costs(&self.events, self.mode(), self.state.tick) .map_err(|error| SessionError::content("event-timeline-invalid", error.to_string()))?; self.state .try_hash_state() .map_err(|error| SessionError::content("hash-invalid", format!("{error:?}")))?; Ok(()) }
fn board_len(&self) -> usize { self.pack .board_for_town(MILTHOLLOW, BOARD_TIER_MAX) .map_or(0, |entries| entries.len()) }
fn board_view(&self, index: usize) -> Option<BoardView> { let entries = self.pack.board_for_town(MILTHOLLOW, BOARD_TIER_MAX).ok()?; let contract = entries.get(index)?; Some(BoardView { index, total: entries.len(), id: contract.id.clone(), title: contract.title.clone(), page: contract_card( &contract.title, contract.pay_cu, contract.tier, &contract.poster, &contract.known_hazards, &contract.unknown_hint, &contract.prep_notes, contract.floor, ), }) }
pub(crate) fn contextual_intent(&self) -> Option<ContextualIntent> { let player = self.player_id().ok()?; let position = self.state.actors.iter().find(|a| a.id == player)?.position; let location = plan_location(self.state.run_location())?; let accessible: Vec<_> = self.catalog.location(location)?.interactions.values().flatten() .filter(|entry| crate::geometry::access(&self.state.grid, position, entry.access)).collect(); let entries = Some(&accessible); if location == PlanLocation::Town { if entries.is_some_and(|entries| { entries .iter() .any(|entry| matches!(entry.kind, InteractionKind::NoticeBoard)) }) && self.state.contract(RATS_CONTRACT).is_some_and(|contract| { contract.status == hexwork_core::runtime::ContractStatus::Completed }) { return Some(ContextualIntent::Commit(CommittedAction::TurnIn { contract_id: RATS_CONTRACT.to_owned(), })); } if entries.is_some_and(|entries| { entries .iter() .any(|entry| matches!(entry.kind, InteractionKind::Inn)) }) { return Some(ContextualIntent::Commit(CommittedAction::UseService { service: Service::Inn, })); } if entries.is_some_and(|entries| entries.iter().any(|entry| matches!(&entry.kind, InteractionKind::Service { id } if id == "tinker"))) { return Some(ContextualIntent::OpenTinkerIdentify); } } if let Some(connector) = self .connectors_for(location) .iter() .filter(|connector| crate::geometry::access(&self.state.grid, position, connector.access)) .min_by_key(|connector| connector.transition_id) { return Some(ContextualIntent::Commit(CommittedAction::UseTransition { transition_id: connector.transition_id, })); } self.contextual_melee_target(player).map(|target_actor| { ContextualIntent::Commit(CommittedAction::ResolveAttack { target_actor }) }) }
fn contextual_melee_target(&self, player: u64) -> Option<u64> { self.state .actors .iter() .filter_map(|actor| { melee_geometry(&self.state, &self.catalog, player, actor.id) .ok() .map(|geometry| (geometry, actor.id)) }) .min() .map(|(_, id)| id) }
pub(crate) fn contextual_melee_hint(&self) -> Option<String> { let player = self.player_id().ok()?; let target = self.contextual_melee_target(player)?; let identity = self.state.identity(target)?; let actor = self.state.actors.iter().find(|actor| actor.id == target)?; let geometry = melee_geometry(&self.state, &self.catalog, player, target).ok()?; let return_strike = if geometry == MeleeGeometry::Extended { "; beyond its return reach".to_owned() } else if actor.hp > PLAYER_STRIKE_DAMAGE { format!("; if it survives, it returns {PLAYER_STRIKE_DAMAGE} immediately") } else { String::new() }; Some(format!( "Attack {}: deal {PLAYER_STRIKE_DAMAGE}{return_strike}", identity.display_name )) }
pub fn player_id(&self) -> Result<u64, SessionError> { let mut players = self.state.actors.iter().filter_map(|actor| { self.state .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Player) .then_some(actor.id) }); let player = players .next() .ok_or_else(|| SessionError::runtime("player-missing", "no canonical Player actor"))?; if players.next().is_some() { return Err(SessionError::runtime( "player-ambiguous", "multiple canonical Player actors", )); } Ok(player) }
#[cfg(any(test, feature = "test-support"))] pub fn player_cell(&self) -> Result<Cell, SessionError> { let player = self.player_id()?; actor_cell(&self.state, player) .ok_or_else(|| SessionError::content("player-missing", player.to_string())) }
pub(crate) fn advance_one_tick(&mut self) -> Result<TickOutcome, SessionError> { self.ensure_operational()?; if self.liveness()? == PlayerLiveness::Dead { return Ok(TickOutcome::LifecycleRequired { tick: self.state.tick, liveness: PlayerLiveness::Dead, }); } self.state .validate_runtime() .map_err(|error| SessionError::runtime("runtime-invalid", format!("{error:?}")))?; let pre_tick = self.state.tick; pre_tick .checked_add(1) .ok_or_else(|| SessionError::runtime("tick-overflow", pre_tick.to_string()))?; self.state.step(); let post_tick = self.state.tick; if post_tick != pre_tick + 1 { let fault = SessionError::runtime( "tick-phase-fault", format!("expected {}, actual {post_tick}", pre_tick + 1), ); self.frozen_fault = Some(fault.clone()); return Err(fault); } let liveness = self.liveness().map_err(|error| { let fault = SessionError::runtime("tick-phase-fault", error.to_string()); self.frozen_fault = Some(fault.clone()); fault })?; Ok(TickOutcome::Advanced { pre_tick, post_tick, liveness, }) }
pub fn dispatch( &mut self, action: CommittedAction, ) -> Result<ActionOutcome, SessionError> { self.dispatch_with_checkpoint_builder(action, |session, count| { session.build_checkpoint(count) }) }
fn prepare_transfer( &self, player: u64, action: &CommittedAction, structural_start: Option<u64>, ) -> Result<PreparedTransfer, SessionError> { let cell = if matches!( action, CommittedAction::Wield { .. } | CommittedAction::TinkerIdentify { .. } ) { None } else { let cell = self.transfer_player_cell()?; self.check_transfer_geometry(cell)?; Some(cell) }; let (item_id, item_name) = match action { CommittedAction::Pickup { item_id } => { let instance = self.state.items.get(item_id).ok_or_else(|| { SessionError::rejected("pickup-target-missing", "selected ground item is gone") })?; if self.state.ground_items.get(item_id) != Some(&tuple(cell.expect("pickup cell"))) { return Err(SessionError::rejected( "pickup-target-elsewhere", "selected ground item is no longer at the player's cell", )); } (*item_id, self.transfer_item_name(instance)?) } CommittedAction::Drop { item_id } => { let instance = self .state .packs .get(&player) .and_then(|pack| pack.items.get(item_id)) .ok_or_else(|| { SessionError::rejected("drop-item-missing", "selected carried item is gone") })?; (*item_id, self.transfer_item_name(instance)?) } CommittedAction::TinkerIdentify { item_id } => { self.validate_tinker_selection(*item_id)?; if self.tinker_wallet_cu()? < Self::tinker_fee_cu() { return Err(SessionError::rejected( "tinker-insufficient-funds", "identification requires full payment", )); } (*item_id, String::new()) } CommittedAction::Wield { item_id } => { self.validate_wield_selection(*item_id)?; let instance = self.player_pack()?.items.get(item_id).ok_or_else(|| { SessionError::runtime("wield-runtime-invalid", "validated item disappeared") })?; (*item_id, self.transfer_item_name(instance)?) } _ => { return Err(SessionError::runtime( "transfer-preflight", "non-transfer action reached transfer preparation", )) } }; let current_image = self.state.to_image().map_err(|error| { SessionError::runtime("transfer-runtime-invalid", format!("{error:?}")) })?; let mut candidate = State::from_image(current_image).map_err(|error| { SessionError::runtime("transfer-runtime-invalid", format!("{error:?}")) })?; if let CommittedAction::TinkerIdentify { item_id } = action { let template = self.player_pack()?.items[item_id].template.clone(); candidate .spend_copper(player, Self::tinker_fee_cu()) .map_err(|error| { SessionError::runtime("tinker-wallet-invalid", format!("{error:?}")) })?; candidate .initialize_known_template(template) .map_err(|error| { SessionError::runtime("tinker-knowledge-invalid", format!("{error:?}")) })?; } else if let CommittedAction::Wield { item_id } = action { candidate .packs .get_mut(&player) .ok_or_else(|| { SessionError::runtime("wield-runtime-invalid", "Player pack missing") })? .wield(*item_id) .map_err(|error| match error { "it sticks to your hands" => SessionError::rejected("wield-stuck", error), _ => SessionError::runtime("wield-runtime-invalid", error), })?; } else { let transfer = match action { CommittedAction::Pickup { item_id } => { candidate.pickup_ground_item(player, *item_id) } CommittedAction::Drop { item_id } => candidate.drop_carried_item(player, *item_id), _ => unreachable!("item action checked above"), } .map_err(transfer_session_error)?; debug_assert_eq!(transfer.item_id, item_id); debug_assert_eq!(Some(transfer.cell), cell.map(tuple)); } validate_restored_state(&self.pack, &self.catalog, &self.identity, &candidate).map_err( |error| SessionError::runtime("transfer-runtime-invalid", error.to_string()), )?; // The prospective journal span must equal the authored duration, and the // run's final tick must cover it. Admission preflights before those ticks // are spent, so pin the prospective image tick to the completion tick the // journal event asserts; at completion the two already agree. if let Some(start_tick) = structural_start { let span = hexwork_core::action_timing::action_timing(action).ticks(); let completion_tick = start_tick.saturating_add(span); let event = SessionEvent::action(start_tick, completion_tick, action.clone()); let mut candidate_image = candidate.to_image().map_err(|error| { SessionError::runtime("transfer-runtime-invalid", format!("{error:?}")) })?; candidate_image.tick = completion_tick; validate_run_structural_limits( &self.identity, &candidate_image, self.story_checkpoint.as_ref(), &self.events, Some(&event), ) .map_err(transfer_structural_error)?; } let outcome = match action { CommittedAction::Pickup { .. } => ActionOutcome::PickedUp { item_id, item_name }, CommittedAction::Drop { .. } => ActionOutcome::Dropped { item_id, item_name }, CommittedAction::Wield { .. } => ActionOutcome::Wielded { item_id, item_name }, CommittedAction::TinkerIdentify { .. } => ActionOutcome::TinkerIdentified { item_id, cost_cu: Self::tinker_fee_cu(), }, _ => unreachable!("transfer action checked above"), }; Ok(PreparedTransfer { candidate, outcome, }) }
pub(crate) fn tinker_fee_cu() -> u32 { services::price(Service::Tinker).copper }
pub(crate) fn tinker_wallet_cu(&self) -> Result<u32, SessionError> { self.state .wallet(self.player_id()?) .map(|wallet| wallet.copper()) .ok_or_else(|| SessionError::runtime("tinker-wallet-missing", "Player wallet missing")) }
/// Eligibility excludes affordability. State's effective template knowledge is /// encoded as count >= 2; initialize_known_template raises it monotonically. pub(crate) fn tinker_eligible(&self, item_id: u64) -> bool { item_id != 0 && self .player_pack() .ok() .and_then(|pack| pack.items.get(&item_id)) .is_some_and(|item| { self.pack.item(&item.template).is_some() && !self.state.is_effectively_identified(item) }) }
pub(crate) fn tinker_eligible_total(&self) -> Result<usize, SessionError> { Ok(self .player_pack()? .items .keys() .filter(|id| self.tinker_eligible(**id)) .count()) }
pub(crate) fn tinker_known(&self, item_id: u64) -> bool { self.player_pack() .ok() .and_then(|pack| pack.items.get(&item_id)) .is_some_and(|item| self.state.is_effectively_identified(item)) }
pub(crate) fn validate_tinker_access(&self) -> Result<(), SessionError> { self.ensure_operational()?; self.require_town_access( self.player_id()?, MILTHOLLOW, |kind| matches!(kind, InteractionKind::Service { id } if id == "tinker"), ) }
pub(crate) fn validate_tinker_selection(&self, item_id: u64) -> Result<(), SessionError> { self.validate_tinker_access()?; if !self.tinker_eligible(item_id) { return Err(SessionError::rejected( "tinker-item-ineligible", "selected item is missing, known, or unsupported", )); } Ok(()) }
/// Validate the exact carried selection without refreshing or retargeting UI state. pub(crate) fn validate_wield_selection(&self, item_id: u64) -> Result<(), SessionError> { self.ensure_operational()?; let carried = self.player_pack()?; let instance = carried.items.get(&item_id).ok_or_else(|| { SessionError::rejected("wield-item-missing", "selected carried weapon is gone") })?; // Built-in corpses are valid carried inventory, but not catalog weapons. // Other missing templates remain fatal invariants. if instance.template == CORPSE_TEMPLATE { return Err(SessionError::rejected( "wield-not-weapon", "selected item is not a weapon", )); } let content = self .pack .item(&instance.template) .ok_or_else(|| SessionError::runtime("wield-template-missing", &instance.template))?; if content.category != hexwork_content::schema::ItemCategory::Weapon { return Err(SessionError::rejected( "wield-not-weapon", "selected item is not a weapon", )); } if carried.wielded().is_some_and(|held| held.id == item_id) { return Err(SessionError::rejected( "wield-already", "selected weapon is already wielded", )); } Ok(()) }
fn transfer_item_name(&self, instance: &Instance) -> Result<String, SessionError> { let entry = self.project_inventory_entry(instance)?; Ok(bounded_utf8_prefix( &entry.tooltip.title, TRANSFER_ITEM_NAME_LIMIT, )) }
pub(crate) fn transfer_player_cell(&self) -> Result<Cell, SessionError> { self.ensure_operational()?; let player = self.player_id()?; let actor = self .state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(|| { SessionError::runtime("transfer-runtime-invalid", "Player actor missing") })?; checked_actor_cell(actor.position).ok_or_else(|| { SessionError::runtime( "transfer-runtime-invalid", "Player position is nonfinite or outside rounded i32 bounds", ) }) }
fn check_transfer_geometry(&self, cell: Cell) -> Result<(), SessionError> { let location = plan_location(self.state.run_location()).ok_or_else(|| { SessionError::runtime( "transfer-runtime-invalid", format!("unknown run location {:?}", self.state.run_location()), ) })?; let catalog = self.catalog.location(location).ok_or_else(|| { SessionError::runtime( "transfer-runtime-invalid", format!("catalog location missing for {location:?}"), ) })?; let head_z = cell[2].checked_add(1).ok_or_else(|| { SessionError::rejected("transfer-location-blocked", "head coordinate overflow") })?; let below_z = cell[2].checked_sub(1).ok_or_else(|| { SessionError::rejected("transfer-location-blocked", "support coordinate overflow") })?; if !catalog.bounds.contains(cell) { return Err(SessionError::rejected( "transfer-location-blocked", format!("player cell {cell:?} is outside the current location"), )); } let body = self.state.grid.get_world(cell[0], cell[1], cell[2]); let head = self.state.grid.get_world(cell[0], cell[1], head_z); let below = self.state.grid.get_world(cell[0], cell[1], below_z); if body.occ != 0 || head.occ != 0 || below.occ == 0 { return Err(SessionError::rejected( "transfer-location-blocked", format!("blocked body/head or unsupported player cell {cell:?}"), )); } Ok(()) }
fn dispatch_with_checkpoint_builder<F>( &mut self, action: CommittedAction, builder: F, ) -> Result<ActionOutcome, SessionError> where F: FnOnce(&GameSession, u32) -> Result<StoryCheckpoint, SessionError>, { match self.admit_action(&action)? { ActionAdmission::Resolved(outcome) => Ok(outcome), ActionAdmission::Admitted { .. } => self.drive_pending_to_completion(builder), } }
/// Validate a committed request and either resolve it immediately (`Move`, /// `Wait`, `InspectBoard`) or reserve it in the single pending slot. /// /// Everything rejectable before commitment is rejected here, so a rejected /// request spends no ticks and does not stop the world. The Inn price is /// debited exactly once, at successful admission. pub(crate) fn admit_action( &mut self, action: &CommittedAction, ) -> Result<ActionAdmission, SessionError> { self.ensure_operational()?; if !self.journal_has_capacity() { return Err(SessionError::runtime( "journal-capacity", format!("events reached {MAX_SESSION_EVENTS}"), )); } self.events .try_reserve(1) .map_err(|error| SessionError::runtime("journal-capacity", error.to_string()))?; self.state .validate_runtime() .map_err(|error| SessionError::runtime("runtime-invalid", format!("{error:?}")))?; let player = self.player_id()?; let alive = self .state .actors .iter() .find(|actor| actor.id == player) .is_some_and(|actor| actor.alive && actor.hp > 0); if !alive { return Err(SessionError::content("player-dead", player.to_string())); } // Exactly one pending committed action at a time: other committed actions // and movement are refused while busy; looking stays free. if self.state.pending.is_some() { return Err(SessionError::rejected( "pending-busy", "an action is already in progress", )); } match action { CommittedAction::Move(_) | CommittedAction::Wait | CommittedAction::InspectBoard { .. } => { let pre_tick = self.state.tick; let outcome = self.execute_nonrecording(player, action)?; self.events .push(SessionEvent::action(pre_tick, self.state.tick, action.clone())); Ok(ActionAdmission::Resolved(outcome)) } _ => { self.preflight_timed(player, action)?; let pending = Pending::admit(action, self.state.tick); let total_ticks = pending.total_ticks(); self.state.pending = Some(pending); Ok(ActionAdmission::Admitted { total_ticks }) } } }
/// Reject a world-duration request before commitment by running its effect /// against a disposable fork (or the transfer candidate builder), leaving the /// live state untouched. The Inn debits its price exactly once on success. fn preflight_timed( &mut self, player: u64, action: &CommittedAction, ) -> Result<(), SessionError> { match action { CommittedAction::UseService { service: Service::Inn, } => { self.require_town_access(player, MILTHOLLOW, |kind| { matches!(kind, InteractionKind::Inn) })?; let price = services::price(Service::Inn).copper; let wallet = self.state.wallet(player).map(|wallet| wallet.copper()).ok_or_else( || SessionError::runtime("inn-wallet-missing", "Player wallet missing"), )?; if wallet < price { return Err(SessionError::rejected( "service-insufficient-funds", format!("need {price}cu"), )); } // Debit exactly once at admission; no refund or cancel follows. self.state.spend_copper(player, price).map_err(|error| { SessionError::runtime("inn-wallet-invalid", format!("{error:?}")) })?; Ok(()) } CommittedAction::Pickup { .. } | CommittedAction::Drop { .. } | CommittedAction::Wield { .. } | CommittedAction::TinkerIdentify { .. } => { let _prepared = self.prepare_transfer(player, action, Some(self.state.tick))?; Ok(()) } _ => { let image = self.state.to_image().map_err(|error| { SessionError::runtime("preflight-fork", format!("{error:?}")) })?; let fork = State::from_image(image).map_err(|error| { SessionError::runtime("preflight-fork", format!("{error:?}")) })?; let live = std::mem::replace(&mut self.state, fork); let result = self.execute_nonrecording(player, action); self.state = live; result.map(|_| ()) } } }
/// Drive an admitted pending descriptor through the pending-aware tick loop /// until its final tick, then commit the completion journal event. fn drive_pending_to_completion<F>( &mut self, builder: F, ) -> Result<ActionOutcome, SessionError> where F: FnOnce(&GameSession, u32) -> Result<StoryCheckpoint, SessionError>, { let action = self .state .pending .as_ref() .ok_or_else(|| SessionError::runtime("pending-missing", "no admitted action"))? .action .as_committed(); let checkpoint_transition = self.mode() == Mode::Story && self.action_will_enter_town(&action); if !checkpoint_transition { let outcome = loop { match self.advance_tick()? { TickProgress::Advanced { .. } => continue, TickProgress::Completed { outcome, .. } => break outcome?, TickProgress::LifecycleRequired { .. } => { return Err(SessionError::content( "player-lifecycle-required", "dead player requires lifecycle resolution", )) } } }; return Ok(outcome); } // Story transition keeps the existing atomic rollback guard around the // final tick, effect application, journal append and checkpoint refresh. let pre_image = self.state.to_image().map_err(|error| { SessionError::runtime("story-rollback-capture", format!("{error:?}")) })?; let old_len = self.events.len(); let old_checkpoint = self.story_checkpoint.clone(); let old_witness = self.checkpoint_witness.borrow().clone(); let result: Result<(ActionOutcome, StoryCheckpoint, CheckpointWitness), SessionError> = (|| { let outcome = loop { match self.advance_tick()? { TickProgress::Advanced { .. } => continue, TickProgress::Completed { outcome, .. } => break outcome?, TickProgress::LifecycleRequired { .. } => { return Err(SessionError::content( "player-lifecycle-required", "dead player requires lifecycle resolution", )) } } }; self.validate_session_state_only()?; let count = u32::try_from(self.events.len()).map_err(|_| { SessionError::runtime("journal-capacity", self.events.len().to_string()) })?; let candidate = builder(self, count)?; validate_checkpoint_state(&self.pack, &self.catalog, &self.identity, &candidate) .map_err(SessionError::into_runtime)?; let witness = self.checkpoint_witness_for(&candidate)?; Ok((outcome, candidate, witness)) })(); match result { Ok((outcome, candidate, witness)) => { self.story_checkpoint = Some(candidate); *self.checkpoint_witness.borrow_mut() = Some(witness); Ok(outcome) } Err(error) => { self.state = State::from_image(pre_image).map_err(|restore| { SessionError::runtime( "story-refresh-rollback", format!("original {error}; restore {restore:?}"), ) })?; self.state.pending = None; self.events.truncate(old_len); self.story_checkpoint = old_checkpoint; *self.checkpoint_witness.borrow_mut() = old_witness; let detail = error.to_string(); Err(error.with_context("story-checkpoint-refresh", detail)) } } }
/// Advance exactly one pending-aware world tick (docs/fluid §4.3). /// /// Order: consume admitted movement and run the world phases; resolve /// death/lifecycle FIRST; then drive the surviving pending descriptor one /// countdown step, finalizing it exactly once on its last tick. A pending Inn /// consumes one unattended whitelisted tick instead of a full step. pub(crate) fn advance_tick(&mut self) -> Result<TickProgress, SessionError> { self.ensure_operational()?; if self.liveness()? == PlayerLiveness::Dead { self.state.pending = None; return Ok(TickProgress::LifecycleRequired { tick: self.state.tick, liveness: PlayerLiveness::Dead, }); } self.state .validate_runtime() .map_err(|error| SessionError::runtime("runtime-invalid", format!("{error:?}")))?; let pre_tick = self.state.tick; pre_tick .checked_add(1) .ok_or_else(|| SessionError::runtime("tick-overflow", pre_tick.to_string()))?; let unattended = self .state .pending .as_ref() .is_some_and(|pending| pending.inn().is_some()); if unattended { self.state .advance_unattended_tick() .map_err(|error| SessionError::runtime("inn-tick", format!("{error:?}")))?; } else { self.state.step(); } let post_tick = self.state.tick; if post_tick != pre_tick + 1 { let fault = SessionError::runtime( "tick-phase-fault", format!("expected {}, actual {post_tick}", pre_tick + 1), ); self.frozen_fault = Some(fault.clone()); return Err(fault); } let liveness = self.liveness().map_err(|error| { let fault = SessionError::runtime("tick-phase-fault", error.to_string()); self.frozen_fault = Some(fault.clone()); fault })?; // Death/lifecycle wins over a completing pending effect. if liveness == PlayerLiveness::Dead { self.state.pending = None; return Ok(TickProgress::LifecycleRequired { tick: post_tick, liveness, }); } let due = match self.state.pending.as_mut() { Some(pending) => { pending.remaining_ticks = pending.remaining_ticks.saturating_sub(1); if let PendingAction::Inn(inn) = &mut pending.action { inn.remaining_ticks = pending.remaining_ticks; } pending.remaining_ticks == 0 } None => false, }; if !due { return Ok(TickProgress::Advanced { pre_tick, post_tick, liveness, }); } let entry = self .state .pending .as_ref() .map(|pending| (pending.start_tick, pending.action.as_committed())); let outcome = self.finalize_action(); if outcome.is_ok() { if let Some((start_tick, action)) = entry { self.events .push(SessionEvent::action(start_tick, post_tick, action)); } } Ok(TickProgress::Completed { pre_tick, post_tick, liveness, outcome, }) }
/// Revalidate the pending descriptor against the then-current State and apply /// its effect exactly once, or report an honest failure without retargeting. /// The slot is always cleared; previously elapsed time stays spent and no /// partial item/currency effect survives a failed revalidation. fn finalize_action(&mut self) -> Result<ActionOutcome, SessionError> { let pending = self .state .pending .take() .ok_or_else(|| SessionError::runtime("pending-missing", "no admitted action"))?; let player = self.player_id()?; let start_tick = pending.start_tick; match pending.action { PendingAction::Pickup { item_id } => { self.finalize_transfer(player, &CommittedAction::Pickup { item_id }, start_tick) } PendingAction::Drop { item_id } => { self.finalize_transfer(player, &CommittedAction::Drop { item_id }, start_tick) } PendingAction::Wield { item_id } => { self.finalize_transfer(player, &CommittedAction::Wield { item_id }, start_tick) } PendingAction::TinkerIdentify { item_id } => self.finalize_transfer( player, &CommittedAction::TinkerIdentify { item_id }, start_tick, ), PendingAction::ResolveAttack { target_actor } => { self.resolve_attack(player, target_actor) } PendingAction::UseTransition { transition_id } => { self.use_transition(player, transition_id) } PendingAction::AcceptContract { id } => self.accept_contract(player, &id), PendingAction::TurnIn { contract_id } => self.turn_in(player, &contract_id), PendingAction::Inn(_) | PendingAction::UseService { service: Service::Inn } => { // The unattended ticks were already spent tick-by-tick and the // price was debited once at admission; observe current location. self.require_town_access(player, MILTHOLLOW, |kind| { matches!(kind, InteractionKind::Inn) })?; Ok(ActionOutcome::Slept { cost_cu: services::price(Service::Inn).copper, ticks: services::sleep_ticks(), }) } PendingAction::UseService { service } => Err(SessionError::content( "service-unimplemented", format!("{service:?}"), )), PendingAction::Move { .. } | PendingAction::Wait | PendingAction::InspectBoard { .. } => Err(SessionError::runtime( "pending-nonimmediate", "immediate action reached the pending finalizer", )), } }
fn finalize_transfer( &mut self, player: u64, action: &CommittedAction, start_tick: u64, ) -> Result<ActionOutcome, SessionError> { let prepared = self.prepare_transfer(player, action, Some(start_tick))?; self.state = prepared.candidate; Ok(prepared.outcome) }
/// True while the single pending slot is occupied. pub(crate) fn has_pending_action(&self) -> bool { self.state.pending.is_some() }
/// Remaining authored ticks owed by the pending slot, if any. pub(crate) fn pending_remaining_ticks(&self) -> Option<u64> { self.state.pending.as_ref().map(|pending| pending.remaining_ticks) }
/// Authoritative pacing settlement reported to the Action-paced scheduler. /// /// A held or blocked move, an occupied pending slot, or an unsettled fall /// must keep Action-paced cadence running; only a grounded, idle, empty /// world settles. pub(crate) fn settlement(&self) -> Result<Settlement, SessionError> { let player = self.player_id()?; let actor = self .state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; Ok(Settlement { grounded: crate::geometry::supported_floor(&self.state.grid, actor.position).is_some(), pending_action: self.state.pending.is_some(), pending_fall: self.state.fall_odo.contains_key(&player), }) }
fn execute_nonrecording( &mut self, player: u64, action: &CommittedAction, ) -> Result<ActionOutcome, SessionError> { match action { CommittedAction::Move(direction) => self.move_player(player, direction.clone()), CommittedAction::Wait => match self.advance_one_tick()? { TickOutcome::Advanced { post_tick, .. } => { Ok(ActionOutcome::Waited { tick: post_tick }) } TickOutcome::LifecycleRequired { .. } => Err(SessionError::content( "player-lifecycle-required", "dead player requires lifecycle resolution", )), }, CommittedAction::InspectBoard { town } => self.inspect_board(player, town), CommittedAction::AcceptContract { id } => self.accept_contract(player, id), CommittedAction::UseTransition { transition_id } => { self.use_transition(player, *transition_id) } CommittedAction::ResolveAttack { target_actor } => { self.resolve_attack(player, *target_actor) } // Transfers own candidate validation and journal append in dispatch; // executing one here would create an unjournaled invariant bypass. CommittedAction::Pickup { .. } | CommittedAction::Drop { .. } | CommittedAction::Wield { .. } | CommittedAction::TinkerIdentify { .. } => Err(SessionError::runtime( "transfer-nonrecording", "transfer requires atomic dispatch", )), CommittedAction::TurnIn { contract_id } => self.turn_in(player, contract_id), CommittedAction::UseService { service } => self.use_service(player, *service), } }
fn validate_session_state_only(&self) -> Result<(), SessionError> { validate_restored_state(&self.pack, &self.catalog, &self.identity, &self.state) .map_err(SessionError::into_runtime)?; self.state .try_hash_state() .map_err(|error| SessionError::runtime("hash-invalid", format!("{error:?}")))?; Ok(()) }
fn action_will_enter_town(&self, action: &CommittedAction) -> bool { let CommittedAction::UseTransition { transition_id } = action else { return false; }; let Some(current) = plan_location(self.state.run_location()) else { return false; }; self.connectors_for(current) .iter() .find(|connector| connector.transition_id == *transition_id) .is_some_and(|connector| { current != PlanLocation::Town && destination_location(connector.destination) == PlanLocation::Town }) }
fn move_player( &mut self, player: u64, direction: Direction, ) -> Result<ActionOutcome, SessionError> { let from = actor_cell(&self.state, player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; // One admitted world sample is consumed by exactly one tick. Collision, // support, and blocking are core authority; a blocked sample still // spends its tick and leaves the cell unchanged. self.state .admit_player_movement(player, Some(heading_for(&direction))) .map_err(|error| { SessionError::content("move-admission", format!("{error:?}")) })?; match self.advance_one_tick()? { TickOutcome::Advanced { .. } => {} TickOutcome::LifecycleRequired { .. } => { return Err(SessionError::content( "player-lifecycle-required", "dead player requires lifecycle resolution", )) } } let to = actor_cell(&self.state, player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; self.sync_player_light_infallible(player, to); Ok(ActionOutcome::Moved { from, to }) }
fn check_passable( &self, cell: Cell, moving_actor: u64, context: &'static str, ) -> Result<(), SessionError> { let policy = MovementPolicy::default(); let body = self.state.grid.get_world(cell[0], cell[1], cell[2]); let head = self.state.grid.get_world(cell[0], cell[1], cell[2] + 1); let support = self.state.grid.get_world(cell[0], cell[1], cell[2] - 1); if body.occ != 0 || head.occ != 0 { return Err(SessionError::rejected( "passage-blocked", format!("{context}: solid body or headroom at {cell:?}"), )); } if support.occ == 0 { return Err(SessionError::rejected( "passage-unsupported", format!("{context}: no solid support at {cell:?}"), )); } if body.fluid_amt > policy.max_water_depth { return Err(SessionError::rejected( "passage-flooded", format!( "{context}: water depth {} exceeds {} at {cell:?}", body.fluid_amt, policy.max_water_depth ), )); } if self.state.actors.iter().any(|actor| { actor.id != moving_actor && actor.alive && actor.position.cell() == cell }) { return Err(SessionError::rejected( "passage-occupied", format!("{context}: another actor occupies {cell:?}"), )); } Ok(()) }
fn sync_player_light(&mut self) -> Result<(), SessionError> { let player = self.player_id()?; let cell = actor_cell(&self.state, player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; self.sync_player_light_infallible(player, cell); Ok(()) }
fn sync_player_light_infallible(&mut self, _player: u64, cell: Cell) { let light = &mut self.state.lights[0]; light.x = cell[0]; light.y = cell[1]; light.z = cell[2]; }
fn inspect_board(&self, player: u64, town: &str) -> Result<ActionOutcome, SessionError> { self.require_town_access(player, town, |kind| { matches!(kind, InteractionKind::NoticeBoard) })?; let contracts = self .pack .board_for_town(town, BOARD_TIER_MAX) .map_err(|error| SessionError::content("unknown-town", error.to_string()))?; let rows = contracts .iter() .map(|contract| { ( contract.title.clone(), contract.pay_cu, contract.tier, contract.poster.clone(), ) }) .collect::<Vec<_>>(); let mut text = board_list(&rows); for contract in contracts { text.push_str("\n\n"); text.push_str(&contract_card( &contract.title, contract.pay_cu, contract.tier, &contract.poster, &contract.known_hazards, &contract.unknown_hint, &contract.prep_notes, contract.floor, )); } Ok(ActionOutcome::Board { town: town.to_owned(), text, }) }
fn accept_contract(&mut self, player: u64, id: &str) -> Result<ActionOutcome, SessionError> { self.require_town_access(player, MILTHOLLOW, |kind| { matches!(kind, InteractionKind::NoticeBoard) })?; let posted = self .pack .board_for_town(MILTHOLLOW, BOARD_TIER_MAX) .map_err(|error| SessionError::content("unknown-town", error.to_string()))?; let contract = posted .into_iter() .find(|contract| contract.id == id) .ok_or_else(|| SessionError::content("contract-unposted", id))?; let objective_target = if id == RATS_CONTRACT { RATS_OBJECTIVE_TARGET } else { 1 }; let standing_delta = if id == RATS_CONTRACT { RATS_STANDING_DELTA } else { 0 }; self.state .accept_contract( contract.id.clone(), player, contract.floor, contract.pay_cu, objective_target, MILTHOLLOW, standing_delta, ) .map_err(contract_accept_error)?; Ok(ActionOutcome::ContractAccepted { id: id.to_owned() }) }
fn use_transition( &mut self, player: u64, transition_id: u64, ) -> Result<ActionOutcome, SessionError> { let current_run = self.state.run_location().clone(); let current = plan_location(¤t_run) .ok_or_else(|| SessionError::content("location-uninitialized", "no active plan"))?; let connector = self .connectors_for(current) .iter() .find(|connector| connector.transition_id == transition_id) .cloned() .ok_or_else(|| { SessionError::content("transition-unknown", transition_id.to_string()) })?; let position = self.state.actors.iter().find(|a| a.id == player).ok_or_else(|| SessionError::content("player-missing", player.to_string()))?.position; if !crate::geometry::access(&self.state.grid, position, connector.access) { return Err(SessionError::rejected( "transition-out-of-range", format!("stand at {:?}", connector.access), )); } self.check_passable(connector.access, player, "transition-source")?; let destination = destination_location(connector.destination); let local_same_floor = destination == current; if !local_same_floor && !self.has_transition_interaction(player, current, connector.link_id) { return Err(SessionError::content( "transition-unbound", connector.link_id.to_string(), )); } let paired = self .connectors_for(destination) .iter() .find(|candidate| candidate.transition_id == connector.paired_transition_id) .cloned() .ok_or_else(|| { SessionError::content("transition-pair-missing", transition_id.to_string()) })?; if paired.paired_transition_id != connector.transition_id || paired.link_id != connector.link_id || destination_location(paired.destination) != current { return Err(SessionError::content( "transition-pair-malformed", transition_id.to_string(), )); } if connector.connector.blocked || paired.connector.blocked { return Err(SessionError::rejected( "transition-blocked", connector.link_id.to_string(), )); } let live_connector = self .state .connectors .iter() .find(|live| live.id == connector.link_id) .ok_or_else(|| { SessionError::content("transition-live-missing", connector.link_id.to_string()) })?; if live_connector.blocked { return Err(SessionError::rejected( "transition-blocked", connector.link_id.to_string(), )); } self.check_passable(paired.access, player, "transition-destination")?; let next_run = destination.run_location(); if destination != current && !legal_location_edge(¤t_run, &next_run) { return Err(SessionError::content( "transition-location-illegal", format!("{current_run:?} -> {next_run:?}"), )); } let player_index = self .state .actors .iter() .position(|actor| actor.id == player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; let region_id = mapped_region_id(destination); preflight_mapped_region_learning(self.state.knowledge(), ®ion_id)?; let destination_position = checked_cell_pos(paired.access)?;
if destination != current { self.state.transition_location(next_run).map_err(|error| { SessionError::runtime("transition-location-illegal", format!("{error:?}")) })?; } self.state .learn_mapped_region(region_id) .expect("mapped-region learning preflighted"); self.state.actors[player_index].position = destination_position; self.state.fall_odo.remove(&player); self.sync_player_light_infallible(player, paired.access); Ok(ActionOutcome::Transitioned { transition_id, location: self.state.run_location().clone(), position: paired.access, }) }
fn resolve_attack( &mut self, player: u64, target_actor: u64, ) -> Result<ActionOutcome, SessionError> { let geometry = melee_geometry(&self.state, &self.catalog, player, target_actor) .map_err(|error| melee_geometry_session_error(error, player, target_actor))?; let target = self .state .actors .iter() .find(|actor| actor.id == target_actor) .ok_or_else(|| { SessionError::content("attack-target-missing", target_actor.to_string()) })?; let identity = self.state.identity(target_actor).ok_or_else(|| { SessionError::content("attack-target-identity-missing", target_actor.to_string()) })?; let lethal_rat = target.hp <= PLAYER_STRIKE_DAMAGE && self.state.run_location() == &RunLocation::Deep(1) && identity.template_id == "cable_rat"; let objective = lethal_rat .then(|| self.state.contract(RATS_CONTRACT)) .flatten() .filter(|contract| { contract.taker == player && contract.floor == 1 && contract.status == ContractStatus::Active && contract.objective_progress < contract.objective_target }) .map(|_| RATS_CONTRACT); let exchange = self .state .resolve_melee_exchange_with_retaliation( player, target_actor, PLAYER_STRIKE_DAMAGE, // Current defenders have one-cell reach. Do not reverse the // hostile-target geometry helper: Player is not a Monster. (geometry == MeleeGeometry::Adjacent).then_some(PLAYER_STRIKE_DAMAGE), objective, ) .map_err(|error| SessionError::content("melee-rejected", format!("{error:?}")))?; Ok(ActionOutcome::AttackResolved { target_actor, damage: exchange.outgoing_damage, killed: exchange.defender_killed, retaliation_damage: exchange.retaliation_damage, player_killed: exchange.attacker_killed, objective_progress: exchange.objective_progress, }) }
fn turn_in(&mut self, player: u64, contract_id: &str) -> Result<ActionOutcome, SessionError> { self.require_town_access(player, MILTHOLLOW, |kind| { matches!(kind, InteractionKind::NoticeBoard) })?; let reward = self .state .contract(contract_id) .ok_or_else(|| SessionError::content("contract-missing", contract_id))? .pay_cu; self.state .turn_in_contract(contract_id) .map_err(contract_turn_in_error)?; Ok(ActionOutcome::ContractTurnedIn { id: contract_id.to_owned(), reward_cu: reward, }) }
fn use_service( &mut self, player: u64, service: Service, ) -> Result<ActionOutcome, SessionError> { if service != Service::Inn { return Err(SessionError::content( "service-unimplemented", format!("{service:?}"), )); } self.require_town_access(player, MILTHOLLOW, |kind| { matches!(kind, InteractionKind::Inn) })?; let price = services::price(Service::Inn).copper; let ticks = services::sleep_ticks(); self.state .spend_copper_and_advance_unattended(player, price, ticks) .map_err(|error| inn_paid_time_error(error, price))?; Ok(ActionOutcome::Slept { cost_cu: price, ticks, }) }
fn require_town_access( &self, player: u64, town: &str, predicate: impl Fn(&InteractionKind) -> bool, ) -> Result<(), SessionError> { if town != MILTHOLLOW || self.state.run_location() != &RunLocation::Town(town.to_owned()) { return Err(SessionError::rejected("wrong-town", town)); } self.require_access(player, PlanLocation::Town, predicate) }
fn require_access( &self, player: u64, location: PlanLocation, predicate: impl Fn(&InteractionKind) -> bool, ) -> Result<(), SessionError> { let cell = actor_cell(&self.state, player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; let position = self.state.actors.iter().find(|actor| actor.id == player).ok_or_else(|| SessionError::content("player-missing", player.to_string()))?.position; let found = self.catalog.location(location).is_some_and(|entry| entry.interactions.values().flatten().any(|entry| predicate(&entry.kind) && crate::geometry::access(&self.state.grid, position, entry.access))); if found { Ok(()) } else { Err(SessionError::rejected( "interaction-out-of-range", format!("no matching interaction at {cell:?}"), )) } }
fn has_transition_interaction( &self, player: u64, location: PlanLocation, link_id: u64, ) -> bool { let Some(position) = self.state.actors.iter().find(|a| a.id == player).map(|a| a.position) else { return false; }; self.catalog.location(location).is_some_and(|location| location.interactions.values().flatten().any(|entry| { crate::geometry::access(&self.state.grid, position, entry.access) && match &entry.kind { InteractionKind::DeepEntrance { link_id: id } | InteractionKind::Up { link_id: id } | InteractionKind::Down { link_id: id } => *id == link_id, _ => false, } })) }
fn connectors_for(&self, location: PlanLocation) -> &[ConnectorPlan] { self.catalog .location(location) .map_or(&[], |entry| entry.connectors.as_slice()) }
#[cfg(test)] fn location_for_cell(&self, cell: Cell) -> Option<PlanLocation> { self.catalog .locations .values() .find(|entry| entry.bounds.contains(cell)) .map(|entry| entry.location) }
#[cfg(test)] fn location_catalog(&self, location: PlanLocation) -> Option<&LocationCatalog> { self.catalog.location(location) }
#[cfg(test)] fn first_connector(&self, location: PlanLocation) -> &ConnectorPlan { self.connectors_for(location) .first() .expect("location has a connector") }
#[cfg(test)] fn floor_catalog(&self, depth: u8) -> &LocationCatalog { self.location_catalog(PlanLocation::Deep(depth)) .expect("floor catalog exists") }}
fn advance_replay_to( session: &mut GameSession, target_tick: u64, event_index: usize, budget: &mut ReplayBudget, evidence: &mut Vec<ReplayEvidence>, cause_cursor: &mut usize,) -> Result<(), SessionError> { if session.state.tick > target_tick { return Err(SessionError::content( "replay-past-tick", format!( "before event {event_index}: {} > {target_tick}", session.state.tick ), )); } while session.state.tick < target_tick { budget.debit_full(1)?; let outcome = session.advance_one_tick()?; capture_evidence( &session.state, cause_cursor, event_index, EvidenceSegment::Idle, budget, evidence, )?; match outcome { TickOutcome::Advanced { liveness: PlayerLiveness::Alive, .. } => {} TickOutcome::Advanced { liveness: PlayerLiveness::Dead, .. } | TickOutcome::LifecycleRequired { .. } => { if session.state.tick < target_tick { return Err(SessionError::content( "replay-lifecycle-required", format!( "player died at tick {} before target {target_tick}", session.state.tick ), )); } } } } Ok(())}
fn capture_evidence( state: &State, cursor: &mut usize, event_ordinal: usize, segment: EvidenceSegment, budget: &mut ReplayBudget, out: &mut Vec<ReplayEvidence>,) -> Result<(), SessionError> { let links = state.causes.links(); if *cursor > links.len() { *cursor = links.len(); } for (local_ordinal, link) in links[*cursor..].iter().enumerate() { if budget.evidence_rows == 0 || out.len() >= budget.limits.max_evidence_rows { return Err(SessionError::runtime( "replay-evidence-limit", format!("event {event_ordinal}"), )); } let subtitle = hexwork_core::epitaphs::subtitle(link.rule); capture_evidence_row( EvidenceRowMeta { event_ordinal, segment, local_ordinal, tick: link.tick, }, EvidenceRowText { rule: link.rule, detail: &link.detail, subtitle, }, budget, out, || {}, )?; } *cursor = links.len(); Ok(())}
#[derive(Clone, Copy)]struct EvidenceRowMeta { event_ordinal: usize, segment: EvidenceSegment, local_ordinal: usize, tick: u64,}
#[derive(Clone, Copy)]struct EvidenceRowText<'a> { rule: &'a str, detail: &'a str, subtitle: &'a str,}
fn capture_evidence_row( meta: EvidenceRowMeta, text: EvidenceRowText<'_>, budget: &mut ReplayBudget, out: &mut Vec<ReplayEvidence>, before_clone: impl FnOnce(),) -> Result<(), SessionError> { if budget.evidence_rows == 0 || out.len() >= budget.limits.max_evidence_rows { return Err(SessionError::runtime( "replay-evidence-limit", format!("event {}", meta.event_ordinal), )); } let bytes = text .rule .len() .checked_add(text.detail.len()) .and_then(|n| n.checked_add(text.subtitle.len())) .ok_or_else(|| SessionError::runtime("replay-evidence-text-limit", "length overflow"))?; if bytes > 4 * 1024 { return Err(SessionError::runtime( "replay-evidence-text-limit", format!( "event {} row {}: {bytes}", meta.event_ordinal, meta.local_ordinal ), )); } out.try_reserve(1) .map_err(|error| SessionError::runtime("replay-evidence-limit", error.to_string()))?; before_clone(); out.push(ReplayEvidence { event_ordinal: meta.event_ordinal, segment: meta.segment, local_ordinal: meta.local_ordinal, tick: meta.tick, rule: text.rule.to_owned(), detail: text.detail.to_owned(), subtitle: text.subtitle.to_owned(), }); budget.evidence_rows -= 1; Ok(())}
fn canonical_player_id(state: &State, run: &RunIdentity) -> Result<u64, SessionError> { let mut players = state.actors.iter().filter(|actor| { state .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Player) }); let actor = players .next() .ok_or_else(|| SessionError::content("player-missing", "no canonical Player actor"))?; if players.next().is_some() { return Err(SessionError::content( "player-ambiguous", "multiple canonical Player actors", )); } let identity = state .identity(actor.id) .ok_or_else(|| SessionError::content("player-identity-missing", actor.id.to_string()))?; if identity.template_id != PLAYER_TEMPLATE { return Err(SessionError::content( "player-template-invalid", &identity.template_id, )); } if identity.display_name != run.display_name { return Err(SessionError::content( "player-name-mismatch", &identity.display_name, )); } Ok(actor.id)}
fn validate_identity_content( pack: &RuntimePack, identity: &RunIdentity,) -> Result<(), SessionError> { if identity.content_hash != pack.hash() { return Err(SessionError::content( "content-hash-mismatch", format!("recorded {}, loaded {}", identity.content_hash, pack.hash()), )); } validate_initial_lineage(&identity.initial_lineage, true) .map_err(|error| SessionError::content("initial-lineage-invalid", error.to_string()))?; validate_knowledge_references( pack, &identity.initial_lineage.mapped_regions, &identity.initial_lineage.translated_hymns, &identity.initial_lineage.identified_item_templates, "initial-lineage", )?; for ghost in &identity.initial_lineage.ghosts { if pack.item(&ghost.item_template_id).is_none() { return Err(SessionError::content( "initial-ghost-template-missing", &ghost.item_template_id, )); } } for lot in &identity.initial_lineage.auction_lots { if pack.item(&lot.template_id).is_none() { return Err(SessionError::content( "initial-lot-template-missing", &lot.template_id, )); } } Ok(())}
fn validate_knowledge_references<'a>( pack: &RuntimePack, mapped_regions: impl IntoIterator<Item = &'a String>, translated_hymns: impl IntoIterator<Item = &'a String>, identified_templates: impl IntoIterator<Item = &'a String>, context: &'static str,) -> Result<(), SessionError> { for region in mapped_regions { if let Some(town_id) = region.strip_prefix("town:") { if town_id.is_empty() || pack.town(town_id).is_none() { return Err(SessionError::content( "knowledge-region-missing", format!("{context}: {region}"), )); } } else if let Some(depth_text) = region.strip_prefix("deep:") { let depth = depth_text.parse::<u8>().map_err(|_| { SessionError::content("knowledge-region-invalid", format!("{context}: {region}")) })?; if !(1..=12).contains(&depth) || depth.to_string() != depth_text || pack.floor(depth).is_none() { return Err(SessionError::content( "knowledge-region-invalid", format!("{context}: {region}"), )); } } else { return Err(SessionError::content( "knowledge-region-invalid", format!("{context}: {region}"), )); } } for hymn_id in translated_hymns { if !pack.hymns().iter().any(|hymn| hymn.id == *hymn_id) { return Err(SessionError::content( "knowledge-hymn-missing", format!("{context}: {hymn_id}"), )); } } for template_id in identified_templates { if pack.item(template_id).is_none() { return Err(SessionError::content( "knowledge-item-template-missing", format!("{context}: {template_id}"), )); } } Ok(())}
fn apply_initial_lineage(state: &mut State, lineage: &InitialLineage) -> Result<(), SessionError> { for id in &lineage.mapped_regions { state .learn_mapped_region(id.clone()) .map_err(|error| SessionError::content("initial-map-invalid", format!("{error:?}")))?; } for id in &lineage.recipes { state.learn_recipe(id.clone()).map_err(|error| { SessionError::content("initial-recipe-invalid", format!("{error:?}")) })?; } for id in &lineage.translated_hymns { state.learn_translation(id.clone()).map_err(|error| { SessionError::content("initial-translation-invalid", format!("{error:?}")) })?; } for id in &lineage.identified_item_templates { state .initialize_known_template(id.clone()) .map_err(|error| { SessionError::content("initial-identification-invalid", format!("{error:?}")) })?; } Ok(())}
fn validate_character(character: &Character, run: &RunIdentity) -> Result<(), SessionError> { if character.ancestry != run.ancestry || character.calling != run.calling { return Err(SessionError::content( "player-character-identity-mismatch", format!("{:?}/{:?}", character.ancestry, character.calling), )); } let initial = character::new(run.ancestry, run.calling); for attribute in ALL_ATTRIBUTES { let i = attribute.index(); if character.attrs[i] < initial.attrs[i] || character.attrs[i] > MAX_ATTR { return Err(SessionError::content( "player-attribute-range", format!("{attribute:?}: {}", character.attrs[i]), )); } } for skill in ALL_SKILLS { let i = skill.index(); let level = character.skills[i]; let pips = character.pips[i]; if level < initial.skills[i] || level > MAX_SKILL { return Err(SessionError::content( "player-skill-range", format!("{skill:?}: {level}"), )); } if character.uses[i] < u32::from(pips) { return Err(SessionError::content( "player-skill-uses-pips", format!("{skill:?}"), )); } if (level == MAX_SKILL && pips != 0) || (level < MAX_SKILL && u32::from(pips) >= character::uses_for_next_point(level)) { return Err(SessionError::content( "player-skill-pips-range", format!("{skill:?}: {pips}"), )); } } Ok(())}
fn validate_checkpoint_state( pack: &RuntimePack, catalog: &RuntimeCatalog, run: &RunIdentity, checkpoint: &StoryCheckpoint,) -> Result<(), SessionError> { let rebuilt = HashedState::from_image(checkpoint.state.image.clone()) .map_err(|error| SessionError::content("story-checkpoint-invalid", error.to_string()))?; if rebuilt.tick != checkpoint.state.tick { return Err(SessionError::content( "story-checkpoint-tick-mismatch", format!("{} != {}", rebuilt.tick, checkpoint.state.tick), )); } if rebuilt.hash != checkpoint.state.hash { return Err(SessionError::content( "story-checkpoint-hash-mismatch", format!("{} != {}", rebuilt.hash, checkpoint.state.hash), )); } let state = rebuilt .into_state() .map_err(|error| SessionError::content("story-checkpoint-invalid", error.to_string()))?; validate_restored_state(pack, catalog, run, &state)?; if state.run_location() != &RunLocation::Town(MILTHOLLOW.to_owned()) { return Err(SessionError::content( "story-checkpoint-not-town", format!("{:?}", state.run_location()), )); } let player = canonical_player_id(&state, run)?; let actor = state .actors .iter() .find(|actor| actor.id == player) .ok_or_else(|| SessionError::content("player-missing", player.to_string()))?; if !actor.alive || actor.hp <= 0 { return Err(SessionError::content( "story-checkpoint-player-dead", player.to_string(), )); } Ok(())}
fn validate_restored_state( pack: &RuntimePack, catalog: &RuntimeCatalog, run: &RunIdentity, state: &State,) -> Result<(), SessionError> { validate_identity_content(pack, run)?; validate_knowledge_references( pack, &state.knowledge().mapped_regions, &state.knowledge().translated_hymns, state.idf.keys(), "current-state", )?; state .validate_runtime() .map_err(|error| SessionError::content("runtime-invalid", format!("{error:?}")))?; if state.seed != run.seed { return Err(SessionError::content( "seed-mismatch", format!("identity {}, state {}", run.seed, state.seed), )); } let player_id = canonical_player_id(state, run)?; let character = state .characters .get(&player_id) .ok_or_else(|| SessionError::content("player-character-missing", player_id.to_string()))?; validate_character(character, run)?; let player = state .actors .iter() .find(|actor| actor.id == player_id) .ok_or_else(|| SessionError::content("player-missing", player_id.to_string()))?; for (table, present) in [ ("reputations", state.reputations.contains_key(&player_id)), ("packs", state.packs.contains_key(&player_id)), ("hymn_cases", state.hymn_cases.contains_key(&player_id)), ("wallets", state.wallet(player_id).is_some()), ] { if !present { return Err(SessionError::content("player-table-missing", table)); } } if player.alive && !state.bargains.contains_key(&player_id) { return Err(SessionError::content("player-table-missing", "bargains")); } let location = plan_location(state.run_location()).ok_or_else(|| { SessionError::content( "location-catalog-mismatch", format!("{:?}", state.run_location()), ) })?; let location_catalog = catalog.location(location).ok_or_else(|| { SessionError::content("location-catalog-missing", format!("{location:?}")) })?; let player_cell = player.position.cell(); if !location_catalog.bounds.contains(player_cell) { return Err(SessionError::content( "player-outside-location", format!("{player_cell:?} outside {location:?}"), )); }
let mut expected_connectors = BTreeMap::new(); for planned in catalog .locations .values() .flat_map(|entry| entry.connectors.iter()) { if planned.link_id == 0 { return Err(SessionError::content( "connector-id-zero", "generated connector", )); } match expected_connectors.entry(planned.link_id) { std::collections::btree_map::Entry::Vacant(slot) => { slot.insert(&planned.connector); } std::collections::btree_map::Entry::Occupied(slot) if connector_provenance_eq(slot.get(), &planned.connector) => {} std::collections::btree_map::Entry::Occupied(_) => { return Err(SessionError::content( "connector-generated-provenance", planned.link_id.to_string(), )) } } } if state.connectors.len() != expected_connectors.len() { return Err(SessionError::content( "connector-catalog-mismatch", format!( "state {}, catalog {}", state.connectors.len(), expected_connectors.len() ), )); } let mut seen = BTreeSet::new(); for connector in &state.connectors { if connector.id == 0 { return Err(SessionError::content("connector-id-zero", "live connector")); } if !seen.insert(connector.id) { return Err(SessionError::content( "connector-id-duplicate", connector.id.to_string(), )); } let expected = expected_connectors.get(&connector.id).ok_or_else(|| { SessionError::content("connector-catalog-missing", connector.id.to_string()) })?; if !connector_provenance_eq(connector, expected) { return Err(SessionError::content( "connector-catalog-mismatch", connector.id.to_string(), )); } if !connector.load_kg.is_finite() || connector.load_kg < 0.0 || connector.anchor_quality > 100 { return Err(SessionError::content( "connector-mutable-invalid", connector.id.to_string(), )); } }
for actor in &state.actors { let identity = state .identity(actor.id) .ok_or_else(|| SessionError::content("actor-identity-missing", actor.id.to_string()))?; match identity.role { ActorRole::Player => {} ActorRole::Npc if identity.template_id == TOWNSFOLK_TEMPLATE && state.npcs.contains_key(&actor.id) => {} ActorRole::Npc => { return Err(SessionError::content( "npc-template-invalid", &identity.template_id, )) } ActorRole::Monster if pack.monster(&identity.template_id).is_some() => {} ActorRole::Monster => { return Err(SessionError::content( "actor-template-missing", &identity.template_id, )) } } } for item in state .items .values() .chain(state.packs.values().flat_map(|pack| pack.items.values())) { if item.template != CORPSE_TEMPLATE && pack.item(&item.template).is_none() { return Err(SessionError::content( "item-template-missing", &item.template, )); } } for (id, contract) in state.contracts() { let content = pack .contract(id) .ok_or_else(|| SessionError::content("contract-content-missing", id))?; if contract.floor != content.floor || contract.pay_cu != content.pay_cu || contract.reward_town != MILTHOLLOW || pack.town(&contract.reward_town).is_none() { return Err(SessionError::content("contract-content-mismatch", id)); } } Ok(())}
fn connector_provenance_eq( left: &hexwork_core::connector::Connector, right: &hexwork_core::connector::Connector,) -> bool { let same_direction = left.from == right.from && left.to == right.to; let reversed = left.from == right.to && left.to == right.from; left.id == right.id && left.kind == right.kind && (same_direction || reversed) && left.length.to_bits() == right.length.to_bits()}
fn contract_accept_error(error: ContractError) -> SessionError { let detail = format!("{error:?}"); match error { ContractError::DuplicateId | ContractError::WrongLocation => { SessionError::rejected("contract-rejected", detail) } _ => SessionError::content("contract-rejected", detail), }}
fn contract_turn_in_error(error: ContractError) -> SessionError { let detail = format!("{error:?}"); match error { ContractError::NotCompleted | ContractError::WrongLocation => { SessionError::rejected("turn-in-rejected", detail) } _ => SessionError::content("turn-in-rejected", detail), }}
fn inn_paid_time_error(error: PaidUnattendedError, price: u32) -> SessionError { match error { PaidUnattendedError::Wallet(WalletError::InsufficientFunds) => { SessionError::rejected("service-insufficient-funds", format!("need {price}cu")) } PaidUnattendedError::Wallet(WalletError::Overflow) => { SessionError::runtime("service-wallet-overflow", "wallet arithmetic overflow") } PaidUnattendedError::Clock(error) => { SessionError::runtime("service-clock-overflow", format!("{error:?}")) } }}
fn check_passable_state( state: &State, cell: Cell, moving_actor: u64, context: &'static str,) -> Result<(), SessionError> { let policy = MovementPolicy::default(); let body = state.grid.get_world(cell[0], cell[1], cell[2]); let head = state.grid.get_world(cell[0], cell[1], cell[2] + 1); let support = state.grid.get_world(cell[0], cell[1], cell[2] - 1); if body.occ != 0 || head.occ != 0 { return Err(SessionError::content( "passage-blocked", format!("{context}: {cell:?}"), )); } if support.occ == 0 { return Err(SessionError::content( "passage-unsupported", format!("{context}: {cell:?}"), )); } if body.fluid_amt > policy.max_water_depth { return Err(SessionError::content( "passage-flooded", format!("{context}: {cell:?}"), )); } if state .actors .iter() .any(|actor| actor.id != moving_actor && actor.alive && actor.position.cell() == cell) { return Err(SessionError::content( "passage-occupied", format!("{context}: {cell:?}"), )); } Ok(())}
fn sync_player_light_state(state: &mut State, cell: Cell) -> Result<(), SessionError> { let light = state .lights .first_mut() .ok_or_else(|| SessionError::runtime("player-light-missing", "no canonical light"))?; light.x = cell[0]; light.y = cell[1]; light.z = cell[2]; Ok(())}
fn install_live_connectors(state: &mut State, catalog: &RuntimeCatalog) { let mut connectors = BTreeMap::new(); for connector in catalog .locations .values() .flat_map(|location| location.connectors.iter()) { connectors .entry(connector.link_id) .or_insert_with(|| connector.connector.clone()); } state.connectors = connectors.into_values().collect();}
fn apply_world_plan(pack: &RuntimePack, state: &mut State, plan: WorldPlan) -> Result<(), SessionError> { for actor in &plan.actors { checked_cell_pos(actor.position)?; } apply_writes(state, &plan.writes); for actor in plan.actors { spawn_planned_actor(state, actor)?; } for item in plan.items { spawn_planned_items(pack, state, item); } Ok(())}
fn apply_floor_plan(pack: &RuntimePack, state: &mut State, plan: FloorPlan) -> Result<(), SessionError> { for actor in &plan.actors { checked_cell_pos(actor.position)?; } apply_writes(state, &plan.writes); for actor in plan.actors { spawn_planned_actor(state, actor)?; } for item in plan.items { spawn_planned_items(pack, state, item); } Ok(())}
fn spawn_planned_actor(state: &mut State, plan: ActorSpawnPlan) -> Result<(), SessionError> { let id = state.spawn_actor_with_identity( checked_cell_pos(plan.position)?, ActorIdentity::new(&plan.template_id, &plan.display_name, plan.role), ); state .actors .iter_mut() .find(|actor| actor.id == id) .expect("spawned actor retained") .hp = plan.hp; if plan.role == ActorRole::Npc { let home = plan.home.expect("NPC home preflighted"); let work = plan.work.expect("NPC work preflighted"); let job = plan.job.expect("NPC job preflighted"); state.npcs.insert( id, Townsfolk { name: plan.display_name, job, home: tuple(home), work: tuple(work), debt_cu: 0, }, ); if job_has_shop_memory(job) { state.shops.entry(tuple(work)).or_default(); } } Ok(())}
fn spawn_planned_items(pack: &RuntimePack, state: &mut State, plan: ItemSpawnPlan) { let template = item_template_for(pack, &plan.template_id).expect("planned item conversion preflighted"); for _ in 0..plan.quantity { state .spawn_ground_item(&template, tuple(plan.position)) .expect("planned item spawn preflighted"); }}
fn preflight( pack: &RuntimePack, town: &WorldPlan, floors: &[FloorPlan], setup: &PlayerSetup,) -> Result<(), SessionError> { validate_deep_links(town, floors) .map_err(|error| SessionError::content("plan-invalid", error.to_string()))?; if town.id != MILTHOLLOW || pack.town(MILTHOLLOW).is_none() { return Err(SessionError::content("town-mismatch", &town.id)); } if setup.display_name.trim().is_empty() { return Err(SessionError::content( "player-name-blank", "display name is blank", )); } for floor in floors { let content = pack.floor(floor.depth).ok_or_else(|| { SessionError::content("floor-content-missing", floor.depth.to_string()) })?; if content.id != floor.id { return Err(SessionError::content( "floor-content-mismatch", format!("{} != {}", floor.id, content.id), )); } } validate_plan_content(pack, town, floors)?; for template_id in kit(setup.character.calling).items { validate_item_conversion(pack, template_id)?; } validate_write_conflicts(town, floors)?; Ok(())}
fn validate_plan_content( pack: &RuntimePack, town: &WorldPlan, floors: &[FloorPlan],) -> Result<(), SessionError> { for actor in town .actors .iter() .chain(floors.iter().flat_map(|floor| floor.actors.iter())) { match actor.role { ActorRole::Npc => { if actor.template_id != TOWNSFOLK_TEMPLATE || actor.hp <= 0 { return Err(SessionError::content( "npc-template-invalid", &actor.template_id, )); } } ActorRole::Monster => { let monster = pack.monster(&actor.template_id).ok_or_else(|| { SessionError::content("actor-template-missing", &actor.template_id) })?; if actor.hp != monster.hp { return Err(SessionError::content( "actor-hp-mismatch", format!( "{}: plan {}, content {}", actor.template_id, actor.hp, monster.hp ), )); } } ActorRole::Player => { return Err(SessionError::content( "plan-player-forbidden", &actor.template_id, )); } } } for zone_id in floors .iter() .flat_map(|floor| floor.spawn_zones.iter()) .flat_map(|zone| zone.template_ids.iter()) { if pack.monster(zone_id).is_none() { return Err(SessionError::content("spawn-template-missing", zone_id)); } } for item in town .items .iter() .chain(floors.iter().flat_map(|floor| floor.items.iter())) { validate_item_conversion(pack, &item.template_id)?; } Ok(())}
struct InventoryTextBudget { remaining: usize, clipped: bool, #[cfg(test)] consumed_input: usize, #[cfg(test)] logical_use_reads: usize,}
impl InventoryTextBudget { fn new(fixed_output_bytes: usize) -> Self { Self { remaining: INVENTORY_TOOLTIP_TEXT_LIMIT .saturating_sub(INVENTORY_TOOLTIP_CLIPPED_LINE.len()) .saturating_sub(fixed_output_bytes), clipped: fixed_output_bytes + INVENTORY_TOOLTIP_CLIPPED_LINE.len() > INVENTORY_TOOLTIP_TEXT_LIMIT, #[cfg(test)] consumed_input: 0, #[cfg(test)] logical_use_reads: 0, } }
fn reserve_output(&mut self, bytes: usize) -> bool { if bytes > self.remaining { self.clipped = true; return false; } self.remaining -= bytes; true }
fn sanitized_prefix(&mut self, input: &str) -> String { let mut output = String::new(); output.reserve(input.len().min(self.remaining)); for character in input.chars() { let input_bytes = character.len_utf8(); if input_bytes > self.remaining { self.clipped = true; break; } self.remaining -= input_bytes; #[cfg(test)] { self.consumed_input += input_bytes; } if character.is_control() { output.push(' '); } else { output.push(character); } } output }}
fn bounded_item_tooltip( name: &str, identified: bool, uses: &[String], material: &str,) -> (Tooltip, bool) { bounded_item_tooltip_inner(name, identified, uses, material).0}
fn bounded_item_tooltip_inner( name: &str, identified: bool, uses: &[String], material: &str,) -> ((Tooltip, bool), InventoryTextBudget) { const MADE_OF_BYTES: usize = "Made of ".len() + ".".len(); const UNIDENTIFIED_SUFFIX_BYTES: usize = " (unidentified)".len(); const UNIDENTIFIED_LINE: &str = "[unidentified — you haven't learned this yet]"; const TRIAL_LINE: &str = "Trial would reveal: safe uses in the Lampwrights' testing chapel, or pay the tinker, or ask a ghost."; const USES_HEADING_BYTES: usize = "Uses:".len(); const USE_BULLET_BYTES: usize = "· ".len(); const EMPTY_USES_LINE: &str = "No recorded uses — flag this: everything has two uses.";
let fixed_output_bytes = if !identified { UNIDENTIFIED_SUFFIX_BYTES + UNIDENTIFIED_LINE.len() + MADE_OF_BYTES + TRIAL_LINE.len() } else { MADE_OF_BYTES + USES_HEADING_BYTES + EMPTY_USES_LINE.len() + INVENTORY_TOOLTIP_USES_CLIPPED_LINE.len() }; let mut budget = InventoryTextBudget::new(fixed_output_bytes); let bounded_name = budget.sanitized_prefix(name); let bounded_material = budget.sanitized_prefix(material); let mut bounded_uses = Vec::new(); let mut uses_omitted = false; if identified { for use_text in uses.iter().take(MAX_TOOLTIP_USE_ROWS) { #[cfg(test)] { budget.logical_use_reads += 1; } let bounded = budget.sanitized_prefix(use_text); if budget.clipped { uses_omitted = true; break; } if bounded.trim().is_empty() { continue; } if !budget.reserve_output(USE_BULLET_BYTES) { uses_omitted = true; break; } bounded_uses.push(bounded); } if uses.len() > MAX_TOOLTIP_USE_ROWS { uses_omitted = true; } } budget.clipped |= uses_omitted; let use_refs = bounded_uses.iter().map(String::as_str).collect::<Vec<_>>(); let mut tooltip = item_tooltip(&bounded_name, identified, &use_refs, &bounded_material); if uses_omitted && bounded_uses.is_empty() { tooltip.lines.retain(|line| line != EMPTY_USES_LINE); tooltip .lines .push(INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned()); } if budget.clipped { tooltip .lines .push(INVENTORY_TOOLTIP_CLIPPED_LINE.to_owned()); } ((tooltip, budget.clipped), budget)}
fn bounded_corpse_tooltip(material: u8) -> Result<(Tooltip, bool), SessionError> { let material = inventory_material_name(material) .ok_or_else(|| SessionError::runtime("inventory-material-invalid", material.to_string()))?; Ok(( Tooltip { title: "Corpse".to_owned(), lines: vec!["Remains.".to_owned(), format!("Made of {material}.")], }, false, ))}
fn inventory_material_name(material_id: u8) -> Option<&'static str> { use hexwork_core::voxel::material;
Some(match material_id { material::AIR => "AIR", material::WOOD => "WOOD", material::IRON => "IRON", material::COPPER => "COPPER", material::STONE => "STONE", material::BRASS => "BRASS", material::FLESH => "FLESH", material::SAINT_BONE => "SAINT_BONE", material::GLASS => "GLASS", material::ROPE => "ROPE", material::PAPER => "PAPER", _ => return None, })}
#[cfg(test)]fn update_inventory_probes( probes: Option<&std::cell::Cell<InventoryQueryProbes>>, update: impl FnOnce(&mut InventoryQueryProbes),) { if let Some(probes) = probes { let mut value = probes.get(); update(&mut value); probes.set(value); }}
#[cfg(test)]fn record_inventory_page_probe(probes: Option<&std::cell::Cell<InventoryQueryProbes>>) { update_inventory_probes(probes, |value| value.page += 1);}
#[cfg(test)]fn record_inventory_boundary_probe(probes: Option<&std::cell::Cell<InventoryQueryProbes>>) { update_inventory_probes(probes, |value| value.boundary += 1);}
#[cfg(test)]fn record_inventory_projection_probe(probes: Option<&std::cell::Cell<InventoryQueryProbes>>) { update_inventory_probes(probes, |value| value.projected += 1);}
#[cfg(test)]fn record_inventory_direct_lookup_probe(probes: Option<&std::cell::Cell<InventoryQueryProbes>>) { update_inventory_probes(probes, |value| value.direct_lookup += 1);}
#[cfg(test)]fn update_ground_probes( probes: Option<&std::cell::Cell<GroundQueryProbes>>, update: impl FnOnce(&mut GroundQueryProbes),) { if let Some(probes) = probes { let mut value = probes.get(); update(&mut value); probes.set(value); }}
#[cfg(test)]fn record_ground_visit_probe(probes: Option<&std::cell::Cell<GroundQueryProbes>>) { update_ground_probes(probes, |value| value.visited += 1);}
#[cfg(test)]fn record_ground_projection_probe(probes: Option<&std::cell::Cell<GroundQueryProbes>>) { update_ground_probes(probes, |value| value.projected += 1);}
#[cfg(test)]fn record_ground_direct_lookup_probe(probes: Option<&std::cell::Cell<GroundQueryProbes>>) { update_ground_probes(probes, |value| value.direct_lookup += 1);}
fn validate_item_conversion(pack: &RuntimePack, id: &str) -> Result<(), SessionError> { let schema = pack .item(id) .ok_or_else(|| SessionError::content("item-template-missing", id))?; let template = item_template(schema)?; item::spawn(&template, 1) .map(|_| ()) .map_err(|error| SessionError::content("item-conversion-invalid", error))}
fn item_template_for(pack: &RuntimePack, id: &str) -> Result<ItemTemplate, SessionError> { let item = pack .item(id) .ok_or_else(|| SessionError::content("item-template-missing", id))?; item_template(item)}
fn item_template(item: &Item) -> Result<ItemTemplate, SessionError> { if !item.weight_kg.is_finite() || item.weight_kg <= 0.0 { return Err(SessionError::content( "item-weight-invalid", format!("{}: {}", item.id, item.weight_kg), )); } let template = ItemTemplate { id: item.id.clone(), material: item.material.clone(), condition: item.condition.clone(), identified: item.identified, cursed: item.cursed, blessed: item.blessed, silvered: item.silvered, illegal: item.illegal, tags: item.tags.clone(), }; item::spawn(&template, 1) .map_err(|error| SessionError::content("item-conversion-invalid", error))?; Ok(template)}
fn validate_write_conflicts(town: &WorldPlan, floors: &[FloorPlan]) -> Result<(), SessionError> { let mut writes: BTreeMap<(i32, i32, i32), Voxel> = BTreeMap::new(); for write in town .writes .iter() .chain(floors.iter().flat_map(|floor| floor.writes.iter())) { let key = cell_key(write.cell); if let Some(previous) = writes.insert(key, write.voxel) { if previous != write.voxel { return Err(SessionError::content( "plan-write-conflict", format!("conflicting writes at {:?}", write.cell), )); } } } Ok(())}
fn apply_writes(state: &mut State, writes: &[VoxelWrite]) { let edge = CHUNK as i32; let chunks: BTreeSet<_> = writes .iter() .map(|write| { ( write.cell[0].div_euclid(edge), write.cell[1].div_euclid(edge), write.cell[2].div_euclid(edge), ) }) .collect(); for (cx, cy, cz) in chunks { state.activate_around(cx, cy, cz); } for write in writes { state .grid .set_world(write.cell[0], write.cell[1], write.cell[2], write.voxel); }}
fn mapped_region_id(location: PlanLocation) -> String { match location { PlanLocation::Town => format!("town:{MILTHOLLOW}"), PlanLocation::Deep(depth) => format!("deep:{depth}"), }}
fn preflight_mapped_region_learning( knowledge: &hexwork_core::runtime::RunKnowledge, id: &str,) -> Result<(), SessionError> { validate_knowledge_id(KnowledgeCategory::MappedRegions, id).map_err(|error| { SessionError::content("transition-knowledge-invalid", format!("{error:?}")) })?; if knowledge.mapped_regions.contains(id) { return Ok(()); } if knowledge.mapped_regions.len() >= MAPPED_REGION_LIMIT { return Err(SessionError::runtime( "transition-knowledge-capacity", format!("mapped regions reached {MAPPED_REGION_LIMIT}"), )); } let total_rows = knowledge.mapped_regions.len() + knowledge.recipes.len() + knowledge.translated_hymns.len(); if total_rows >= KNOWLEDGE_TOTAL_ROW_LIMIT { return Err(SessionError::runtime( "transition-knowledge-capacity", format!("knowledge rows reached {KNOWLEDGE_TOTAL_ROW_LIMIT}"), )); } let total_bytes: usize = knowledge .mapped_regions .iter() .chain(&knowledge.recipes) .chain(&knowledge.translated_hymns) .map(String::len) .sum(); let bytes_over_limit = match total_bytes.checked_add(id.len()) { Some(bytes) => bytes > KNOWLEDGE_TOTAL_ID_BYTES_LIMIT, None => true, }; if bytes_over_limit { return Err(SessionError::runtime( "transition-knowledge-capacity", format!("knowledge bytes exceed {KNOWLEDGE_TOTAL_ID_BYTES_LIMIT}"), )); } Ok(())}
fn legal_location_edge(from: &RunLocation, to: &RunLocation) -> bool { match (from, to) { (RunLocation::Town(_), RunLocation::Deep(1)) | (RunLocation::Deep(1), RunLocation::Town(_)) => true, (RunLocation::Deep(from), RunLocation::Deep(to)) => from.abs_diff(*to) == 1, _ => false, }}
fn destination_location(destination: PlanDestination) -> PlanLocation { match destination { PlanDestination::Town => PlanLocation::Town, PlanDestination::Deep { depth } => PlanLocation::Deep(depth), }}
fn plan_location(location: &RunLocation) -> Option<PlanLocation> { match location { RunLocation::Uninitialized => None, RunLocation::Town(town) if town == MILTHOLLOW => Some(PlanLocation::Town), RunLocation::Town(_) => None, RunLocation::Deep(depth) => Some(PlanLocation::Deep(*depth)), }}
fn job_has_shop_memory(job: Job) -> bool { matches!( job, Job::Tinker | Job::Lampwright | Job::Mender | Job::Fence | Job::Auctioneer | Job::Innkeep | Job::CorpseTender )}
fn transfer_session_error(error: TransferError) -> SessionError { match error { TransferError::GroundItemMissing => { SessionError::rejected("pickup-target-missing", "selected ground item is gone") } TransferError::GroundItemElsewhere => SessionError::rejected( "pickup-target-elsewhere", "selected ground item is no longer at the player's cell", ), TransferError::CarriedItemMissing => { SessionError::rejected("drop-item-missing", "selected carried item is gone") } TransferError::Stuck => SessionError::rejected("drop-item-stuck", "selected item sticks"), TransferError::InvalidRuntime(source) => { SessionError::runtime("transfer-runtime-invalid", format!("{source:?}")) } TransferError::UnknownActor | TransferError::DeadActor | TransferError::MissingPack | TransferError::InvalidActorPosition => { SessionError::runtime("transfer-runtime-invalid", format!("{error:?}")) } }}
fn transfer_structural_error(error: PersistenceError) -> SessionError { let code = if matches!(error, PersistenceError::Limit { .. }) { "transfer-limit" } else { "transfer-preflight" }; SessionError::runtime(code, error.to_string())}
fn bounded_utf8_prefix(input: &str, limit: usize) -> String { let mut end = input.len().min(limit); while !input.is_char_boundary(end) { end -= 1; } input[..end].to_owned()}
/// Compass-to-canonical-heading bins. Bins are 1024 per quadrant; the explicit/// table keeps game movement independent of the legacy voxel delta convention.fn heading_for(direction: &Direction) -> Heading { match direction { Direction::North => Heading::NORTH, Direction::NorthEast => Heading::new(512).expect("bounded NE heading"), Direction::East => Heading::EAST, Direction::SouthEast => Heading::new(1536).expect("bounded SE heading"), Direction::South => Heading::SOUTH, Direction::SouthWest => Heading::new(2560).expect("bounded SW heading"), Direction::West => Heading::WEST, Direction::NorthWest => Heading::new(3584).expect("bounded NW heading"), }}
fn checked_actor_cell(position: FixedVec3) -> Option<Cell> { Some(position.cell())}
fn tuple(cell: Cell) -> (i32, i32, i32) { (cell[0], cell[1], cell[2])}
fn cell_key(cell: Cell) -> (i32, i32, i32) { tuple(cell)}
fn checked_cell_pos(cell: Cell) -> Result<FixedVec3, SessionError> { FixedVec3::from_cell_feet(cell).map_err(|error| SessionError::content("actor-position", error.to_string()))}#[cfg(any(test, feature = "test-support"))]fn cell_pos(cell: Cell) -> FixedVec3 { FixedVec3::from_cell_feet(cell).expect("test cell is within canonical bounds")}
fn actor_cell(state: &State, actor_id: u64) -> Option<Cell> { state .actors .iter() .find(|actor| actor.id == actor_id) .map(|actor| actor.position.cell())}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]enum MeleeGeometry { Adjacent, Extended,}
fn melee_geometry( state: &State, catalog: &RuntimeCatalog, attacker_id: u64, target_id: u64,) -> Result<MeleeGeometry, MeleeGeometryError> { let attacker = state .actors .iter() .find(|actor| actor.id == attacker_id) .ok_or(MeleeGeometryError::AttackerMissing)?; if !attacker.alive || attacker.hp <= 0 { return Err(MeleeGeometryError::AttackerDead); } let target = state .actors .iter() .find(|actor| actor.id == target_id) .ok_or(MeleeGeometryError::TargetMissing)?; let target_identity = state .identity(target_id) .ok_or(MeleeGeometryError::TargetIdentityMissing)?; if target_identity.role != ActorRole::Monster { return Err(MeleeGeometryError::TargetNotHostile); } if !target.alive || target.hp <= 0 { return Err(MeleeGeometryError::TargetDead); } let active = plan_location(state.run_location()).ok_or(MeleeGeometryError::LocationUninitialized)?; let location = catalog .location(active) .ok_or(MeleeGeometryError::LocationUninitialized)?; let attacker_cell = checked_actor_cell(attacker.position).ok_or(MeleeGeometryError::WrongLocation)?; let target_cell = checked_actor_cell(target.position).ok_or(MeleeGeometryError::WrongLocation)?; if !location.bounds.contains(attacker_cell) || !location.bounds.contains(target_cell) { return Err(MeleeGeometryError::WrongLocation); } for actor in [attacker,target] { let top = actor.position.checked_translate([0,0,actor.body.millimetres()[2]-1]).map_err(|_| MeleeGeometryError::WrongLocation)?; if !location.bounds.contains(top.cell()) { return Err(MeleeGeometryError::WrongLocation); } } let from = attacker.position.checked_translate([0,0,500]).map_err(|_| MeleeGeometryError::WrongLocation)?; let to = target.position.checked_translate([0,0,500]).map_err(|_| MeleeGeometryError::WrongLocation)?; if state.actors.iter().any(|actor| actor.alive && actor.id != attacker_id && actor.id != target_id && crate::geometry::segment_hits_body(from,to,actor.position,actor.body)) { return Err(MeleeGeometryError::CornerBlocked); } let reach_mm = if state.packs.get(&attacker_id).is_some_and(|pack| pack.reach_bonus(false) > 0) { 2000 } else { 1000 }; if !crate::geometry::same_supported_floor(&state.grid, attacker.position, target.position) { return Err(MeleeGeometryError::OutOfRange); } let gap = crate::geometry::surface_gap_squared(attacker.position, attacker.body, target.position, target.body).ok_or(MeleeGeometryError::OutOfRange)?; if gap > i128::from(reach_mm) * i128::from(reach_mm) { return Err(MeleeGeometryError::OutOfRange); } if !crate::geometry::melee(&state.grid, attacker.position, attacker.body, target.position, target.body, reach_mm) { return Err(MeleeGeometryError::CornerBlocked); } Ok(if gap <= 1000_i128 * 1000 { MeleeGeometry::Adjacent } else { MeleeGeometry::Extended })}
fn melee_geometry_session_error( error: MeleeGeometryError, attacker_id: u64, target_id: u64,) -> SessionError { match error { MeleeGeometryError::AttackerMissing => { SessionError::content("player-missing", attacker_id.to_string()) } MeleeGeometryError::AttackerDead => { SessionError::content("player-dead", attacker_id.to_string()) } MeleeGeometryError::TargetMissing => { SessionError::content("attack-target-missing", target_id.to_string()) } MeleeGeometryError::TargetIdentityMissing => { SessionError::content("attack-target-identity-missing", target_id.to_string()) } MeleeGeometryError::TargetNotHostile => { SessionError::content("attack-target-not-monster", target_id.to_string()) } MeleeGeometryError::TargetDead => { SessionError::content("attack-target-dead", target_id.to_string()) } MeleeGeometryError::LocationUninitialized => { SessionError::content("location-uninitialized", "no active plan") } MeleeGeometryError::WrongLocation => { SessionError::content("attack-target-wrong-location", target_id.to_string()) } MeleeGeometryError::OutOfRange => { SessionError::content("attack-target-out-of-range", target_id.to_string()) } MeleeGeometryError::CornerBlocked => { SessionError::content("attack-target-corner-blocked", target_id.to_string()) } }}
#[cfg(test)]mod tests { use super::*; use hexwork_core::character::{self, Ancestry, Attribute, Calling, Skill}; use hexwork_core::item::Condition; use hexwork_core::runtime::ContractStatus; use hexwork_save::{AuctionLot, LotId}; use hexwork_worldgen::TransitionDirection; use std::collections::{BTreeSet, VecDeque}; use std::path::PathBuf; use std::sync::OnceLock;
fn content_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") }
fn setup(calling: Calling) -> PlayerSetup { PlayerSetup::new("Ada", character::new(Ancestry::Mudborn, calling)) }
fn identity( pack: &RuntimePack, seed: u64, mode: Mode, calling: Calling, initial_lineage: InitialLineage, ) -> RunIdentity { RunIdentity { seed, mode, ancestry: Ancestry::Mudborn, calling, display_name: "Ada".to_owned(), content_hash: pack.hash().to_owned(), lineage_id: LineageId::from_bytes([0x51; 16]), run_id: RunId::new(7).unwrap(), initial_lineage, } }
fn session_from_lineage(seed: u64, mode: Mode, initial: InitialLineage) -> GameSession { let pack = RuntimePack::load(content_root()).unwrap(); let identity = identity(&pack, seed, mode, Calling::Lampbearer, initial); GameSession::from_identity(pack, identity).unwrap() }
fn session(seed: u64) -> GameSession { session_from_lineage(seed, Mode::Classic, InitialLineage::default()) }
fn clone_session(source: &GameSession) -> GameSession { GameSession { state: State::from_image(source.state.to_image().unwrap()).unwrap(), pack: source.pack.clone(), catalog: source.catalog.clone(), identity: source.identity.clone(), events: source.events.clone(), story_checkpoint: source.story_checkpoint.clone(), checkpoint_witness: RefCell::new(source.checkpoint_witness.borrow().clone()), replay_evidence: source.replay_evidence.clone(), frozen_fault: source.frozen_fault.clone(), } }
#[derive(Clone, Debug, PartialEq, Eq)] struct AtomicSnapshot { canonical_hash: u64, diagnostic_hash: u64, tick: u64, committed_history: Vec<SessionEvent>, }
fn atomic_snapshot(game: &GameSession) -> AtomicSnapshot { AtomicSnapshot { canonical_hash: game.canonical_hash().unwrap(), diagnostic_hash: game.diagnostic_hash(), tick: game.tick(), committed_history: game.events().to_vec(), } }
fn assert_atomic_error( game: &mut GameSession, operation: impl FnOnce(&mut GameSession) -> Result<(), SessionError>, class: SessionErrorClass, code: &'static str, detail: &str, ) { let before = atomic_snapshot(game); let error = operation(game).unwrap_err(); assert_eq!(error.class, class); assert_eq!(error.code, code); assert_eq!(error.detail, detail); assert_eq!(atomic_snapshot(game), before); }
fn assert_atomic_dispatch( game: &mut GameSession, action: CommittedAction, class: SessionErrorClass, code: &'static str, detail: &str, ) { assert_atomic_error( game, |game| game.dispatch(action).map(|_| ()), class, code, detail, ); }
fn assert_atomic_core_error<E: fmt::Debug + PartialEq>( game: &mut GameSession, operation: impl FnOnce(&mut GameSession) -> Result<(), E>, expected_source: E, adapter: impl FnOnce(E) -> SessionError, class: SessionErrorClass, code: &'static str, detail: &str, ) { let before = atomic_snapshot(game); let source = operation(game).unwrap_err(); assert_eq!(source, expected_source); let error = adapter(source); assert_eq!(error.class, class); assert_eq!(error.code, code); assert_eq!(error.detail, detail); assert_eq!(atomic_snapshot(game), before); }
/// Canonical cell delta to compass direction, matching `heading_for` and the /// core compass table: North is -Y, East is +X (see /// `heading_bins_match_core_table_compass`). fn direction(from: Cell, to: Cell) -> Direction { match (to[0] - from[0], to[1] - from[1]) { (0, -1) => Direction::North, (0, 1) => Direction::South, (1, 0) => Direction::East, (-1, 0) => Direction::West, (1, -1) => Direction::NorthEast, (1, 1) => Direction::SouthEast, (-1, 1) => Direction::SouthWest, (-1, -1) => Direction::NorthWest, delta => panic!("not a movement delta: {delta:?}"), } }
/// Test-route clearance that matches core solidity. Core decides walkability /// from `material != AIR`, so the BFS must read material, not `occ`: body and /// head must be AIR, the cell below must be solid non-AIR, and the body may be /// no deeper than the movement policy allows. This only seeds a cell-accurate /// BFS; it is never an independent collision authority. fn route_cell_clear(session: &GameSession, cell: Cell) -> bool { let body = session.state.grid.get_world(cell[0], cell[1], cell[2]); let head = session.state.grid.get_world(cell[0], cell[1], cell[2] + 1); let below = session.state.grid.get_world(cell[0], cell[1], cell[2] - 1); body.material == hexwork_core::voxel::material::AIR && head.material == hexwork_core::voxel::material::AIR && below.material != hexwork_core::voxel::material::AIR && body.fluid_amt <= MovementPolicy::default().max_water_depth }
/// Core movement admits one 3000 mm/s sample per tick and steps exactly one /// tick, so a single Move advances 300 mm — several Moves are required to cross /// a 1000 mm cell. These route helpers therefore move the player cell by cell /// and re-plan after every step, because live actors walk each tick and can /// block a previously open corridor. Iteration is bounded so an unreachable /// goal fails loudly instead of spinning forever. /// /// Interaction radius shared with `geometry::access`: a player within 750 mm /// (horizontally) of a cell centre can use its interaction or transition. A /// posted actor can occupy an access cell outright, so the final goal is /// satisfied by being inside this radius, which is what route callers assert. const ROUTE_ARRIVAL_MM: i64 = 750;
fn player_within_cell_range(session: &GameSession, target: Cell) -> bool { let player = session.player_id().unwrap(); let Some(actor) = session.state.actors.iter().find(|actor| actor.id == player) else { return false; }; let p = actor.position.millimetres(); let dx = p[0] - (i64::from(target[0]) * 1000 + 500); let dy = p[1] - (i64::from(target[1]) * 1000 + 500); dx * dx + dy * dy <= ROUTE_ARRIVAL_MM * ROUTE_ARRIVAL_MM }
/// BFS over walkable cells from `start` to `goal`, avoiding live actor cells. /// The goal cell itself may be held by a posted actor, so it is exempt from the /// blocker test; the caller approaches it and stops within interaction range. /// `None` means no route exists right now, so the caller spends a tick (letting /// walkers clear a cell) and re-plans. fn route_path(session: &GameSession, start: Cell, goal: Cell) -> Option<Vec<Cell>> { let blockers: BTreeSet<_> = session .state .actors .iter() .filter(|actor| actor.id != session.player_id().unwrap() && actor.alive) .map(|actor| actor.position.cell()) .collect(); let mut queue = VecDeque::from([start]); let mut previous: BTreeMap<Cell, Cell> = BTreeMap::new(); let mut seen = BTreeSet::from([start]); while let Some(cell) = queue.pop_front() { for (dx, dy) in [(0, 1), (-1, 0), (1, 0), (0, -1)] { let next = [cell[0] + dx, cell[1] + dy, cell[2]]; if seen.contains(&next) || (blockers.contains(&next) && next != goal) { continue; } if !route_cell_clear(session, next) { continue; } previous.insert(next, cell); if next == goal { let mut path = vec![goal]; let mut cursor = goal; while cursor != start { cursor = previous[&cursor]; path.push(cursor); } path.reverse(); return Some(path); } seen.insert(next); queue.push_back(next); } } None }
/// Dispatch one Move and report whether the player's position actually changed. /// Core may block the sample (a spilled AABB corner), so a blocked Move is a /// legal non-progress outcome, not an error. fn try_move(session: &mut GameSession, direction: Direction) -> bool { let player = session.player_id().unwrap(); let before = session .state .actors .iter() .find(|a| a.id == player) .unwrap() .position .millimetres(); session .dispatch(CommittedAction::Move(direction)) .unwrap(); let after = session .state .actors .iter() .find(|a| a.id == player) .unwrap() .position .millimetres(); before != after }
fn opposite(direction: &Direction) -> Direction { match direction { Direction::North => Direction::South, Direction::South => Direction::North, Direction::East => Direction::West, Direction::West => Direction::East, Direction::NorthEast => Direction::SouthWest, Direction::SouthWest => Direction::NorthEast, Direction::NorthWest => Direction::SouthEast, Direction::SouthEast => Direction::NorthWest, } }
/// Direction preference for a greedy, collision-aware walk: the BFS-preferred /// cardinal step first, then the perpendicular un-wedge steps ordered by /// progress toward the goal, then the reverse. The player's 600 mm AABB can /// spill into a diagonally adjacent solid cell that a cell-level BFS never /// sees; biasing the sidestep toward the goal stops the walk oscillating. fn move_order(preferred: Option<Direction>, from: Cell, goal: Cell) -> [Direction; 4] { let toward_x = if goal[0] >= from[0] { Direction::East } else { Direction::West }; let toward_y = if goal[1] >= from[1] { Direction::South } else { Direction::North }; let away_x = opposite(&toward_x); let away_y = opposite(&toward_y); match preferred { Some(d) => { let reverse = opposite(&d); match d { Direction::East | Direction::West => { [d.clone(), toward_y.clone(), away_y.clone(), reverse] } _ => [d.clone(), toward_x.clone(), away_x.clone(), reverse], } } None => [toward_y, toward_x, away_x, away_y], } }
/// When a planned step is refused by an AABB corner, nudge the player back /// toward its cell centre on the axis perpendicular to the step. This clears /// the footprint spill so the step succeeds on the next tick instead of the /// walk oscillating against the corner. fn recenter_direction( session: &GameSession, cell: Cell, preferred: Option<Direction>, ) -> Option<Direction> { let player = session.player_id().unwrap(); let pos = session .state .actors .iter() .find(|actor| actor.id == player)? .position .millimetres(); let center_x = i64::from(cell[0]) * 1000 + 500; let center_y = i64::from(cell[1]) * 1000 + 500; let (axis, center, low, high) = match preferred { Some(Direction::North) | Some(Direction::South) => { (pos[0], center_x, Direction::West, Direction::East) } Some(Direction::East) | Some(Direction::West) => { (pos[1], center_y, Direction::North, Direction::South) } _ => (pos[0], center_x, Direction::West, Direction::East), }; if axis > center { Some(low) } else if axis < center { Some(high) } else { None } }
pub(super) fn route(session: &mut GameSession, goal: Cell) { // Re-plan every tick and take the best step that actually moves: live // actors walk each tick, and the player's footprint can wedge on a corner. // Bounded so an unreachable goal fails loudly instead of spinning forever. const MAX_ATTEMPTS: usize = 8192; let mut stuck = 0usize; let mut path: Vec<Cell> = Vec::new(); for _ in 0..MAX_ATTEMPTS { let start = session.player_cell().unwrap(); if start == goal || player_within_cell_range(session, goal) { return; } // Cache the BFS path and re-plan only on deviation or a blocked step; // a full re-plan every tick is wasteful on long routes. if path.len() < 2 || path[0] != start { path = route_path(session, start, goal).unwrap_or_default(); } let preferred = (path.len() >= 2).then(|| direction(start, path[1])); let mut moved = false; if let Some(dir) = preferred.clone() { moved = try_move(session, dir); } if !moved { if let Some(dir) = recenter_direction(session, start, preferred.clone()) { moved = try_move(session, dir); } } if !moved { for dir in move_order(preferred, start, goal) { if try_move(session, dir) { moved = true; break; } } } if moved { stuck = 0; // Keep following the cached path while it still tracks the player. let now = session.player_cell().unwrap(); if path.get(1) == Some(&now) { path.remove(0); } else { path.clear(); } } else { stuck += 1; if stuck >= 512 { panic!("no production movement route to {goal:?} from {start:?} (wedged)"); } path.clear(); // A blocked sample still spends its tick, so waiting lets moving // actors clear the cell before the next re-plan. session.dispatch(CommittedAction::Wait).unwrap(); } } panic!( "no production movement route to {goal:?} from {:?}", session.player_cell().unwrap() ); }
fn drive( controller: &mut PlayController, session: &mut GameSession, input: Input, ) -> Result<Option<ActionOutcome>, SessionError> { match controller.adapt(input, session) { ControllerEvent::Dispatch { action, source } => { let result = session.dispatch(action); let _ = reconcile_dispatch(controller, &source, &result); result.map(Some) } ControllerEvent::BoardOpenRequested { inspect, .. } => { let result = session.dispatch(inspect); if result.is_ok() { controller.board_open_succeeded(); } result.map(Some) } ControllerEvent::OpenTinkerIdentify | ControllerEvent::BoardSelectionChanged(_) | ControllerEvent::BoardClosed | ControllerEvent::Control(_) | ControllerEvent::None => Ok(None), } }
fn input_for(direction: Direction) -> Input { match direction { Direction::North => Input::Up, Direction::South => Input::Down, Direction::West => Input::Left, Direction::East => Input::Right, Direction::NorthWest => Input::UpLeft, Direction::NorthEast => Input::UpRight, Direction::SouthWest => Input::DownLeft, Direction::SouthEast => Input::DownRight, } }
fn route_inputs(controller: &mut PlayController, session: &mut GameSession, goal: Cell) { // Mirror `route`: cache the BFS path, and step the preferred direction, // else a recenter nudge, else the goal-biased sidestep order. Bounded so an // unreachable goal fails loudly. const MAX_ATTEMPTS: usize = 8192; let mut stuck = 0usize; let mut path: Vec<Cell> = Vec::new(); for _ in 0..MAX_ATTEMPTS { let start = session.player_cell().unwrap(); if start == goal || player_within_cell_range(session, goal) { return; } if path.len() < 2 || path[0] != start { path = route_path(session, start, goal).unwrap_or_default(); } let preferred = (path.len() >= 2).then(|| direction(start, path[1])); let mut moved = false; if let Some(dir) = preferred.clone() { moved = try_drive(controller, session, input_for(dir)); } if !moved { if let Some(dir) = recenter_direction(session, start, preferred.clone()) { moved = try_drive(controller, session, input_for(dir)); } } if !moved { for dir in move_order(preferred, start, goal) { if try_drive(controller, session, input_for(dir)) { moved = true; break; } } } if moved { stuck = 0; let now = session.player_cell().unwrap(); if path.get(1) == Some(&now) { path.remove(0); } else { path.clear(); } } else { stuck += 1; if stuck >= 512 { panic!("no controller movement route to {goal:?} from {start:?} (wedged)"); } path.clear(); drive(controller, session, Input::Wait).unwrap(); } } panic!( "no controller movement route to {goal:?} from {:?}", session.player_cell().unwrap() ); }
fn try_drive(controller: &mut PlayController, session: &mut GameSession, input: Input) -> bool { let player = session.player_id().unwrap(); let position = |session: &GameSession| { session .state .actors .iter() .find(|a| a.id == player) .unwrap() .position .millimetres() }; let before = position(session); drive(controller, session, input).unwrap(); position(session) != before }
fn adjacent_access(session: &GameSession, target: Cell) -> Cell { let player = session.player_id().unwrap(); [(0, 1), (-1, 0), (1, 0), (0, -1)] .into_iter() .map(|(dx, dy)| [target[0] + dx, target[1] + dy, target[2]]) .find(|cell| { session .check_passable(*cell, player, "attack-access") .is_ok() }) .expect("monster has adjacent access") }
#[test] fn tactical_context_matches_generated_catalog_bounds() { let mut game = session(223); let town = milthollow(223); let context = game.tactical_context().unwrap(); assert_eq!(context.bounds, town.bounds); assert_eq!(context.player_cell, town.start); // Synthetic placements in actual generated catalogs; real traversal is tested separately. for floor in playable_deep(223) { game.place_tactical_catalog_fixture(floor.depth); let context = game.tactical_context().unwrap(); assert_eq!(context.location, RunLocation::Deep(floor.depth)); assert_eq!(context.bounds, floor.bounds); let planes: BTreeSet<_> = floor.connectors.iter().map(|c| c.access[2]).collect(); assert!(planes.len() >= 2, "main plane and raised gallery"); for connector in &floor.connectors { assert!(context.bounds.contains(connector.access)); } } }
#[test] fn tactical_context_follows_real_two_floor_route() { let mut game = session(223); assert_eq!(game.tactical_context().unwrap().player_cell[2], 8); for depth in 1..=2 { descend_to(&mut game, depth); let context = game.tactical_context().unwrap(); assert_eq!(context.location, RunLocation::Deep(depth)); assert_eq!( context.bounds, playable_deep(223)[usize::from(depth - 1)].bounds ); assert_eq!(context.player_cell, game.player_cell().unwrap()); assert_eq!(context.player_cell[2], 1 - 10 * i32::from(depth)); } assert!(!game.events.is_empty()); }
#[test] fn tactical_context_rejects_invalid_invariants_without_mutation() { for case in 0..6 { let mut game = session(223); let player = game.player_id().unwrap(); match case { 0 => game.state.actors.retain(|a| a.id != player), 1 => game .state .restore_run_location(RunLocation::Uninitialized) .unwrap(), 2 => { game.catalog.locations.clear(); } 3..=5 => { game.state .actors .iter_mut() .find(|a| a.id == player) .unwrap() .position = FixedVec3::new(match case { 3 => [hexwork_core::spatial::MIN_POSITION_MM, 500, 8000], 4 => [hexwork_core::spatial::MAX_POSITION_MM, 500, 8000], _ => [500, 500, -1_000_000], }).unwrap(); } _ => unreachable!(), } let before = game.state.diagnostic_hash(); let events = game.events.clone(); let error = game.tactical_context().unwrap_err(); assert_eq!(error.class, SessionErrorClass::Runtime); assert_eq!(game.state.diagnostic_hash(), before); assert_eq!(game.events, events); } }
fn descend_to(session: &mut GameSession, depth: u8) { while session.state.run_location() != &RunLocation::Deep(depth) { let current = plan_location(session.state.run_location()).unwrap(); let next_depth = match current { PlanLocation::Town => 1, PlanLocation::Deep(current_depth) => current_depth + 1, }; let connector = session .connectors_for(current) .iter() .find(|connector| { connector.destination == PlanDestination::Deep { depth: next_depth } }) .unwrap() .clone(); route(session, connector.access); session .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap(); } }
fn return_to_town(session: &mut GameSession) { while !matches!(session.state.run_location(), RunLocation::Town(_)) { let current = plan_location(session.state.run_location()).unwrap(); let destination = match current { PlanLocation::Town => unreachable!(), PlanLocation::Deep(1) => PlanDestination::Town, PlanLocation::Deep(depth) => PlanDestination::Deep { depth: depth - 1 }, }; let connector = session .connectors_for(current) .iter() .find(|connector| connector.destination == destination) .unwrap() .clone(); route(session, connector.access); session .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap(); } }
fn build_canonical_dead_story() -> GameSession { let mut game = GameSession::load_with_mode( content_root(), 223, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut game, 2); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "grate_mother") }) .unwrap() .id; let access = adjacent_access(&game, actor_cell(&game.state, target).unwrap()); route(&mut game, access); for _ in 0..4 { game.dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); } game }
fn build_alternate_dead_story() -> GameSession { let mut game = GameSession::load_with_mode( content_root(), 223, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut game, 1); let rat = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") }) .unwrap() .id; let rat_access = adjacent_access(&game, actor_cell(&game.state, rat).unwrap()); route(&mut game, rat_access); game.dispatch(CommittedAction::ResolveAttack { target_actor: rat }) .unwrap(); descend_to(&mut game, 2); let mother = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "grate_mother") }) .unwrap() .id; let mother_access = adjacent_access(&game, actor_cell(&game.state, mother).unwrap()); route(&mut game, mother_access); for _ in 0..3 { game.dispatch(CommittedAction::ResolveAttack { target_actor: mother, }) .unwrap(); } assert!( !game .state .actors .iter() .find(|actor| actor.id == game.player_id().unwrap()) .unwrap() .alive ); game }
fn canonical_dead_story_replay() -> &'static [u8] { static DEAD_REPLAY: OnceLock<Vec<u8>> = OnceLock::new(); DEAD_REPLAY.get_or_init(|| build_canonical_dead_story().encode_replay().unwrap()) }
fn kill_player_at_grate_mother(game: &mut GameSession) { descend_to(game, 2); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "grate_mother") }) .unwrap() .id; let access = adjacent_access(game, actor_cell(&game.state, target).unwrap()); route(game, access); for _ in 0..4 { game.dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); } assert!( !game .state .actors .iter() .find(|actor| actor.id == game.player_id().unwrap()) .unwrap() .alive ); }
fn canonical_dead_story_run() -> &'static [u8] { static DEAD_RUN: OnceLock<Vec<u8>> = OnceLock::new(); DEAD_RUN.get_or_init(|| { let game = build_canonical_dead_story(); let current = game.state.to_image().unwrap(); let checkpoint = &game.story_checkpoint.as_ref().unwrap().state.image; eprintln!( "Story Deep dual-image aggregates: current chunks={} actors={} items={} causes={}; checkpoint chunks={} actors={} items={} causes={}", current.chunks.len(), current.actors.len(), current.items.len(), current.causes.len(), checkpoint.chunks.len(), checkpoint.actors.len(), checkpoint.items.len(), checkpoint.causes.len(), ); game.encode_current_run().unwrap() }) }
fn interaction_access( session: &GameSession, location: PlanLocation, predicate: impl Fn(&InteractionKind) -> bool, ) -> Cell { session .location_catalog(location) .expect("location catalog exists") .interactions .iter() .find_map(|(access, entries)| { entries .iter() .any(|entry| predicate(&entry.kind)) .then_some([access.0, access.1, access.2]) }) .expect("interaction exists") }
fn prepared_town_connector(seed: u64) -> (GameSession, ConnectorPlan, ConnectorPlan) { let mut game = session(seed); let connector = game.first_connector(PlanLocation::Town).clone(); let paired = game .floor_catalog(1) .connectors .iter() .find(|candidate| candidate.transition_id == connector.paired_transition_id) .unwrap() .clone(); route(&mut game, connector.access); (game, connector, paired) }
fn prepared_passage_move(seed: u64) -> (GameSession, Cell, Direction) { let mut game = session(seed); let player = game.player_id().unwrap(); let from = [40, 40, 7]; let destination = [41, 40, 7]; game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(from); for actor in &mut game.state.actors { if actor.id != player { actor.alive = false; } } for cell in [from, destination] { game.state .grid .set_world(cell[0], cell[1], cell[2], Voxel::default()); game.state .grid .set_world(cell[0], cell[1], cell[2] + 1, Voxel::default()); game.state.grid.set_world( cell[0], cell[1], cell[2] - 1, Voxel { // Core support is `material != AIR`. material: hexwork_core::voxel::material::STONE, occ: 1, ..Voxel::default() }, ); } game.sync_player_light().unwrap(); (game, destination, Direction::East) }
#[derive(Clone, Copy)] enum PassageFailure { Blocked, Unsupported, Flooded, }
fn install_passage_failure(game: &mut GameSession, cell: Cell, failure: PassageFailure) { match failure { PassageFailure::Blocked => { game.state.grid.set_world( cell[0], cell[1], cell[2], Voxel { occ: 1, ..Voxel::default() }, ); } PassageFailure::Unsupported => { game.state .grid .set_world(cell[0], cell[1], cell[2] - 1, Voxel::default()); } PassageFailure::Flooded => { let mut voxel = game.state.grid.get_world(cell[0], cell[1], cell[2]); voxel.fluid_amt = MovementPolicy::default().max_water_depth + 1; game.state.grid.set_world(cell[0], cell[1], cell[2], voxel); } } }
/// Core-authority variant of [`install_passage_failure`]: core reads solidity /// from `material`, so a blocking wall needs a real material and deep water /// needs the WATER kind. Used where the *core* sweep (not the session's /// occ-based `check_passable`) must refuse the sample. fn install_core_passage_failure(game: &mut GameSession, cell: Cell, failure: PassageFailure) { match failure { PassageFailure::Blocked => { game.state.grid.set_world( cell[0], cell[1], cell[2], Voxel { material: hexwork_core::voxel::material::STONE, occ: 1, ..Voxel::default() }, ); } PassageFailure::Unsupported => { game.state .grid .set_world(cell[0], cell[1], cell[2] - 1, Voxel::default()); } PassageFailure::Flooded => { let mut voxel = game.state.grid.get_world(cell[0], cell[1], cell[2]); voxel.fluid_kind = hexwork_core::fluids::fluid::WATER; voxel.fluid_amt = MovementPolicy::default().max_water_depth + 1; game.state.grid.set_world(cell[0], cell[1], cell[2], voxel); } } }
fn completed_contract_session(seed: u64, id: &str, pay: u32, standing: i16) -> GameSession { let mut game = session(seed); let player = game.player_id().unwrap(); game.state .accept_contract(id, player, 1, pay, 1, MILTHOLLOW, standing) .unwrap(); game.state .transition_location(RunLocation::Deep(1)) .unwrap(); game.state .advance_contract_objective(id, player, 1) .unwrap(); game.state .transition_location(RunLocation::Town(MILTHOLLOW.to_owned())) .unwrap(); game }
#[test] fn non_input_contexts_fatalize_rejection_class() { let rejected = SessionError::rejected("same-code", "inner detail"); let wrapped = rejected.with_context("proof-context", "wrapped detail"); assert_eq!(wrapped.class, SessionErrorClass::Content); assert_eq!(wrapped.code, "proof-context"); assert_eq!(wrapped.detail, "wrapped detail");
let content = SessionError::content("same-code", "content") .with_context("proof-context", "content wrapped"); let runtime = SessionError::runtime("same-code", "runtime") .with_context("proof-context", "runtime wrapped"); assert_eq!(content.class, SessionErrorClass::Content); assert_eq!(runtime.class, SessionErrorClass::Runtime); }
#[test] fn expected_rejections_are_tagged_at_atomic_origins() { // Real movement delegates collision, support and water to core: a blocked // sample is now a successful, tick-spending, journaled no-op, not a tagged // rejection. let (passage_baseline, destination, move_direction) = prepared_passage_move(201); for failure in [ PassageFailure::Blocked, PassageFailure::Unsupported, PassageFailure::Flooded, ] { let mut game = clone_session(&passage_baseline); install_core_passage_failure(&mut game, destination, failure); let before = atomic_snapshot(&game); assert!(matches!( game.dispatch(CommittedAction::Move(move_direction.clone())) .unwrap(), ActionOutcome::Moved { .. } )); assert_eq!( game.state.tick, before.tick + 1, "a blocked sample still spends exactly one tick" ); assert_eq!( game.journal().len(), before.committed_history.len() + 1, "the successful no-op Move is journaled" ); } let mut occupied = clone_session(&passage_baseline); let player = occupied.player_id().unwrap(); let blocker = occupied .state .actors .iter_mut() .find(|actor| actor.id != player) .unwrap(); blocker.alive = true; blocker.hp = 1; blocker.position = cell_pos(destination); let before = atomic_snapshot(&occupied); assert!(matches!( occupied .dispatch(CommittedAction::Move(move_direction)) .unwrap(), ActionOutcome::Moved { .. } )); assert_eq!(occupied.state.tick, before.tick + 1); assert_eq!(occupied.journal().len(), before.committed_history.len() + 1);
let (connector_baseline, connector, paired) = prepared_town_connector(202); for (context, cell) in [ ("transition-source", connector.access), ("transition-destination", paired.access), ] { // Blocked and flooded passages are tagged by check_passable with their // transition context. for (failure, code, detail) in [ ( PassageFailure::Blocked, "passage-blocked", format!("{context}: solid body or headroom at {cell:?}"), ), ( PassageFailure::Flooded, "passage-flooded", format!( "{context}: water depth {} exceeds {} at {cell:?}", MovementPolicy::default().max_water_depth + 1, MovementPolicy::default().max_water_depth ), ), ] { let mut game = clone_session(&connector_baseline); install_passage_failure(&mut game, cell, failure); assert_atomic_dispatch( &mut game, CommittedAction::UseTransition { transition_id: connector.transition_id, }, SessionErrorClass::InputRejected, code, &detail, ); } // An unsupported *source* is refused earlier by the support-aware // `geometry::access` (it requires a shared floor), so it surfaces as // out-of-range; the destination has no access() gate and reaches // check_passable. let mut unsupported = clone_session(&connector_baseline); install_passage_failure(&mut unsupported, cell, PassageFailure::Unsupported); if context == "transition-source" { assert_atomic_dispatch( &mut unsupported, CommittedAction::UseTransition { transition_id: connector.transition_id, }, SessionErrorClass::InputRejected, "transition-out-of-range", &format!("stand at {cell:?}"), ); } else { assert_atomic_dispatch( &mut unsupported, CommittedAction::UseTransition { transition_id: connector.transition_id, }, SessionErrorClass::InputRejected, "passage-unsupported", &format!("{context}: no solid support at {cell:?}"), ); } let mut game = clone_session(&connector_baseline); let player = game.player_id().unwrap(); let blocker = game .state .actors .iter_mut() .find(|actor| actor.id != player) .unwrap(); blocker.alive = true; blocker.hp = 1; blocker.position = cell_pos(cell); assert_atomic_dispatch( &mut game, CommittedAction::UseTransition { transition_id: connector.transition_id, }, SessionErrorClass::InputRejected, "passage-occupied", &format!("{context}: another actor occupies {cell:?}"), ); }
let mut planned_blocked = clone_session(&connector_baseline); planned_blocked .catalog .locations .get_mut(&PlanLocation::Town) .unwrap() .connectors .iter_mut() .find(|candidate| candidate.transition_id == connector.transition_id) .unwrap() .connector .blocked = true; assert_atomic_dispatch( &mut planned_blocked, CommittedAction::UseTransition { transition_id: connector.transition_id, }, SessionErrorClass::InputRejected, "transition-blocked", &connector.link_id.to_string(), );
let mut live_blocked = clone_session(&connector_baseline); live_blocked .state .connectors .iter_mut() .find(|live| live.id == connector.link_id) .unwrap() .blocked = true; assert_atomic_dispatch( &mut live_blocked, CommittedAction::UseTransition { transition_id: connector.transition_id, }, SessionErrorClass::InputRejected, "transition-blocked", &connector.link_id.to_string(), );
let mut out_of_range = session(203); let player_cell = out_of_range.player_cell().unwrap(); assert_atomic_dispatch( &mut out_of_range, CommittedAction::InspectBoard { town: MILTHOLLOW.to_owned(), }, SessionErrorClass::InputRejected, "interaction-out-of-range", &format!("no matching interaction at {player_cell:?}"), ); let transition = out_of_range.first_connector(PlanLocation::Town).clone(); assert_atomic_dispatch( &mut out_of_range, CommittedAction::UseTransition { transition_id: transition.transition_id, }, SessionErrorClass::InputRejected, "transition-out-of-range", &format!("stand at {:?}", transition.access), ); let player = out_of_range.player_id().unwrap(); assert_atomic_error( &mut out_of_range, |game| game.require_town_access(player, "copperwash", |_| true), SessionErrorClass::InputRejected, "wrong-town", "copperwash", );
let mut duplicate = session(204); let player = duplicate.player_id().unwrap(); duplicate .state .accept_contract("duplicate", player, 1, 1, 1, MILTHOLLOW, 0) .unwrap(); assert_atomic_core_error( &mut duplicate, |game| { game.state .accept_contract("duplicate", player, 1, 1, 1, MILTHOLLOW, 0) }, ContractError::DuplicateId, contract_accept_error, SessionErrorClass::InputRejected, "contract-rejected", "DuplicateId", );
let mut wrong_accept = session(205); let player = wrong_accept.player_id().unwrap(); wrong_accept .state .transition_location(RunLocation::Deep(1)) .unwrap(); assert_atomic_core_error( &mut wrong_accept, |game| { game.state .accept_contract("wrong-location", player, 1, 1, 1, MILTHOLLOW, 0) }, ContractError::WrongLocation, contract_accept_error, SessionErrorClass::InputRejected, "contract-rejected", "WrongLocation", );
let mut incomplete = session(206); let player = incomplete.player_id().unwrap(); incomplete .state .accept_contract("incomplete", player, 1, 1, 1, MILTHOLLOW, 0) .unwrap(); assert_atomic_core_error( &mut incomplete, |game| game.state.turn_in_contract("incomplete"), ContractError::NotCompleted, contract_turn_in_error, SessionErrorClass::InputRejected, "turn-in-rejected", "NotCompleted", );
let mut wrong_turn_in = completed_contract_session(207, "wrong-turn-in", 1, 0); wrong_turn_in .state .transition_location(RunLocation::Deep(1)) .unwrap(); assert_atomic_core_error( &mut wrong_turn_in, |game| game.state.turn_in_contract("wrong-turn-in"), ContractError::WrongLocation, contract_turn_in_error, SessionErrorClass::InputRejected, "turn-in-rejected", "WrongLocation", );
let mut poor = session(208); let player = poor.player_id().unwrap(); let balance = poor.state.wallet(player).unwrap().copper(); poor.state.spend_copper(player, balance).unwrap(); assert_atomic_core_error( &mut poor, |game| game.state.spend_copper_and_advance_unattended(player, 1, 1), PaidUnattendedError::Wallet(WalletError::InsufficientFunds), |source| inn_paid_time_error(source, 1), SessionErrorClass::InputRejected, "service-insufficient-funds", "need 1cu", );
let mut clock_overflow = session(209); let player = clock_overflow.player_id().unwrap(); clock_overflow.state.tick = u64::MAX; let before_canonical = clock_overflow.canonical_hash().unwrap_err(); let before_diagnostic = clock_overflow.diagnostic_hash(); let before_tick = clock_overflow.tick(); let before_history = clock_overflow.events().to_vec(); let source = clock_overflow .state .spend_copper_and_advance_unattended(player, 0, 1) .unwrap_err(); assert_eq!( source, PaidUnattendedError::Clock(hexwork_core::state::UnattendedAdvanceError::TickOverflow) ); let error = inn_paid_time_error(source, 0); assert_eq!(error.class, SessionErrorClass::Runtime); assert_eq!(error.code, "service-clock-overflow"); assert_eq!(error.detail, "TickOverflow"); assert_eq!( clock_overflow.canonical_hash().unwrap_err(), before_canonical ); assert_eq!(clock_overflow.diagnostic_hash(), before_diagnostic); assert_eq!(clock_overflow.tick(), before_tick); assert_eq!(clock_overflow.events(), before_history);
let mut blank = session(210); let player = blank.player_id().unwrap(); assert_atomic_core_error( &mut blank, |game| { game.state .accept_contract(" ", player, 1, 1, 1, MILTHOLLOW, 0) }, ContractError::BlankId, contract_accept_error, SessionErrorClass::Content, "contract-rejected", "BlankId", );
let mut missing_reputation = completed_contract_session(211, "missing-reputation", 1, 0); let player = missing_reputation.player_id().unwrap(); let mut image = missing_reputation.state.to_image().unwrap(); image.reputations.remove(&player); missing_reputation.state = State::from_image(image).unwrap(); assert_atomic_core_error( &mut missing_reputation, |game| game.state.turn_in_contract("missing-reputation"), ContractError::MissingReputation, contract_turn_in_error, SessionErrorClass::Content, "turn-in-rejected", "MissingReputation", );
let mut payout_overflow = completed_contract_session(212, "payout-overflow", 1, 0); let player = payout_overflow.player_id().unwrap(); let balance = payout_overflow.state.wallet(player).unwrap().copper(); payout_overflow.state.spend_copper(player, balance).unwrap(); payout_overflow.state.earn_copper(player, u32::MAX).unwrap(); assert_atomic_core_error( &mut payout_overflow, |game| game.state.turn_in_contract("payout-overflow"), ContractError::PayoutOverflow, contract_turn_in_error, SessionErrorClass::Content, "turn-in-rejected", "PayoutOverflow", );
let mut standing_overflow = completed_contract_session(213, "standing-overflow", 0, 1); let player = standing_overflow.player_id().unwrap(); standing_overflow .state .reputations .get_mut(&player) .unwrap() .town_ledger .insert(MILTHOLLOW.to_owned(), i16::MAX); assert_atomic_core_error( &mut standing_overflow, |game| game.state.turn_in_contract("standing-overflow"), ContractError::StandingOverflow, contract_turn_in_error, SessionErrorClass::Content, "turn-in-rejected", "StandingOverflow", );
// MissingWallet is not constructible in a runtime-valid State: the public // operation's validate_runtime guard rejects the malformed image first. let guarded = session(214); let player = guarded.player_id().unwrap(); let mut image = guarded.state.to_image().unwrap(); image.wallets.remove(&player); assert!(matches!( State::from_image(image), Err(hexwork_core::checkpoint::StateImageError::Runtime( hexwork_core::runtime::RuntimeInvariantError::MissingWallet { actor_id } )) if actor_id == player )); let missing_wallet = contract_turn_in_error(ContractError::MissingWallet); assert_eq!(missing_wallet.class, SessionErrorClass::Content); assert_eq!(missing_wallet.code, "turn-in-rejected"); assert_eq!(missing_wallet.detail, "MissingWallet");
let (validation_baseline, validation_cell, _) = prepared_passage_move(215); for (failure, code) in [ (PassageFailure::Blocked, "passage-blocked"), (PassageFailure::Unsupported, "passage-unsupported"), (PassageFailure::Flooded, "passage-flooded"), ] { let mut game = clone_session(&validation_baseline); install_passage_failure(&mut game, validation_cell, failure); let player = game.player_id().unwrap(); assert_atomic_error( &mut game, |game| check_passable_state(&game.state, validation_cell, player, "validation"), SessionErrorClass::Content, code, &format!("validation: {validation_cell:?}"), ); } let mut validation_occupied = clone_session(&validation_baseline); let player = validation_occupied.player_id().unwrap(); let blocker = validation_occupied .state .actors .iter_mut() .find(|actor| actor.id != player) .unwrap(); blocker.alive = true; blocker.hp = 1; blocker.position = cell_pos(validation_cell); assert_atomic_error( &mut validation_occupied, |game| check_passable_state(&game.state, validation_cell, player, "validation"), SessionErrorClass::Content, "passage-occupied", &format!("validation: {validation_cell:?}"), );
let accept_cases = [ ( ContractError::DuplicateId, SessionErrorClass::InputRejected, "DuplicateId", ), ( ContractError::WrongLocation, SessionErrorClass::InputRejected, "WrongLocation", ), ( ContractError::BlankId, SessionErrorClass::Content, "BlankId", ), ( ContractError::PayoutOverflow, SessionErrorClass::Content, "PayoutOverflow", ), ]; for (source, class, detail) in accept_cases { let error = contract_accept_error(source); assert_eq!(error.class, class); assert_eq!(error.code, "contract-rejected"); assert_eq!(error.detail, detail); }
let turn_in_cases = [ ( ContractError::NotCompleted, SessionErrorClass::InputRejected, "NotCompleted", ), ( ContractError::WrongLocation, SessionErrorClass::InputRejected, "WrongLocation", ), ( ContractError::NotFound, SessionErrorClass::Content, "NotFound", ), ( ContractError::StandingOverflow, SessionErrorClass::Content, "StandingOverflow", ), ]; for (source, class, detail) in turn_in_cases { let error = contract_turn_in_error(source); assert_eq!(error.class, class); assert_eq!(error.code, "turn-in-rejected"); assert_eq!(error.detail, detail); }
let insufficient = inn_paid_time_error( PaidUnattendedError::Wallet(WalletError::InsufficientFunds), 8, ); assert_eq!(insufficient.class, SessionErrorClass::InputRejected); assert_eq!(insufficient.code, "service-insufficient-funds"); assert_eq!(insufficient.detail, "need 8cu");
let overflow = inn_paid_time_error(PaidUnattendedError::Wallet(WalletError::Overflow), 8); assert_eq!(overflow.class, SessionErrorClass::Runtime); assert_eq!(overflow.code, "service-wallet-overflow"); assert!(!overflow.detail.contains("funds"));
let clock = inn_paid_time_error( PaidUnattendedError::Clock(hexwork_core::state::UnattendedAdvanceError::TickOverflow), 8, ); assert_eq!(clock.class, SessionErrorClass::Runtime); assert_eq!(clock.code, "service-clock-overflow"); }
#[test] fn initialization_is_authoritative_deterministic_and_seeded() { let first = session(140); let second = session(140); let other = session(141); let town = first.location_catalog(PlanLocation::Town).unwrap(); assert_eq!( first.state.run_location(), &RunLocation::Town(MILTHOLLOW.into()) ); assert!(town.bounds.contains(first.player_cell().unwrap())); assert_eq!(first.state.grid.get_world(21, 31, 7).occ, 1); assert!(!first.state.npcs.is_empty()); assert!(first.state.npcs.keys().all(|id| first .state .identity(*id) .is_some_and(|identity| identity.template_id == TOWNSFOLK_TEMPLATE))); assert!(first.state.ecs.world().is_empty()); assert_eq!(first.state.validate_runtime(), Ok(())); assert!(first.state.try_hash_state().is_ok()); assert_eq!( first.state.try_hash_state().unwrap(), second.state.try_hash_state().unwrap() ); assert_ne!( first.state.try_hash_state().unwrap(), other.state.try_hash_state().unwrap() ); assert!(!first.state.items.is_empty()); assert_eq!(first.state.items.len(), first.state.ground_items.len()); assert_eq!(first.catalog.locations.len(), 13); for depth in 1..=12 { let floor = first.floor_catalog(depth); assert_eq!(floor.location, PlanLocation::Deep(depth)); assert!(!floor.connectors.is_empty()); }
let image = first.state.to_image().unwrap(); let raw_voxels: usize = image.chunks.iter().map(|chunk| chunk.voxels.len()).sum(); assert_eq!(raw_voxels, first.state.grid.loaded_count() * 48 * 48 * 48); let restore_started = std::time::Instant::now(); let mut restored = State::from_image(image).unwrap(); eprintln!( "production StateImage restore: {:?}", restore_started.elapsed() ); assert_eq!(restored.tick, first.state.tick); assert_eq!(restored.seed, first.state.seed); assert_eq!(restored.run_location(), first.state.run_location()); assert_eq!(restored.try_hash_state(), first.state.try_hash_state()); let mut original = first.state; for _ in 0..3 { original.step(); restored.step(); assert_eq!(restored.try_hash_state(), original.try_hash_state()); } }
#[test] fn initial_character_is_canonical_from_recorded_choices() { let pack = RuntimePack::load(content_root()).unwrap(); let identity = identity( &pack, 224, Mode::Story, Calling::Lampbearer, InitialLineage::default(), ); let game = GameSession::from_identity(pack, identity).unwrap(); let player = game.player_id().unwrap(); assert_eq!( game.state.characters[&player], character::new(Ancestry::Mudborn, Calling::Lampbearer) ); }
#[test] fn grown_character_with_training_and_use_pips_restores() { let mut game = session(225); let player = game.player_id().unwrap(); let original_identity = game.identity().clone(); let character = game.state.characters.get_mut(&player).unwrap(); character::train_attr(character, Attribute::Brawn); for _ in 0..11 { character::record_use(character, Skill::Climb); } let expected = *character; let bytes = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(restored.identity(), &original_identity); assert_eq!(restored.state.characters[&player], expected); }
#[test] fn invalid_character_ranges_and_pips_reject() { let game = session(226); let player = game.player_id().unwrap(); for (mutate, expected_code) in [ (0, "player-attribute-range"), (1, "player-skill-range"), (2, "player-skill-pips-range"), ] { let mut state = State::from_image(game.state.to_image().unwrap()).unwrap(); let character = state.characters.get_mut(&player).unwrap(); match mutate { 0 => character.attrs[Attribute::Brawn.index()] = 0, 1 => character.skills[Skill::Climb.index()] = 101, 2 => { let index = Skill::Climb.index(); let threshold = character::uses_for_next_point(character.skills[index]); character.uses[index] = threshold; character.pips[index] = threshold as u8; } _ => unreachable!(), } assert_eq!( validate_restored_state(&game.pack, &game.catalog, game.identity(), &state) .unwrap_err() .code, expected_code ); } }
#[test] fn mutated_connector_load_quality_blocked_restore() { let mut game = session(230); game.state.connectors[0].load_kg = 42.5; game.state.connectors[0].anchor_quality = 17; game.state.connectors[0].blocked = true; let bytes = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!( restored.state.connectors[0].load_kg.to_bits(), 42.5_f32.to_bits() ); assert_eq!(restored.state.connectors[0].anchor_quality, 17); assert!(restored.state.connectors[0].blocked); }
#[test] fn duplicate_or_changed_connector_provenance_rejects() { let base = session(231); for mutate in 0..2 { let mut image = base.state.to_image().unwrap(); if mutate == 0 { image.connectors.push(image.connectors[0].clone()); } else { image.connectors[0].from[0] += 1.0; } let state = State::from_image(image).unwrap(); let bytes = encode_run(base.identity(), &state, None, base.events()).unwrap(); let error = match GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic) { Err(error) => error, Ok(_) => panic!("changed connector provenance unexpectedly restored"), }; assert!(matches!( error.code, "connector-id-duplicate" | "connector-catalog-mismatch" )); } }
#[test] fn failed_preflight_rejects_unknown_template_before_state_publication() { let pack = RuntimePack::load(content_root()).unwrap(); let mut town = milthollow(4); let floors = playable_deep(4); town.items[0].template_id = "missing_item".into(); let error = validate_plan_content(&pack, &town, &floors).unwrap_err(); assert_eq!(error.code, "item-template-missing"); }
#[test] fn movement_delegates_collision_to_core_and_blocked_samples_still_spend_a_tick() { // Collision, support and water are core authority now. Every Move reports an // ordinary Moved outcome and spends exactly one tick, whether or not core // let the player enter the target cell. let mut game = session(140); let player = game.player_id().unwrap(); let cell = |game: &GameSession| { game.state .actors .iter() .find(|a| a.id == player) .unwrap() .position .cell() }; let spends_tick = |game: &mut GameSession, direction: Direction| { let tick = game.state.tick; assert!(matches!( game.dispatch(CommittedAction::Move(direction.clone())) .unwrap(), ActionOutcome::Moved { .. } )); assert_eq!( game.state.tick, tick + 1, "{direction:?} spends exactly one tick" ); };
// Deep water west of the spawn blocks the sample outright (no translation). let spawn = cell(&game); let deep = [spawn[0] - 1, spawn[1], spawn[2]]; let mut water = game.state.grid.get_world(deep[0], deep[1], deep[2]); water.fluid_kind = hexwork_core::fluids::fluid::WATER; water.fluid_amt = MovementPolicy::default().max_water_depth + 1; game.state.grid.set_world(deep[0], deep[1], deep[2], water); spends_tick(&mut game, Direction::West); assert_eq!(cell(&game), spawn, "deep water blocks entry");
// A solid east neighbour cell is never entered. let solid = [spawn[0] + 1, spawn[1], spawn[2]]; for z in 0..2 { game.state.grid.set_world( solid[0], solid[1], solid[2] + z, Voxel { material: hexwork_core::voxel::material::STONE, occ: 255, ..Voxel::default() }, ); } for _ in 0..6 { spends_tick(&mut game, Direction::East); assert_ne!(cell(&game), solid, "core never enters a solid cell"); }
// A live actor in the next cell south is never entered either. let occupied = [cell(&game)[0], cell(&game)[1] + 1, cell(&game)[2]]; let blocker = game .state .actors .iter_mut() .find(|a| a.id != player) .unwrap(); blocker.alive = true; blocker.hp = 1; blocker.position = cell_pos(occupied); for _ in 0..6 { spends_tick(&mut game, Direction::South); assert_ne!(cell(&game), occupied, "core never enters an occupied cell"); } }
#[test] fn diagonal_moves_never_cut_solid_corners() { // NorthEast moves +X and -Y in the core compass, so the two shared side // cells are the +X and -Y neighbours. If either is blocked, core's swept // body must never cut the corner into the diagonal cell. let solid = || Voxel { material: hexwork_core::voxel::material::STONE, occ: 255, ..Voxel::default() }; let diagonal = [41, 39, 7]; let side_cells = [[41, 40, 7], [40, 39, 7]];
fn prepared() -> (GameSession, Cell, u64) { let mut game = session(142); let player = game.player_id().unwrap(); let from = [40, 40, 7]; game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(from); for cell in [from, [41, 40, 7], [40, 39, 7], [41, 39, 7]] { game.state .grid .set_world(cell[0], cell[1], cell[2], Voxel::default()); game.state .grid .set_world(cell[0], cell[1], cell[2] + 1, Voxel::default()); game.state.grid.set_world( cell[0], cell[1], cell[2] - 1, Voxel { material: hexwork_core::voxel::material::STONE, occ: 1, ..Voxel::default() }, ); } game.sync_player_light().unwrap(); (game, from, player) }
// Player AABB (600 mm footprint, 1800 mm tall) versus a solid cell box. let overlaps = |game: &GameSession, cell: Cell| -> bool { let player = game.player_id().unwrap(); let p = game .state .actors .iter() .find(|a| a.id == player) .unwrap() .position .millimetres(); let lo = [i64::from(cell[0]) * 1000, i64::from(cell[1]) * 1000, i64::from(cell[2]) * 1000]; let hi = [lo[0] + 1000, lo[1] + 1000, lo[2] + 1000]; (p[0] - 300) < hi[0] && (p[0] + 300) > lo[0] && (p[1] - 300) < hi[1] && (p[1] + 300) > lo[1] && p[2] < hi[2] && (p[2] + 1800) > lo[2] };
for blocked_sides in [[true, true], [true, false], [false, true]] { let (mut game, _, _) = prepared(); for (blocked, cell) in blocked_sides.into_iter().zip(side_cells) { if blocked { game.state.grid.set_world(cell[0], cell[1], cell[2], solid()); } } for _ in 0..8 { // The sample is always a successful, tick-spending Move, but the // swept body must never cut a blocked corner (core slides instead). assert!(matches!( game.dispatch(CommittedAction::Move(Direction::NorthEast)) .unwrap(), ActionOutcome::Moved { .. } )); for (blocked, cell) in blocked_sides.into_iter().zip(side_cells) { if blocked { assert!(!overlaps(&game, cell), "swept body cut the solid corner"); } } } }
// A live actor in a side cell blocks the same corner. let (mut occupied, _, player) = prepared(); let side_actor = occupied .state .actors .iter_mut() .find(|actor| actor.id != player) .unwrap(); side_actor.position = cell_pos([41, 40, 7]); for _ in 0..8 { occupied .dispatch(CommittedAction::Move(Direction::NorthEast)) .unwrap(); assert_ne!( occupied.player_cell().unwrap(), [41, 40, 7], "core never shares a cell with another actor" ); }
// Both sides clear: the identical command can take the diagonal. let (mut open, _, _) = prepared(); let mut reached = false; for _ in 0..10 { open.dispatch(CommittedAction::Move(Direction::NorthEast)) .unwrap(); if open.player_cell().unwrap() == diagonal { reached = true; break; } } assert!(reached, "both sides clear lets the player take the diagonal"); }
#[test] fn frame_observation_does_not_change_committed_hashes() { fn observe_frame(game: &GameSession) -> (usize, usize, usize) { let lights = game .state .lights .iter() .map(|light| (light.x, light.y, light.z, light.radius)) .collect::<Vec<_>>(); let actors = game .state .actors .iter() .map(|actor| { ( game.state.identity(actor.id).unwrap().template_id.as_str(), actor.position, actor.alive, ) }) .collect::<Vec<_>>(); (lights.len(), actors.len(), game.state.grid.loaded_count()) }
let mut rendered = session(143); let mut headless = session(143); let mut rendered_controller = PlayController::default(); let mut headless_controller = PlayController::default(); for input in [Input::Down, Input::Left, Input::Right, Input::Wait] { let _ = observe_frame(&rendered); drive(&mut rendered_controller, &mut rendered, input).unwrap(); drive(&mut headless_controller, &mut headless, input).unwrap(); let _ = observe_frame(&rendered); assert_eq!( rendered.state.try_hash_state().unwrap(), headless.state.try_hash_state().unwrap() ); } }
#[test] fn board_requires_access_and_accepts_real_post_once() { let mut game = session(140); let initial_hash = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::InspectBoard { town: MILTHOLLOW.into() }) .unwrap_err() .code, "interaction-out-of-range" ); assert_eq!(initial_hash, game.state.try_hash_state().unwrap()); let board = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route(&mut game, board); let outcome = game .dispatch(CommittedAction::InspectBoard { town: MILTHOLLOW.into(), }) .unwrap(); let ActionOutcome::Board { text, .. } = outcome else { panic!("board outcome") }; for expected in [ "Rats in the granary", "Miller Odo", "25cu", "KNOWN", "UNKNOWN", ] { assert!(text.contains(expected), "missing {expected}: {text}"); } game.dispatch(CommittedAction::AcceptContract { id: RATS_CONTRACT.into(), }) .unwrap(); assert_eq!( game.dispatch(CommittedAction::AcceptContract { id: RATS_CONTRACT.into() }) .unwrap_err() .code, "contract-rejected" ); }
#[test] fn board_open_request_does_not_latch_until_success() { let game = session(154); let mut controller = PlayController { board_open: false, selected_contract: 3, }; let before = controller.clone();
let ControllerEvent::BoardOpenRequested { inspect, view } = controller.adapt(Input::Contracts, &game) else { panic!("Contracts requests inspection even away from the board") }; assert_eq!( inspect, CommittedAction::InspectBoard { town: MILTHOLLOW.to_owned() } ); assert_eq!(view, game.board_view(0)); assert_eq!(controller, before); assert_eq!( controller.adapt(Input::Confirm, &game), ControllerEvent::None );
controller.board_open_succeeded(); assert!(controller.board_open()); assert_eq!(controller.selected_contract, 0); }
#[test] fn board_navigation_reaches_all_rows_wraps_and_accepts_displayed_id() { let mut game = session(155); let board = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route(&mut game, board); let mut controller = PlayController::default(); let ControllerEvent::BoardOpenRequested { inspect, view: Some(first), } = controller.adapt(Input::Contracts, &game) else { panic!("board inspection is requested with a prospective selected row") }; game.dispatch(inspect).unwrap(); controller.board_open_succeeded(); assert_eq!(first.index, 0); assert_eq!(first.total, game.board_len()); assert!(first.page.contains(&first.title)); let mut probe = controller.clone(); let ControllerEvent::Dispatch { action, .. } = probe.adapt(Input::Confirm, &game) else { panic!("first row confirms") }; assert_eq!( action, CommittedAction::AcceptContract { id: first.id.clone() } );
let ControllerEvent::BoardSelectionChanged(last) = controller.adapt(Input::Up, &game) else { panic!("Up wraps first to last") }; assert_eq!(last.index, last.total - 1); let mut probe = controller.clone(); let ControllerEvent::Dispatch { action, .. } = probe.adapt(Input::Confirm, &game) else { panic!("last row confirms") }; assert_eq!( action, CommittedAction::AcceptContract { id: last.id.clone() } ); let ControllerEvent::BoardSelectionChanged(first_again) = controller.adapt(Input::Down, &game) else { panic!("Down wraps last to first") }; assert_eq!(first_again.index, 0);
let middle_index = first.total / 2; let mut displayed = first_again; for _ in 0..middle_index { let ControllerEvent::BoardSelectionChanged(next) = controller.adapt(Input::Down, &game) else { panic!("Down advances board selection") }; displayed = next; } assert_eq!(displayed.index, middle_index); let displayed_id = displayed.id.clone(); let ControllerEvent::Dispatch { action, source } = controller.adapt(Input::Confirm, &game) else { panic!("Confirm requests acceptance") }; assert_eq!( action, CommittedAction::AcceptContract { id: displayed_id.clone() } ); assert!(controller.board_open()); let result = game.dispatch(action); assert!(result.is_ok()); let presentation = reconcile_dispatch(&mut controller, &source, &result); assert!(reconcile_board_screen(true, presentation)); assert!(!controller.board_open()); assert!(game.state.contract(&displayed_id).is_some()); }
#[test] fn rejected_board_acceptance_keeps_controller_and_screen_open() { let mut game = session(156); let board = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route(&mut game, board); let mut controller = PlayController::default(); let ControllerEvent::BoardOpenRequested { inspect, .. } = controller.adapt(Input::Contracts, &game) else { panic!("board inspection is requested") }; game.dispatch(inspect).unwrap(); controller.board_open_succeeded();
let ControllerEvent::Dispatch { action, source } = controller.adapt(Input::Confirm, &game) else { panic!("Confirm requests acceptance") }; game.dispatch(action.clone()).unwrap(); let duplicate = game.dispatch(action).unwrap_err(); assert_eq!(duplicate.code, "contract-rejected"); let presentation = reconcile_dispatch::<ActionOutcome>(&mut controller, &source, &Err(duplicate)); assert!(!reconcile_board_screen(true, presentation)); assert!(controller.board_open()); assert!(matches!( controller.adapt(Input::Down, &game), ControllerEvent::BoardSelectionChanged(_) ));
let selected = controller.board_view(&game).unwrap(); let player = game.player_id().unwrap(); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos([selected.index as i32 + 40, 40, 7]); game.sync_player_light().unwrap(); let ControllerEvent::Dispatch { action, source } = controller.adapt(Input::Confirm, &game) else { panic!("Confirm still requests displayed acceptance") }; let result = game.dispatch(action); assert_eq!( result.as_ref().unwrap_err().code, "interaction-out-of-range" ); let presentation = reconcile_dispatch(&mut controller, &source, &result); assert!(!reconcile_board_screen(true, presentation)); assert!(controller.board_open());
game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(board); game.sync_player_light().unwrap(); let ControllerEvent::Dispatch { action, source } = controller.adapt(Input::Confirm, &game) else { panic!("Confirm requests displayed acceptance") }; let accepted_id = match &action { CommittedAction::AcceptContract { id } => id.clone(), _ => panic!("board Confirm constructs acceptance"), }; let result = game.dispatch(action); assert!(result.is_ok()); let presentation = reconcile_dispatch(&mut controller, &source, &result); assert!(reconcile_board_screen(true, presentation)); assert!(!controller.board_open()); assert!(game.state.contract(&accepted_id).is_some()); assert!(!reconcile_board_screen( true, controller.dispatch_succeeded(&source) )); }
#[test] fn production_input_path_completes_real_three_rat_slice() { let mut game = session(140); let mut controller = PlayController::default(); let player = game.player_id().unwrap(); let stipend = kit(Calling::Lampbearer).stipend_cu; let board = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route_inputs(&mut controller, &mut game, board); let ControllerEvent::BoardOpenRequested { inspect, .. } = controller.adapt(Input::Contracts, &game) else { panic!("board inspection is requested") }; game.dispatch(inspect).unwrap(); controller.board_open_succeeded(); let rats_index = (0..game.board_len()) .position(|index| { game.board_view(index) .is_some_and(|view| view.id == RATS_CONTRACT) }) .expect("rats contract is posted"); drive(&mut controller, &mut game, Input::ChargenPick(rats_index)).unwrap(); drive(&mut controller, &mut game, Input::Confirm).unwrap();
let town_connector = game.first_connector(PlanLocation::Town).clone(); route_inputs(&mut controller, &mut game, town_connector.access); drive(&mut controller, &mut game, Input::Confirm).unwrap(); assert_eq!(game.state.run_location(), &RunLocation::Deep(1));
let f1_bounds = game.floor_catalog(1).bounds; let rats: Vec<u64> = game .state .actors .iter() .filter(|actor| { actor.alive && game .state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") && f1_bounds.contains(actor.position.cell()) }) .map(|actor| actor.id) .collect(); assert_eq!(rats.len(), 3); for rat in &rats { let target = actor_cell(&game.state, *rat).unwrap(); let access = adjacent_access(&game, target); route_inputs(&mut controller, &mut game, access); let first = drive(&mut controller, &mut game, Input::Confirm) .unwrap() .unwrap(); assert!(matches!( first, ActionOutcome::AttackResolved { killed: false, .. } )); let second = drive(&mut controller, &mut game, Input::Confirm) .unwrap() .unwrap(); assert!(matches!( second, ActionOutcome::AttackResolved { killed: true, .. } )); } assert!(rats.iter().all(|id| !game .state .actors .iter() .find(|a| a.id == *id) .unwrap() .alive)); assert_eq!( game.state.contract(RATS_CONTRACT).unwrap().status, ContractStatus::Completed );
let up = game .floor_catalog(1) .connectors .iter() .find(|connector| connector.destination == PlanDestination::Town) .unwrap() .clone(); route_inputs(&mut controller, &mut game, up.access); drive(&mut controller, &mut game, Input::Confirm).unwrap(); route_inputs(&mut controller, &mut game, board); drive(&mut controller, &mut game, Input::Confirm).unwrap(); assert_eq!( game.state.contract(RATS_CONTRACT).unwrap().status, ContractStatus::TurnedIn ); assert_eq!(game.state.wallet(player).unwrap().copper(), stipend + 25);
let inn = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::Inn) }); route_inputs(&mut controller, &mut game, inn); let before_tick = game.state.tick; let before_day = game.state.far.day; let before_day_tick = game.state.far.day_tick; drive(&mut controller, &mut game, Input::Confirm).unwrap(); assert_eq!(game.state.tick - before_tick, services::sleep_ticks()); assert_eq!(game.state.far.day, before_day + 1); assert_eq!(game.state.far.day_tick, before_day_tick); assert_eq!( game.state.wallet(player).unwrap().copper(), stipend + 25 - 8 ); assert_eq!(game.state.validate_runtime(), Ok(())); }
#[test] fn melee_survivor_retaliates_once_and_killing_blow_does_not() { let mut game = session(221); descend_to(&mut game, 1); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") }) .unwrap() .id; let access = adjacent_access(&game, actor_cell(&game.state, target).unwrap()); route(&mut game, access); assert!(game.contextual_melee_hint().unwrap().contains("returns 3")); let first = game .dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); assert!(matches!( first, ActionOutcome::AttackResolved { damage: 3, killed: false, retaliation_damage: Some(3), player_killed: false, .. } )); assert!(!game.contextual_melee_hint().unwrap().contains("returns")); let second = game .dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); assert!(matches!( second, ActionOutcome::AttackResolved { damage: 3, killed: true, retaliation_damage: None, player_killed: false, .. } )); let player = game.player_id().unwrap(); assert_eq!( game.state .actors .iter() .find(|actor| actor.id == player) .unwrap() .hp, 7 ); }
#[test] fn melee_diagonal_through_solid_corner_is_not_contextual_and_rejects_atomically() { let mut game = session(222); descend_to(&mut game, 1); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") }) .unwrap() .id; let target_cell = actor_cell(&game.state, target).unwrap(); let player = game.player_id().unwrap(); let player_cell = [target_cell[0] - 1, target_cell[1] - 1, target_cell[2]]; game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(player_cell); // Core solidity is `material != AIR`, so the corner wall needs a material. let wall = Voxel { material: hexwork_core::voxel::material::STONE, occ: 1, ..Voxel::default() }; game.state .grid .set_world(target_cell[0] - 1, target_cell[1], target_cell[2], wall); let before_hash = game.state.try_hash_state().unwrap(); let before_events = game.events.len(); assert_eq!(game.contextual_melee_hint(), None); assert_eq!( game.dispatch(CommittedAction::ResolveAttack { target_actor: target }) .unwrap_err() .code, "attack-target-corner-blocked" ); assert_eq!(game.state.try_hash_state().unwrap(), before_hash); assert_eq!(game.events.len(), before_events); }
#[test] fn production_hostile_exchange_reaches_replayable_player_death() { let mut game = GameSession::load_with_mode( content_root(), 223, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut game, 2); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "grate_mother") }) .unwrap() .id; let access = adjacent_access(&game, actor_cell(&game.state, target).unwrap()); route(&mut game, access); for exchange in 0..4 { let result = game .dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); assert!(matches!( result, ActionOutcome::AttackResolved { retaliation_damage: Some(3), player_killed, .. } if player_killed == (exchange == 3) )); } let player = game.player_id().unwrap(); let player_actor = game .state .actors .iter() .find(|actor| actor.id == player) .unwrap(); let monster = game .state .actors .iter() .find(|actor| actor.id == target) .unwrap(); assert!(!player_actor.alive); assert_eq!(player_actor.hp, -2); assert!(monster.alive); assert_eq!(monster.hp, 18); let dead_hash = game.state.try_hash_state().unwrap(); let replay_bytes = game.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay_bytes, Mode::Story).unwrap(); assert_eq!(replayed.state.tick, game.state.tick); assert_eq!(replayed.state.try_hash_state().unwrap(), dead_hash); assert_eq!(replayed.events(), game.events()); }
#[test] fn combat_objective_provenance_rejects_spoofs_and_wrong_victims() { fn enter_f1_with_contract(game: &mut GameSession) { let board = interaction_access(game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route(game, board); game.dispatch(CommittedAction::AcceptContract { id: RATS_CONTRACT.into(), }) .unwrap(); let connector = game.first_connector(PlanLocation::Town).clone(); route(game, connector.access); game.dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap(); }
let mut game = session(150); let npc = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Npc) }) .unwrap() .id; let before = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::ResolveAttack { target_actor: npc }) .unwrap_err() .code, "attack-target-not-monster" ); assert_eq!(game.state.try_hash_state().unwrap(), before); assert_eq!( game.dispatch(CommittedAction::ResolveAttack { target_actor: u64::MAX, }) .unwrap_err() .code, "attack-target-missing" ); assert_eq!(game.state.try_hash_state().unwrap(), before);
enter_f1_with_contract(&mut game); let f1_rat = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") && game .floor_catalog(1) .bounds .contains(actor.position.cell()) }) .unwrap() .id; let before = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::ResolveAttack { target_actor: f1_rat, }) .unwrap_err() .code, "attack-target-out-of-range" ); assert_eq!(game.state.try_hash_state().unwrap(), before);
let wrong_floor_rat = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") && game .floor_catalog(2) .bounds .contains(actor.position.cell()) }) .unwrap() .id; let player = game.player_id().unwrap(); let wrong_cell = actor_cell(&game.state, wrong_floor_rat).unwrap(); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos([wrong_cell[0] + 1, wrong_cell[1], wrong_cell[2]]); game.sync_player_light().unwrap(); let before = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::ResolveAttack { target_actor: wrong_floor_rat, }) .unwrap_err() .code, "attack-target-wrong-location" ); assert_eq!(game.state.try_hash_state().unwrap(), before);
let non_rat = game .state .actors .iter() .find(|actor| { game.state.identity(actor.id).is_some_and(|identity| { identity.role == ActorRole::Monster && identity.template_id != "cable_rat" }) }) .unwrap() .id; let target = actor_cell(&game.state, non_rat).unwrap(); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos([target[0] + 1, target[1], target[2]]); game.state .actors .iter_mut() .find(|actor| actor.id == non_rat) .unwrap() .hp = PLAYER_STRIKE_DAMAGE; game.state .restore_run_location(game.location_for_cell(target).unwrap().run_location()) .unwrap(); game.sync_player_light().unwrap(); game.dispatch(CommittedAction::ResolveAttack { target_actor: non_rat, }) .unwrap(); assert_eq!( game.state .contract(RATS_CONTRACT) .unwrap() .objective_progress, 0 ); let before = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::ResolveAttack { target_actor: non_rat, }) .unwrap_err() .code, "attack-target-dead" ); assert_eq!(game.state.try_hash_state().unwrap(), before); }
#[test] fn inn_and_turn_in_failures_are_atomic_and_sleep_is_exact() { fn at_inn(mut game: GameSession) -> GameSession { let inn = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::Inn) }); route(&mut game, inn); game }
let mut poor = at_inn(GameSession::load(content_root(), 151, setup(Calling::CorpseTender)).unwrap()); let before = poor.state.try_hash_state().unwrap(); assert_eq!( poor.dispatch(CommittedAction::UseService { service: Service::Inn, }) .unwrap_err() .code, "service-insufficient-funds" ); assert_eq!(poor.state.try_hash_state().unwrap(), before);
let mut overflow = at_inn(session(152)); overflow.state.tick = u64::MAX; let before_balance = overflow .state .wallet(overflow.player_id().unwrap()) .unwrap() .copper(); let before = overflow.state.diagnostic_hash(); assert_eq!( overflow .dispatch(CommittedAction::UseService { service: Service::Inn, }) .unwrap_err() .code, "runtime-invalid" ); assert_eq!(overflow.state.diagnostic_hash(), before); assert_eq!( overflow .state .wallet(overflow.player_id().unwrap()) .unwrap() .copper(), before_balance );
let mut first = at_inn(session(153)); let mut second = at_inn(session(153)); let tick = first.state.tick; let day = first.state.far.day; let day_tick = first.state.far.day_tick; first .dispatch(CommittedAction::UseService { service: Service::Inn, }) .unwrap(); second .dispatch(CommittedAction::UseService { service: Service::Inn, }) .unwrap(); assert_eq!(first.state.tick, tick + 8_640); assert_eq!(first.state.far.day, day + 1); assert_eq!(first.state.far.day_tick, day_tick); assert_eq!( first.state.try_hash_state().unwrap(), second.state.try_hash_state().unwrap() );
let mut turned = session(154); let player = turned.player_id().unwrap(); turned .state .accept_contract(RATS_CONTRACT, player, 1, 25, 3, MILTHOLLOW, 2) .unwrap(); turned .state .restore_run_location(RunLocation::Deep(1)) .unwrap(); turned .state .advance_contract_objective(RATS_CONTRACT, player, 3) .unwrap(); turned .state .restore_run_location(RunLocation::Town(MILTHOLLOW.into())) .unwrap(); let board = interaction_access(&turned, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route(&mut turned, board); turned .dispatch(CommittedAction::TurnIn { contract_id: RATS_CONTRACT.into(), }) .unwrap(); let before = turned.state.try_hash_state().unwrap(); assert_eq!( turned .dispatch(CommittedAction::TurnIn { contract_id: RATS_CONTRACT.into(), }) .unwrap_err() .code, "turn-in-rejected" ); assert_eq!(turned.state.try_hash_state().unwrap(), before); }
#[test] fn transition_preflight_failures_are_hash_atomic() { fn at_town_connector(mut game: GameSession) -> (GameSession, ConnectorPlan) { let connector = game.first_connector(PlanLocation::Town).clone(); route(&mut game, connector.access); (game, connector) }
let (mut occupied, connector) = at_town_connector(session(144)); let paired = occupied .floor_catalog(1) .connectors .iter() .find(|candidate| candidate.transition_id == connector.paired_transition_id) .unwrap() .clone(); let player = occupied.player_id().unwrap(); occupied .state .actors .iter_mut() .find(|actor| actor.id != player) .unwrap() .position = cell_pos(paired.access); let before = occupied.state.try_hash_state().unwrap(); assert_eq!( occupied .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap_err() .code, "passage-occupied" ); assert_eq!(occupied.state.try_hash_state().unwrap(), before);
let (mut unsupported, connector) = at_town_connector(session(145)); let paired = unsupported .floor_catalog(1) .connectors .iter() .find(|candidate| candidate.transition_id == connector.paired_transition_id) .unwrap() .clone(); unsupported.state.grid.set_world( paired.access[0], paired.access[1], paired.access[2] - 1, Voxel::default(), ); let before = unsupported.state.try_hash_state().unwrap(); assert_eq!( unsupported .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap_err() .code, "passage-unsupported" ); assert_eq!(unsupported.state.try_hash_state().unwrap(), before);
let (mut flooded, connector) = at_town_connector(session(146)); let paired = flooded .floor_catalog(1) .connectors .iter() .find(|candidate| candidate.transition_id == connector.paired_transition_id) .unwrap() .clone(); let mut water = flooded .state .grid .get_world(paired.access[0], paired.access[1], paired.access[2]); water.fluid_amt = MovementPolicy::default().max_water_depth + 1; flooded .state .grid .set_world(paired.access[0], paired.access[1], paired.access[2], water); let before = flooded.state.try_hash_state().unwrap(); assert_eq!( flooded .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap_err() .code, "passage-flooded" ); assert_eq!(flooded.state.try_hash_state().unwrap(), before);
let (mut blocked, connector) = at_town_connector(session(147)); blocked .state .connectors .iter_mut() .find(|live| live.id == connector.link_id) .unwrap() .blocked = true; let before = blocked.state.try_hash_state().unwrap(); assert_eq!( blocked .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap_err() .code, "transition-blocked" ); assert_eq!(blocked.state.try_hash_state().unwrap(), before);
let (mut malformed, connector) = at_town_connector(session(148)); malformed .catalog .locations .get_mut(&PlanLocation::Deep(1)) .unwrap() .connectors .iter_mut() .find(|candidate| candidate.transition_id == connector.paired_transition_id) .unwrap() .paired_transition_id += 1; let before = malformed.state.try_hash_state().unwrap(); assert_eq!( malformed .dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap_err() .code, "transition-pair-malformed" ); assert_eq!(malformed.state.try_hash_state().unwrap(), before); }
#[test] fn all_external_and_local_transitions_revisit_without_duplication() { let mut game = session(149); let player = game.player_id().unwrap(); for actor in &mut game.state.actors { if actor.id != player { actor.alive = false; } } let actors = game.state.actors.len(); let items = game.state.items.len(); let ground = game.state.ground_items.len(); let loaded_chunks = game.state.grid.loaded_count(); let sample = game.state.grid.get_world(21, 31, 7);
let mut source = game.first_connector(PlanLocation::Town).clone(); for depth in 1..=12 { game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(source.access); game.sync_player_light().unwrap(); game.dispatch(CommittedAction::UseTransition { transition_id: source.transition_id, }) .unwrap(); assert_eq!(game.state.run_location(), &RunLocation::Deep(depth));
let local = game .floor_catalog(depth) .connectors .iter() .find(|connector| { connector.destination == PlanDestination::Deep { depth } && connector.direction == TransitionDirection::Up }) .unwrap() .clone(); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(local.access); game.sync_player_light().unwrap(); let coarse = game.state.run_location().clone(); game.dispatch(CommittedAction::UseTransition { transition_id: local.transition_id, }) .unwrap(); assert_eq!(game.state.run_location(), &coarse); let reverse = game .floor_catalog(depth) .connectors .iter() .find(|connector| connector.transition_id == local.paired_transition_id) .unwrap() .clone(); assert_eq!(game.player_cell().unwrap(), reverse.access); game.dispatch(CommittedAction::UseTransition { transition_id: reverse.transition_id, }) .unwrap(); assert_eq!(game.player_cell().unwrap(), local.access);
if depth < 12 { source = game .floor_catalog(depth) .connectors .iter() .find(|connector| { connector.destination == PlanDestination::Deep { depth: depth + 1 } }) .unwrap() .clone(); } } for depth in (2..=12).rev() { let up = game .floor_catalog(depth) .connectors .iter() .find(|connector| { connector.destination == PlanDestination::Deep { depth: depth - 1 } }) .unwrap() .clone(); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(up.access); game.sync_player_light().unwrap(); game.dispatch(CommittedAction::UseTransition { transition_id: up.transition_id, }) .unwrap(); } let town = game .floor_catalog(1) .connectors .iter() .find(|connector| connector.destination == PlanDestination::Town) .unwrap() .clone(); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = cell_pos(town.access); game.sync_player_light().unwrap(); game.dispatch(CommittedAction::UseTransition { transition_id: town.transition_id, }) .unwrap(); assert_eq!(game.state.actors.len(), actors); assert_eq!(game.state.items.len(), items); assert_eq!(game.state.ground_items.len(), ground); assert_eq!(game.state.grid.loaded_count(), loaded_chunks); assert_eq!(game.state.grid.get_world(21, 31, 7), sample); assert_eq!(game.state.validate_runtime(), Ok(())); }
#[test] fn deep_to_town_refreshes_checkpoint_after_transition_event() { let mut story = GameSession::load_with_mode( content_root(), 240, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut story, 1); let up = story .connectors_for(PlanLocation::Deep(1)) .iter() .find(|connector| connector.destination == PlanDestination::Town) .unwrap() .clone(); route(&mut story, up.access); story .dispatch(CommittedAction::UseTransition { transition_id: up.transition_id, }) .unwrap(); let checkpoint = story.story_checkpoint.as_ref().unwrap(); assert_eq!(checkpoint.after_event_count as usize, story.events.len()); assert_eq!(checkpoint.state.tick, story.state.tick); assert_eq!(checkpoint.state.hash, story.state.try_hash_state().unwrap()); assert!(matches!( story.events.last().unwrap().kind, SessionEventKind::Action(CommittedAction::UseTransition { transition_id }) if transition_id == up.transition_id )); assert_eq!( story.checkpoint_witness.borrow().as_ref(), Some(&story.checkpoint_witness_for(checkpoint).unwrap()) ); }
#[test] fn injected_story_refresh_failure_restores_state_events_and_old_checkpoint() { let mut story = GameSession::load_with_mode( content_root(), 235, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut story, 1); let up = story .connectors_for(PlanLocation::Deep(1)) .iter() .find(|connector| connector.destination == PlanDestination::Town) .unwrap() .clone(); route(&mut story, up.access); let before_image = story.state.to_image().unwrap(); let before_events = story.events.clone(); let before_checkpoint = story.story_checkpoint.clone().unwrap(); let before_witness = story.checkpoint_witness.borrow().clone(); for invalid_after_event_count in [false, true] { let error = story .dispatch_with_checkpoint_builder( CommittedAction::UseTransition { transition_id: up.transition_id, }, |session, count| { if invalid_after_event_count { session.build_checkpoint(count + 1) } else { Err(SessionError::content( "injected", "checkpoint builder failed", )) } }, ) .unwrap_err(); assert_eq!(error.code, "story-checkpoint-refresh"); assert_eq!( format!("{:?}", story.state.to_image().unwrap()), format!("{before_image:?}") ); assert_eq!(story.events, before_events); let restored_checkpoint = story.story_checkpoint.as_ref().unwrap(); assert_eq!( restored_checkpoint.after_event_count, before_checkpoint.after_event_count ); assert_eq!(restored_checkpoint.state.tick, before_checkpoint.state.tick); assert_eq!(restored_checkpoint.state.hash, before_checkpoint.state.hash); assert_eq!( format!("{:?}", restored_checkpoint.state.image), format!("{:?}", before_checkpoint.state.image) ); assert_eq!(*story.checkpoint_witness.borrow(), before_witness); } }
#[test] fn classic_and_iron_town_transition_ignore_checkpoint_builder_failure_without_panic() { for mode in [Mode::Classic, Mode::Ironcartographer] { let mut game = GameSession::load_with_mode(content_root(), 236, mode, setup(Calling::Lampbearer)) .unwrap(); descend_to(&mut game, 1); let up = game .connectors_for(PlanLocation::Deep(1)) .iter() .find(|connector| connector.destination == PlanDestination::Town) .unwrap() .clone(); route(&mut game, up.access); game.dispatch_with_checkpoint_builder( CommittedAction::UseTransition { transition_id: up.transition_id, }, |_, _| Err(SessionError::content("injected", "must not be called")), ) .unwrap(); assert_eq!( game.state.run_location(), &RunLocation::Town(MILTHOLLOW.into()) ); assert!(game.story_checkpoint.is_none()); } }
#[test] fn restored_checkpoint_prefix_proof_rejects_unrelated_image() { // An unrelated image must sit at the same (non-zero) prefix, so refresh the // genuine town checkpoint with a real descent, then build an unrelated image // at the same event count from a different walk. let mut genuine = GameSession::load_with_mode( content_root(), 242, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); genuine.dispatch(CommittedAction::Wait).unwrap(); let claimed = genuine.story_checkpoint.clone().unwrap();
// Unrelated image: a structurally valid but non-genuine checkpoint at the // same retained prefix. The state is perturbed without advancing the // world, so it sits at tick zero with a different hash (docs/fluid §4.3 // keeps world-duration actions out of any zero-tick walk). let mut unrelated = GameSession::load_with_mode( content_root(), 242, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); let player = unrelated.player_id().unwrap(); { let actor = unrelated .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.position = actor.position.checked_translate([1, 0, 0]).unwrap(); } let unrelated_checkpoint = unrelated .build_checkpoint(claimed.after_event_count) .unwrap(); assert_ne!(unrelated_checkpoint.state.hash, claimed.state.hash); let bytes = encode_run( genuine.identity(), &genuine.state, Some(&unrelated_checkpoint), genuine.events(), ) .unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Story).unwrap(); assert!(restored.checkpoint_witness.borrow().is_none()); assert_eq!( restored.encode_replay().unwrap_err().code, "checkpoint-proof-hash-mismatch" ); assert!(restored.checkpoint_witness.borrow().is_none()); }
#[test] fn multiple_town_checkpoints_and_resurrections_preserve_latest_reference() { let mut story = GameSession::load_with_mode( content_root(), 243, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut story, 1); return_to_town(&mut story); let earlier = story.story_checkpoint.clone().unwrap(); story.dispatch(CommittedAction::Wait).unwrap(); descend_to(&mut story, 1); return_to_town(&mut story); let latest = story.story_checkpoint.clone().unwrap(); assert!(latest.after_event_count > earlier.after_event_count); assert_ne!(latest.state.hash, earlier.state.hash);
let valid_bytes = story.encode_current_run().unwrap(); let mut saved = decode_run(&valid_bytes, story.content_hash()).unwrap(); saved.story_checkpoint = Some(earlier.clone()); let substituted = encode_run( &saved.identity, &saved.current, saved.story_checkpoint.as_ref(), &saved.events, ) .unwrap(); let mut restored = GameSession::restore_from_bytes(content_root(), &substituted, Mode::Story).unwrap(); assert!(restored.checkpoint_witness.borrow().is_none()); assert_eq!( restored.encode_replay().unwrap_err().code, "checkpoint-proof-prefix-mismatch" ); kill_player_at_grate_mother(&mut restored); assert_eq!( restored.resolve_story_death().unwrap_err().code, "checkpoint-proof-prefix-mismatch" ); assert!(restored.checkpoint_witness.borrow().is_none()); }
#[test] fn checkpoint_proof_cache_cannot_bless_changed_image_prefix_or_latest_suffix() { let mut changed_image = GameSession::load_with_mode( content_root(), 237, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); let original = changed_image.checkpoint_witness.borrow().clone().unwrap(); changed_image.story_checkpoint.as_mut().unwrap().state.hash ^= 1; assert_eq!( changed_image.encode_replay().unwrap_err().code, "story-checkpoint-hash-mismatch" ); assert_eq!(*changed_image.checkpoint_witness.borrow(), Some(original));
let mut changed_prefix = GameSession::load_with_mode( content_root(), 237, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); changed_prefix.dispatch(CommittedAction::Wait).unwrap(); changed_prefix .story_checkpoint .as_mut() .unwrap() .after_event_count = 1; let changed_prefix_code = changed_prefix.encode_replay().unwrap_err().code; assert!( matches!( changed_prefix_code, "checkpoint-proof-prefix-mismatch" | "checkpoint-proof-hash-mismatch" ), "unexpected code {changed_prefix_code}" );
let mut changed_suffix = GameSession::load_with_mode( content_root(), 244, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); descend_to(&mut changed_suffix, 1); return_to_town(&mut changed_suffix); let earlier = changed_suffix.story_checkpoint.clone().unwrap(); let earlier_witness = changed_suffix.checkpoint_witness.borrow().clone().unwrap(); changed_suffix.dispatch(CommittedAction::Wait).unwrap(); descend_to(&mut changed_suffix, 1); return_to_town(&mut changed_suffix); assert!( changed_suffix .story_checkpoint .as_ref() .unwrap() .after_event_count > earlier.after_event_count ); changed_suffix.story_checkpoint = Some(earlier); *changed_suffix.checkpoint_witness.borrow_mut() = Some(earlier_witness); assert_eq!( changed_suffix.encode_replay().unwrap_err().code, "checkpoint-proof-prefix-mismatch" ); }
#[test] fn journal_records_success_only_and_orders_one_tick_per_action() { let mut game = session(212); let initial_tick = game.state.tick; // A rejected action is neither journaled nor tick-spending. A blocked Move // is no longer a rejection, so use an out-of-range interaction instead. assert_eq!( game.dispatch(CommittedAction::InspectBoard { town: MILTHOLLOW.into() }) .unwrap_err() .code, "interaction-out-of-range" ); assert!(game.journal().is_empty()); assert_eq!(game.state.tick, initial_tick); // Every accepted action spends exactly one tick now that Move steps, so the // journal carries strictly increasing pre_ticks. game.dispatch(CommittedAction::Move(Direction::South)) .unwrap(); game.dispatch(CommittedAction::Wait).unwrap(); assert_eq!(game.journal().len(), 2); assert_eq!(game.journal()[0].pre_tick, initial_tick); assert_eq!(game.journal()[1].pre_tick, initial_tick + 1); assert!(matches!( game.journal()[0].kind, SessionEventKind::Action(CommittedAction::Move(Direction::South)) )); assert!(matches!( game.journal()[1].kind, SessionEventKind::Action(CommittedAction::Wait) )); assert_eq!(game.state.tick, initial_tick + 2);
game.events = (0..MAX_SESSION_EVENTS) .map(|_| { SessionEvent::action(game.state.tick, game.state.tick + 1, CommittedAction::Wait) }) .collect(); let before = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::Wait).unwrap_err().code, "journal-capacity" ); assert_eq!(game.state.try_hash_state().unwrap(), before); }
#[test] fn exact_run_restore_retains_state_history_and_continuation() { let mut original = GameSession::load_with_mode( content_root(), 213, Mode::Classic, setup(Calling::Lampbearer), ) .unwrap(); let board = interaction_access(&original, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::NoticeBoard) }); route(&mut original, board); original .dispatch(CommittedAction::InspectBoard { town: MILTHOLLOW.into(), }) .unwrap(); original .dispatch(CommittedAction::AcceptContract { id: RATS_CONTRACT.into(), }) .unwrap(); original.state.step(); let bytes = original.encode_current_run().unwrap(); let expected_tick = original.state.tick; let expected_hash = original.state.try_hash_state().unwrap(); let expected_journal = original.journal().to_vec(); let mut restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(restored.mode(), Mode::Classic); assert_eq!(restored.state.tick, expected_tick); assert_eq!(restored.state.try_hash_state().unwrap(), expected_hash); assert_eq!(restored.journal(), expected_journal); assert_eq!( restored.state.contract(RATS_CONTRACT), original.state.contract(RATS_CONTRACT) ); assert_eq!(restored.state.npcs, original.state.npcs); assert_eq!(restored.state.packs, original.state.packs); assert_eq!(restored.state.run_location(), original.state.run_location());
original.dispatch(CommittedAction::Wait).unwrap(); restored.dispatch(CommittedAction::Wait).unwrap(); original.state.step(); restored.state.step(); assert_eq!( restored.state.try_hash_state().unwrap(), original.state.try_hash_state().unwrap() ); }
#[test] fn run_restore_rejects_corrupt_mode_and_catalog_or_content_incoherence() { let game = session(214); let bytes = game.encode_current_run().unwrap(); assert_eq!( GameSession::restore_from_bytes( content_root(), &bytes[..bytes.len() / 2], Mode::Classic ) .unwrap_err() .code, "run-decode" ); assert_eq!( GameSession::restore_from_bytes(content_root(), &bytes, Mode::Story) .unwrap_err() .code, "mode-mismatch" );
let mut bad_connector = session(214); bad_connector.state.connectors[0].from[0] += 1.0; let bad_bytes = encode_run( bad_connector.identity(), &bad_connector.state, None, bad_connector.events(), ) .unwrap(); assert_eq!( GameSession::restore_from_bytes(content_root(), &bad_bytes, Mode::Classic) .unwrap_err() .code, "connector-catalog-mismatch" );
let mut bad_template = session(214); let monster = bad_template .state .actors .iter() .find(|actor| { bad_template .state .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Monster) }) .unwrap() .id; bad_template .state .identity_mut(monster) .unwrap() .template_id = "missing_monster".into(); let bad_bytes = encode_run( bad_template.identity(), &bad_template.state, None, bad_template.events(), ) .unwrap(); assert_eq!( GameSession::restore_from_bytes(content_root(), &bad_bytes, Mode::Classic) .unwrap_err() .code, "actor-template-missing" ); }
#[test] fn mixed_replay_reconstructs_same_tick_actions_idle_gaps_and_terminal_hash() { let mut original = session(215); original .dispatch(CommittedAction::Move(Direction::South)) .unwrap(); original.dispatch(CommittedAction::Wait).unwrap(); original.state.step(); original.state.step(); original .dispatch(CommittedAction::Move(Direction::North)) .unwrap(); let bytes = original.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(replayed.journal(), original.journal()); assert_eq!(replayed.state.tick, original.state.tick); assert_eq!( replayed.state.try_hash_state().unwrap(), original.state.try_hash_state().unwrap() ); let decoded = decode_replay(&bytes, original.content_hash()).unwrap(); assert_eq!(decoded.identity.display_name, "Ada"); assert_eq!(decoded.expected_final_tick, original.state.tick); }
#[test] fn huge_sparse_replay_rejects_before_any_step() { let game = session(232); let replay = ProductionReplay { identity: game.identity().clone(), events: Vec::new(), expected_final_tick: u64::MAX, expected_final_hash: 0, }; let bytes = encode_replay(&replay).unwrap(); let error = match GameSession::replay_from_bytes(content_root(), &bytes, Mode::Classic) { Err(error) => error, Ok(_) => panic!("sparse u64::MAX replay unexpectedly accepted"), }; assert!(matches!( error.code, "replay-full-step-limit" | "replay-elapsed-limit" )); }
#[test] fn limits_boundary_values_accept_then_reject() { let mut game = session(233); game.dispatch(CommittedAction::Wait).unwrap(); let bytes = game.encode_replay().unwrap(); let exact = ReplayLimits { max_full_steps: 1, max_unattended_iterations: 0, max_total_elapsed_ticks: 1, max_evidence_rows: ReplayLimits::default().max_evidence_rows, }; GameSession::replay_from_bytes_with_limits(content_root(), &bytes, Mode::Classic, exact) .unwrap(); for limits in [ ReplayLimits { max_full_steps: 0, ..exact }, ReplayLimits { max_total_elapsed_ticks: 0, ..exact }, ] { let error = match GameSession::replay_from_bytes_with_limits( content_root(), &bytes, Mode::Classic, limits, ) { Err(error) => error, Ok(_) => panic!("below-boundary replay unexpectedly accepted"), }; assert!(matches!( error.code, "replay-full-step-limit" | "replay-elapsed-limit" )); } }
#[test] fn rollback_epochs_charge_elapsed_once_each() { let checkpoint_hash = 7; let events = vec![ SessionEvent::action(2, 3, CommittedAction::Wait), SessionEvent { pre_tick: 5, post_tick: 0, kind: SessionEventKind::StoryResurrect { checkpoint_after_event_count: 0, checkpoint_hash, dead_state_hash: 11, restored_state_hash: 12, }, }, SessionEvent::action(3, 4, CommittedAction::Wait), ]; let costs = timeline_costs(&events, Mode::Story, 6).unwrap(); assert_eq!(costs.full_steps, 11); assert_eq!(costs.unattended_steps, 0); assert_eq!(costs.total_elapsed_ticks, 11); ReplayBudget::new( costs, ReplayLimits { max_full_steps: 11, max_unattended_iterations: 0, max_total_elapsed_ticks: 11, max_evidence_rows: 0, }, ) .unwrap(); assert_eq!( ReplayBudget::new( costs, ReplayLimits { max_full_steps: 10, max_unattended_iterations: 0, max_total_elapsed_ticks: 11, max_evidence_rows: 0, }, ) .unwrap_err() .code, "replay-full-step-limit" ); }
#[test] fn prefix_proof_and_replay_share_request_budget() { let mut story = GameSession::load_with_mode( content_root(), 245, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); story.dispatch(CommittedAction::Wait).unwrap(); let run = story.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &run, Mode::Story).unwrap(); assert!(restored.checkpoint_witness.borrow().is_none()); let one_fold = ReplayLimits { max_full_steps: 1, max_unattended_iterations: 0, max_total_elapsed_ticks: 1, max_evidence_rows: ReplayLimits::default().max_evidence_rows, }; assert_eq!( restored .encode_replay_with_limits(one_fold) .unwrap_err() .code, "replay-full-step-limit" ); assert!(restored.checkpoint_witness.borrow().is_none()); restored .encode_replay_with_limits(ReplayLimits { max_full_steps: 2, max_total_elapsed_ticks: 2, ..one_fold }) .unwrap(); assert!(restored.checkpoint_witness.borrow().is_some()); restored.encode_replay_with_limits(one_fold).unwrap(); }
#[test] fn bounded_idle_gap_replay_timing() { let game = session(241); let replay = ProductionReplay { identity: game.identity().clone(), events: Vec::new(), expected_final_tick: 100, expected_final_hash: { let mut expected = State::from_image(game.state.to_image().unwrap()).unwrap(); for _ in 0..100 { expected.step(); } expected.try_hash_state().unwrap() }, }; let bytes = encode_replay(&replay).unwrap(); let limits = ReplayLimits { max_full_steps: 100, max_unattended_iterations: 0, max_total_elapsed_ticks: 100, max_evidence_rows: ReplayLimits::default().max_evidence_rows, }; let started = std::time::Instant::now(); let replayed = GameSession::replay_from_bytes_with_limits( content_root(), &bytes, Mode::Classic, limits, ) .unwrap(); eprintln!( "release-style idle replay timing: {:?}, ticks=100", started.elapsed() ); assert_eq!(replayed.state.tick, 100); }
#[test] fn inn_service_charges_unattended_work_before_dispatch() { let mut game = session(234); let inn = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::Inn) }); route(&mut game, inn); // Real movement spends a tick per accepted action, so the walk to the Inn // contributes `journey_ticks` on top of the unattended sleep. let journey_ticks = game.state.tick; game.dispatch(CommittedAction::UseService { service: Service::Inn, }) .unwrap(); let total = journey_ticks + services::sleep_ticks(); let bytes = game.encode_replay().unwrap(); let sleep = services::sleep_ticks(); let insufficient = ReplayLimits { max_full_steps: 10_000, max_unattended_iterations: sleep - 1, max_total_elapsed_ticks: 100_000, max_evidence_rows: ReplayLimits::default().max_evidence_rows, }; let error = match GameSession::replay_from_bytes_with_limits( content_root(), &bytes, Mode::Classic, insufficient, ) { Err(error) => error, Ok(_) => panic!("under-budget Inn replay unexpectedly accepted"), }; assert_eq!(error.code, "replay-unattended-limit"); // The exact budget must cover the journey moves plus the unattended sleep. let exact = ReplayLimits { max_unattended_iterations: sleep, max_total_elapsed_ticks: total, ..insufficient }; let started = std::time::Instant::now(); let replayed = GameSession::replay_from_bytes_with_limits( content_root(), &bytes, Mode::Classic, exact, ) .unwrap(); eprintln!( "release-style Inn replay debug timing: {:?}, ticks={total}", started.elapsed() ); assert_eq!(replayed.state.tick, total); }
#[test] fn replay_rejects_setup_mode_action_timing_and_terminal_tampering() { let mut original = session(216); original .dispatch(CommittedAction::Move(Direction::South)) .unwrap(); original.dispatch(CommittedAction::Wait).unwrap(); original .dispatch(CommittedAction::Move(Direction::North)) .unwrap(); let bytes = original.encode_replay().unwrap(); assert_eq!( GameSession::replay_from_bytes(content_root(), &bytes, Mode::Story) .unwrap_err() .code, "mode-mismatch" ); let mut replay = decode_replay(&bytes, original.content_hash()).unwrap(); replay.identity.display_name = " ".into(); assert!(encode_replay(&replay) .unwrap_err() .to_string() .contains("display_name"));
let mut replay = decode_replay(&bytes, original.content_hash()).unwrap(); // A blocked Move is no longer an error, so tamper event 0 into a genuinely // rejected action (an out-of-range board inspection) to exercise the // per-action replay failure path. replay.events[0].kind = SessionEventKind::Action(CommittedAction::InspectBoard { town: MILTHOLLOW.into(), }); // A board inspection spends no tick, so the tampered span must match. replay.events[0].post_tick = replay.events[0].pre_tick; let failed = encode_replay(&replay).unwrap(); let error = GameSession::replay_from_bytes(content_root(), &failed, Mode::Classic).unwrap_err(); assert_eq!(error.code, "replay-action-failed"); assert!(error.detail.contains("event 0"));
let mut replay = decode_replay(&bytes, original.content_hash()).unwrap(); // Every accepted action now spends exactly one tick. assert_eq!(replay.events[2].pre_tick, 2); replay.events[2].pre_tick = 0; let error = encode_replay(&replay).unwrap_err(); assert!(error.to_string().contains("pre_tick precedes cursor"));
let mut moved = session(220); moved .dispatch(CommittedAction::Move(Direction::South)) .unwrap(); let moved_bytes = moved.encode_replay().unwrap(); let mut replay = decode_replay(&moved_bytes, moved.content_hash()).unwrap(); replay.events.clear(); let removed = encode_replay(&replay).unwrap(); assert_eq!( GameSession::replay_from_bytes(content_root(), &removed, Mode::Classic) .unwrap_err() .code, "replay-terminal-divergence" ); }
#[test] fn resume_resurrection_export_replay_matches() { let mut resumed = GameSession::restore_from_bytes( content_root(), canonical_dead_story_run(), Mode::Story, ) .unwrap(); assert!(resumed.checkpoint_witness.borrow().is_none()); let dead_hash = resumed.state.try_hash_state().unwrap(); let before_events = resumed.events.clone(); let checkpoint_hash = resumed.story_checkpoint.as_ref().unwrap().state.hash; resumed.resolve_story_death().unwrap(); assert_eq!(&resumed.events[..before_events.len()], before_events); let restored_hash = resumed.state.try_hash_state().unwrap(); assert!(matches!( resumed.events.last().unwrap().kind, SessionEventKind::StoryResurrect { dead_state_hash, checkpoint_hash: event_checkpoint_hash, restored_state_hash, .. } if dead_state_hash == dead_hash && event_checkpoint_hash == checkpoint_hash && restored_state_hash == restored_hash )); resumed.dispatch(CommittedAction::Wait).unwrap(); let replay = resumed.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay, Mode::Story).unwrap(); assert_eq!(replayed.events(), resumed.events()); assert_eq!( replayed.state.try_hash_state().unwrap(), resumed.state.try_hash_state().unwrap() ); }
#[test] fn repeated_resurrection_keeps_ordered_history_and_active_checkpoint_reference() { let mut story = GameSession::restore_from_bytes( content_root(), canonical_dead_story_run(), Mode::Story, ) .unwrap(); let checkpoint = story.story_checkpoint.as_ref().unwrap(); let checkpoint_count = checkpoint.after_event_count; let checkpoint_hash = checkpoint.state.hash; story.resolve_story_death().unwrap(); let first_resurrection_index = story.events.len() - 1; kill_player_at_grate_mother(&mut story); story.resolve_story_death().unwrap(); let resurrection_rows: Vec<_> = story .events .iter() .enumerate() .filter_map(|(index, event)| match event.kind { SessionEventKind::StoryResurrect { checkpoint_after_event_count, checkpoint_hash, .. } => Some((index, checkpoint_after_event_count, checkpoint_hash)), SessionEventKind::Action(_) => None, }) .collect(); assert_eq!(resurrection_rows.len(), 2); assert_eq!(resurrection_rows[0].0, first_resurrection_index); assert!(resurrection_rows[1].0 > resurrection_rows[0].0); assert!(resurrection_rows .iter() .all(|(_, count, hash)| { *count == checkpoint_count && *hash == checkpoint_hash })); let replay = story.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay, Mode::Story).unwrap(); assert_eq!(replayed.events(), story.events()); assert_eq!( replayed.state.try_hash_state().unwrap(), story.state.try_hash_state().unwrap() ); }
#[test] fn resurrection_continue_export_replay_matches() { let mut live = GameSession::replay_from_bytes( content_root(), canonical_dead_story_replay(), Mode::Story, ) .unwrap(); let dead_hash = live.state.try_hash_state().unwrap(); let before_len = live.events.len(); let checkpoint = live.story_checkpoint.clone().unwrap(); live.resolve_story_death().unwrap(); assert_eq!(live.events.len(), before_len + 1); assert!(matches!( live.events.last().unwrap().kind, SessionEventKind::StoryResurrect { dead_state_hash, checkpoint_after_event_count, checkpoint_hash, .. } if dead_state_hash == dead_hash && checkpoint_after_event_count == checkpoint.after_event_count && checkpoint_hash == checkpoint.state.hash )); live.dispatch(CommittedAction::Wait).unwrap(); let replay = live.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay, Mode::Story).unwrap(); assert_eq!(replayed.events(), live.events()); assert_eq!( replayed.state.try_hash_state().unwrap(), live.state.try_hash_state().unwrap() ); assert!(replayed .replay_evidence() .iter() .any(|row| { row.rule == "melee" && row.detail.contains("retaliates") })); }
#[test] fn story_replay_derives_predeath_and_postrestore_subtitles_in_event_order() { let mut story = GameSession::replay_from_bytes( content_root(), canonical_dead_story_replay(), Mode::Story, ) .unwrap(); story.resolve_story_death().unwrap(); let resurrection_ordinal = story.events.len() - 1; descend_to(&mut story, 1); let rat = story .state .actors .iter() .find(|actor| { story .state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") }) .unwrap() .id; let rat_access = adjacent_access(&story, actor_cell(&story.state, rat).unwrap()); route(&mut story, rat_access); story .dispatch(CommittedAction::ResolveAttack { target_actor: rat }) .unwrap(); let postrestore_attack = story.events.len() - 1; let predeath_attack_ordinals: Vec<_> = story.events[..resurrection_ordinal] .iter() .enumerate() .filter_map(|(ordinal, event)| { matches!( event.kind, SessionEventKind::Action(CommittedAction::ResolveAttack { .. }) ) .then_some(ordinal) }) .collect(); assert_eq!(predeath_attack_ordinals.len(), 4); let replay_bytes = story.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay_bytes, Mode::Story).unwrap(); let evidence = replayed.replay_evidence(); assert!(predeath_attack_ordinals.iter().all(|ordinal| { evidence .iter() .filter(|row| row.event_ordinal == *ordinal && row.rule == "melee") .count() == 2 })); assert_eq!( evidence .iter() .filter(|row| row.event_ordinal == postrestore_attack && row.rule == "melee") .count(), 2 ); assert!(evidence .windows(2) .all(|rows| rows[0].event_ordinal <= rows[1].event_ordinal)); assert!(evidence .iter() .all(|row| { row.subtitle == hexwork_core::epitaphs::subtitle(&row.rule) })); let checkpoint_cause_count = story .story_checkpoint .as_ref() .unwrap() .state .image .causes .len(); // The post-restore walk records environment causes for its Move ticks, so the // durability guarantee this guards is that no melee evidence precedes the // post-restore attack. let restored_baseline_melee: Vec<String> = evidence .iter() .filter(|row| { row.event_ordinal > resurrection_ordinal && row.event_ordinal < postrestore_attack && row.rule == "melee" }) .map(|row| row.rule.clone()) .collect(); assert!( restored_baseline_melee.is_empty(), "unexpected pre-attack melee baseline rows: {restored_baseline_melee:?}" ); assert!(checkpoint_cause_count <= story.state.causes.links().len()); }
#[test] fn tampered_erased_predeath_branch_fails_dead_hash() { let mut original = build_canonical_dead_story(); let original_dead_hash = original.state.try_hash_state().unwrap(); original.resolve_story_death().unwrap(); let claimed_resurrection = original.events.last().unwrap().clone(); assert!(matches!( claimed_resurrection.kind, SessionEventKind::StoryResurrect { dead_state_hash, .. } if dead_state_hash == original_dead_hash ));
let alternate = build_alternate_dead_story(); let alternate_dead_hash = alternate.state.try_hash_state().unwrap(); assert_ne!(alternate_dead_hash, original_dead_hash); let alternate_replay = alternate.encode_replay().unwrap(); let independently_replayed = GameSession::replay_from_bytes(content_root(), &alternate_replay, Mode::Story).unwrap(); assert_eq!( independently_replayed.state.try_hash_state().unwrap(), alternate_dead_hash );
let mut events = alternate.events.clone(); events.push(SessionEvent { pre_tick: alternate.state.tick, post_tick: claimed_resurrection.post_tick, kind: claimed_resurrection.kind, }); let tampered = encode_replay(&ProductionReplay { identity: alternate.identity.clone(), events, expected_final_tick: original.state.tick, expected_final_hash: original.state.try_hash_state().unwrap(), }) .unwrap(); let error = GameSession::replay_from_bytes(content_root(), &tampered, Mode::Story).unwrap_err(); assert_eq!(error.code, "replay-dead-hash-mismatch"); }
#[test] fn story_death_restores_town_checkpoint_and_can_repeat() { let mut story = GameSession::load_with_mode( content_root(), 217, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); assert!(story.story_checkpoint.is_some()); let checkpoint = story.story_checkpoint.clone().unwrap(); story.dispatch(CommittedAction::Wait).unwrap(); let player = story.player_id().unwrap(); let actor = story .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 0; actor.alive = false; story.resolve_story_death().unwrap(); let chapel = story.chapel_interaction().unwrap(); assert_eq!(chapel.position, [21, 5, 8]); assert_eq!(chapel.access, [20, 5, 8]); assert_eq!(story.player_cell().unwrap(), chapel.access); assert_eq!( story.state.run_location(), &RunLocation::Town(MILTHOLLOW.into()) ); let actor = story .state .actors .iter() .find(|actor| actor.id == player) .unwrap(); assert!(actor.alive); assert_eq!(actor.hp, STORY_RESURRECTION_HP); assert_eq!(story.journal().len(), 2); assert!(matches!( story.journal()[1].kind, SessionEventKind::StoryResurrect { .. } )); assert_eq!(story.state.chapel_debt(player).unwrap().burials_owed, 1); let active = story.story_checkpoint.as_ref().unwrap(); assert_eq!(active.after_event_count, checkpoint.after_event_count); assert_eq!(active.state.tick, checkpoint.state.tick); assert_eq!(active.state.hash, checkpoint.state.hash); assert_eq!(story.state.validate_runtime(), Ok(())); assert!(story.state.try_hash_state().is_ok());
story.state.work_chapel_debt(player).unwrap(); let actor = story .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 0; actor.alive = false; story.resolve_story_death().unwrap(); assert_eq!(story.state.chapel_debt(player).unwrap().burials_owed, 1);
for mode in [Mode::Classic, Mode::Ironcartographer] { let mut game = GameSession::load_with_mode(content_root(), 218, mode, setup(Calling::Lampbearer)) .unwrap(); let player = game.player_id().unwrap(); let actor = game .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 0; actor.alive = false; assert_eq!( game.resolve_story_death().unwrap_err().code, "story-mode-required" ); } }
#[test] fn dead_player_dispatch_rejects_before_state_or_event_mutation() { let mut dead = GameSession::replay_from_bytes( content_root(), canonical_dead_story_replay(), Mode::Story, ) .unwrap(); let before_hash = dead.state.try_hash_state().unwrap(); let before_events = dead.events.clone(); let error = dead.dispatch(CommittedAction::Wait).unwrap_err(); assert_eq!(error.code, "player-dead"); assert_eq!(dead.state.try_hash_state().unwrap(), before_hash); assert_eq!(dead.events, before_events); }
/// Observation rows with the branch epoch stripped, for content comparison /// (the fold changes provenance, not the last-seen terrain facts). type ObservationContent = ( hexwork_core::observation::LocationKey, (i32, i32, i32), u8, u8, u8, u8, u64, );
fn strip_epoch( rows: &[hexwork_core::observation::ObservationRow], ) -> Vec<ObservationContent> { rows.iter() .map(|row| { ( row.location.clone(), row.cell, row.face, row.appearance.material, row.appearance.occupancy, row.appearance.door_appearance, row.appearance.observed_tick, ) }) .collect() }
/// Give air cells around the player positive local light so the canonical /// observation pass records real terrain. Content ships unlit terrain (the /// Light phase runs a dirty-only pass), so this mirrors the composed /// `observed_town` fixture the presentation tests use. fn light_air_around_player(game: &mut GameSession, radius: i32) { let player = game.player_id().unwrap(); let cell = actor_cell(&game.state, player).unwrap(); for dz in -2..=2i32 { for dy in -radius..=radius { for dx in -radius..=radius { let (x, y, z) = (cell[0] + dx, cell[1] + dy, cell[2] + dz); let voxel = game.state.grid.get_world(x, y, z); if voxel.occ == 0 { game.state .grid .set_world(x, y, z, Voxel { light: 8, ..voxel }); } } } } }
#[test] fn story_memory_pending_action_fold() { // A Story checkpoint, then post-checkpoint discoveries, then a pending // action live at death. Resurrection must FOLD back onto the checkpoint: // the post-checkpoint discoveries are gone, the restored memory is // exactly the checkpoint's, re-stamped to the incremented epoch, and the // pending action is aborted, not resumed (docs/fluid §7). let mut story = GameSession::load_with_mode( content_root(), 246, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); // Content ships unlit terrain (the Light phase runs a dirty-only pass), // so the canonical observation pass has no positive light to sample. // Compose a lit region around the player, as the presentation-side // `observed_town` fixture does, so the pass records real terrain. light_air_around_player(&mut story, 4); // Observe at tick zero so the checkpoint keeps the valid zero prefix (a // non-zero prefix must end on a town-entry transition). story.state.observe_player().unwrap(); let checkpoint = story.build_checkpoint(0).unwrap(); story.story_checkpoint = Some(checkpoint.clone()); *story.checkpoint_witness.borrow_mut() = None; let checkpoint_rows = checkpoint.state.image.observations.clone(); assert!( !checkpoint_rows.is_empty(), "the checkpoint records observed terrain" ); let checkpoint_epoch = checkpoint.state.image.branch_epoch; assert_eq!(story.state.branch_epoch, checkpoint_epoch);
// Post-checkpoint discoveries: a face the checkpoint observed changes // appearance and is re-observed at a later tick, so the dead branch's // memory is no longer the checkpoint's. let probe = checkpoint_rows .iter() .find(|row| row.appearance.occupancy != 0) .expect("a solid observed face") .clone(); let mut probe_voxel = story .state .grid .get_world(probe.cell.0, probe.cell.1, probe.cell.2); probe_voxel.material = if probe_voxel.material == hexwork_core::voxel::material::STONE { hexwork_core::voxel::material::GLASS } else { hexwork_core::voxel::material::STONE }; story .state .grid .set_world(probe.cell.0, probe.cell.1, probe.cell.2, probe_voxel); story.advance_tick().unwrap(); let discovered_rows = story.state.observation_rows(); assert_ne!( strip_epoch(&discovered_rows), strip_epoch(&checkpoint_rows), "the dead branch discovered terrain after the checkpoint" );
// A pending action live at death. let item = player_ground_item(&mut story); story .admit_action(&CommittedAction::Pickup { item_id: item }) .unwrap(); assert!(story.has_pending_action());
// Kill the player directly, leaving the descriptor live at death. let player = story.player_id().unwrap(); { let actor = story .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 0; actor.alive = false; } // The proof path replays the journaled events to derive the checkpoint; // this injected checkpoint is accepted directly, so skip the proof and // exercise the fold itself. story.resolve_story_death_inner(false).unwrap();
// Epoch is incremented and re-stamped on every restored record. let restored_epoch = story.state.branch_epoch; assert_eq!(restored_epoch, checkpoint_epoch + 1); let restored_rows = story.state.observation_rows(); assert!(restored_rows .iter() .all(|row| row.appearance.branch_epoch == restored_epoch));
// Restored memory is exactly the checkpoint's (post-checkpoint // discoveries are lost — a fold, not a merge). assert_eq!(strip_epoch(&restored_rows), strip_epoch(&checkpoint_rows)); assert_eq!( story.state.run_location(), &checkpoint.state.image.run_location );
// The pending action is aborted, not resumed. assert!(!story.has_pending_action()); assert!(story.state.pending.is_none());
// The fold is journaled with distinct death/checkpoint/restored evidence. assert!(matches!( story.events.last().unwrap().kind, SessionEventKind::StoryResurrect { dead_state_hash, checkpoint_hash, restored_state_hash, .. } if dead_state_hash != restored_state_hash && checkpoint_hash != restored_state_hash )); }
#[test] fn story_fold_save_reload_replay() { // Resurrect, save, reload, replay: the fold (not a merge) reproduces the // same memory, epoch, timeline, and final hash through the REAL replay // driver. let mut story = GameSession::restore_from_bytes( content_root(), canonical_dead_story_run(), Mode::Story, ) .unwrap(); let checkpoint_rows = story .story_checkpoint .as_ref() .unwrap() .state .image .observations .clone(); let dead_epoch = story.state.branch_epoch; // The canonical dead run descended to Deep 2, whose terrain the // tick-boundary observation pass lit and recorded, so the dead branch's // memory is NOT the checkpoint's. The checkpoint is the tick-zero town // entry, and unlit town content records no faces there, so it is empty // (observed: 0 rows), while the dead branch holds Deep-2 rows. The fold // below must therefore DISCARD those post-checkpoint discoveries. The // non-empty restore side is covered by // `story_memory_pending_action_fold`. let dead_rows = strip_epoch(&story.state.observation_rows()); assert!( !dead_rows.is_empty(), "the dead branch recorded deep terrain after the checkpoint" ); assert_ne!( dead_rows, strip_epoch(&checkpoint_rows), "the dead branch holds post-checkpoint discoveries, not the checkpoint's memory" ); assert!( checkpoint_rows.is_empty(), "the tick-zero town checkpoint records no observed terrain" );
story.resolve_story_death().unwrap(); let restored_epoch = story.state.branch_epoch; assert_eq!(restored_epoch, dead_epoch + 1); let restored_rows = story.state.observation_rows(); assert_eq!(strip_epoch(&restored_rows), strip_epoch(&checkpoint_rows)); assert!(restored_rows .iter() .all(|row| row.appearance.branch_epoch == restored_epoch));
// Save the folded run and reload it: memory, epoch, timeline, and hash. let run = story.encode_current_run().unwrap(); let reloaded = GameSession::restore_from_bytes(content_root(), &run, Mode::Story).unwrap(); assert_eq!(reloaded.state.branch_epoch, restored_epoch); assert_eq!( strip_epoch(&reloaded.state.observation_rows()), strip_epoch(&checkpoint_rows) ); assert_eq!(reloaded.events, story.events); assert_eq!( reloaded.state.try_hash_state().unwrap(), story.state.try_hash_state().unwrap() );
// Replay through the real driver applies the same fold. story.dispatch(CommittedAction::Wait).unwrap(); let replay = story.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay, Mode::Story).unwrap(); assert_eq!(replayed.events(), story.events()); assert_eq!(replayed.state.branch_epoch, restored_epoch); assert_eq!( strip_epoch(&replayed.state.observation_rows()), strip_epoch(&story.state.observation_rows()) ); assert_eq!( replayed.state.try_hash_state().unwrap(), story.state.try_hash_state().unwrap() ); }
#[test] fn event_capacity_rejects_action_and_resurrection_atomically() { let mut live = session(238); live.events = (0..MAX_SESSION_EVENTS) .map(|_| SessionEvent::action(0, 1, CommittedAction::Move(Direction::South))) .collect(); let before_hash = live.state.try_hash_state().unwrap(); assert_eq!( live.dispatch(CommittedAction::Wait).unwrap_err().code, "journal-capacity" ); assert_eq!(live.state.try_hash_state().unwrap(), before_hash);
let mut dead = GameSession::replay_from_bytes( content_root(), canonical_dead_story_replay(), Mode::Story, ) .unwrap(); dead.events.resize( MAX_SESSION_EVENTS, SessionEvent::action( dead.state.tick, dead.state.tick, CommittedAction::Move(Direction::South), ), ); let dead_hash = dead.state.try_hash_state().unwrap(); let checkpoint_hash = dead.story_checkpoint.as_ref().unwrap().state.hash; assert_eq!( dead.resolve_story_death().unwrap_err().code, "journal-capacity" ); assert_eq!(dead.state.try_hash_state().unwrap(), dead_hash); assert_eq!( dead.story_checkpoint.as_ref().unwrap().state.hash, checkpoint_hash ); assert_eq!(dead.events.len(), MAX_SESSION_EVENTS); }
#[test] fn replay_evidence_limit_rejects_without_unbounded_growth() { let mut game = session(239); descend_to(&mut game, 1); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") }) .unwrap() .id; let target_cell = actor_cell(&game.state, target).unwrap(); let access = adjacent_access(&game, target_cell); route(&mut game, access); game.dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); let bytes = game.encode_replay().unwrap(); // The journey spends one tick per Move now, so it records more evidence rows; // derive the exact count rather than hard-coding a stale fixture size. let replayed = GameSession::replay_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); let rows = replayed.replay_evidence().len(); assert!(rows > 0, "the journey records replay evidence rows"); let exact = ReplayLimits { max_evidence_rows: rows, ..ReplayLimits::default() }; let replayed = GameSession::replay_from_bytes_with_limits( content_root(), &bytes, Mode::Classic, exact, ) .unwrap(); assert_eq!(replayed.replay_evidence().len(), rows); let error = match GameSession::replay_from_bytes_with_limits( content_root(), &bytes, Mode::Classic, ReplayLimits { max_evidence_rows: rows - 1, ..exact }, ) { Err(error) => error, Ok(_) => panic!("evidence cap+1 unexpectedly accepted"), }; assert_eq!(error.code, "replay-evidence-limit");
let limits = ReplayLimits { max_evidence_rows: 1, ..ReplayLimits::default() }; let mut exact_budget = ReplayBudget::new(TimelineCosts::default(), limits).unwrap(); let mut exact_rows = Vec::new(); capture_evidence_row( EvidenceRowMeta { event_ordinal: 0, segment: EvidenceSegment::Action, local_ordinal: 0, tick: 0, }, EvidenceRowText { rule: "melee", detail: &"x".repeat(4096 - "melee".len()), subtitle: "", }, &mut exact_budget, &mut exact_rows, || {}, ) .unwrap(); assert_eq!(exact_rows.len(), 1); assert_eq!(exact_budget.evidence_rows, 0);
let mut oversized_budget = ReplayBudget::new(TimelineCosts::default(), limits).unwrap(); let mut oversized_rows = Vec::new(); let clone_boundary_reached = std::cell::Cell::new(false); let oversized = capture_evidence_row( EvidenceRowMeta { event_ordinal: 0, segment: EvidenceSegment::Action, local_ordinal: 0, tick: 0, }, EvidenceRowText { rule: "melee", detail: &"x".repeat(4097 - "melee".len()), subtitle: "", }, &mut oversized_budget, &mut oversized_rows, || clone_boundary_reached.set(true), ) .unwrap_err(); assert_eq!(oversized.code, "replay-evidence-text-limit"); assert!(!clone_boundary_reached.get()); assert!(oversized_rows.is_empty()); assert_eq!(oversized_budget.evidence_rows, 1); }
#[test] fn story_missing_or_corrupt_checkpoint_rejects_atomically() { let mut story = GameSession::load_with_mode( content_root(), 219, Mode::Story, setup(Calling::Lampbearer), ) .unwrap(); let player = story.player_id().unwrap(); let actor = story .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 0; actor.alive = false; story.story_checkpoint = None; let tick = story.state.tick; assert_eq!( story.resolve_story_death().unwrap_err().code, "story-checkpoint-missing" ); assert_eq!(story.state.tick, tick); assert!( !story .state .actors .iter() .find(|actor| actor.id == player) .unwrap() .alive );
let mut corrupt = story.build_checkpoint(0).unwrap(); corrupt.state.hash ^= 1; story.story_checkpoint = Some(corrupt); assert_eq!( story.resolve_story_death().unwrap_err().code, "story-checkpoint-hash-mismatch" ); assert!( !story .state .actors .iter() .find(|actor| actor.id == player) .unwrap() .alive ); }
#[test] fn transition_failures_do_not_teleport_or_skip_depth() { let mut game = session(140); let before = game.state.try_hash_state().unwrap(); assert_eq!( game.dispatch(CommittedAction::UseTransition { transition_id: 999_999 }) .unwrap_err() .code, "transition-unknown" ); assert_eq!(before, game.state.try_hash_state().unwrap()); let connector = game.first_connector(PlanLocation::Town).clone(); assert_eq!( game.dispatch(CommittedAction::UseTransition { transition_id: connector.transition_id, }) .unwrap_err() .code, "transition-out-of-range" ); assert_eq!( game.state.run_location(), &RunLocation::Town(MILTHOLLOW.into()) ); assert!(game .floor_catalog(12) .connectors .iter() .all(|connector| { connector.destination != PlanDestination::Deep { depth: 13 } })); assert!(game .floor_catalog(12) .interactions .values() .flatten() .any(|interaction| { matches!(interaction.kind, InteractionKind::Return) })); }
#[derive(Clone, Debug, PartialEq, Eq)] struct TransferSnapshot { canonical_hash: u64, diagnostic_hash: u64, tick: u64, history: Vec<SessionEvent>, checkpoint: Option<(u32, u64, u64)>, witness: Option<CheckpointWitness>, loose: Option<(Instance, (i32, i32, i32))>, carried: Option<Instance>, }
fn transfer_snapshot(game: &GameSession, item_id: u64) -> TransferSnapshot { let player = game.player_id().unwrap(); let loose = game.state.items.get(&item_id).cloned().map(|item| { let cell = game.state.ground_items[&item_id]; (item, cell) }); TransferSnapshot { canonical_hash: game.canonical_hash().unwrap(), diagnostic_hash: game.diagnostic_hash(), tick: game.tick(), history: game.events.clone(), checkpoint: game.story_checkpoint.as_ref().map(|checkpoint| { ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, ) }), witness: game.checkpoint_witness.borrow().clone(), loose, carried: game.state.packs[&player].items.get(&item_id).cloned(), } }
fn starter_item(game: &GameSession) -> (u64, Instance) { let player = game.player_id().unwrap(); game.state.packs[&player] .items .iter() .next() .map(|(&id, item)| (id, item.clone())) .unwrap() }
#[test] fn tinker_story_checkpoint_rollback() { let mut game = ratcatcher_wield_session(140, Mode::Story); let checkpoint = game.story_checkpoint.clone().unwrap(); let witness = game.checkpoint_witness.borrow().clone(); let baseline = checkpoint .state .clone() .into_state() .unwrap() .to_image() .unwrap(); let ids = game.synthetic_tinker_setup_for_test(); game.dispatch(CommittedAction::TinkerIdentify { item_id: ids[0] }) .unwrap(); assert!(game.tinker_known(ids[0])); assert_eq!( game.story_checkpoint.as_ref().unwrap().state.hash, checkpoint.state.hash ); assert_eq!(*game.checkpoint_witness.borrow(), witness); // Synthetic death fixture only for checkpoint rollback, not acquisition // or real-death evidence (the app real-combat test covers that boundary). let player = game.player_id().unwrap(); let actor = game .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 0; actor.alive = false; game.resolve_story_death().unwrap(); let restored = game.state.to_image().unwrap(); assert_eq!(restored.wallets, baseline.wallets); assert_eq!(restored.idf, baseline.idf); assert_eq!(restored.packs, baseline.packs); assert_eq!( game.story_checkpoint.as_ref().unwrap().state.hash, checkpoint.state.hash ); assert_eq!(*game.checkpoint_witness.borrow(), witness); assert!( matches!(game.events[0].kind, SessionEventKind::Action(CommittedAction::TinkerIdentify { item_id }) if item_id == ids[0]) ); assert!( matches!(game.events[1].kind, SessionEventKind::StoryResurrect { checkpoint_hash, .. } if checkpoint_hash == checkpoint.state.hash) ); }
#[test] fn tinker_contextual_priority() { // Synthetic priority overlap, not acquisition/transition evidence: retain // real catalog Tinker access and a real generated hostile, but relocate // that hostile and a catalog connector to overlap the service choice. let (mut overlap, player, target, _) = synthetic_polearm_lane(); overlap .state .transition_location(RunLocation::Town(MILTHOLLOW.into())) .unwrap(); overlap.synthetic_tinker_setup_for_test(); let cell = overlap.transfer_player_cell().unwrap(); let target_cell = [cell[0] + 1, cell[1], cell[2]]; overlap .state .actors .iter_mut() .find(|actor| actor.id == target) .unwrap() .position = cell_pos(target_cell); for base in [cell, target_cell] { for z in 0..=1 { let mut voxel = overlap.state.grid.get_world(base[0], base[1], base[2] + z); voxel.occ = 0; overlap .state .grid .set_world(base[0], base[1], base[2] + z, voxel); } } let town = overlap .catalog .locations .get_mut(&PlanLocation::Town) .unwrap(); let mut connector = town.connectors[0].clone(); connector.access = cell; let transition_id = connector.transition_id; town.connectors = vec![connector]; let mut physical = PlayController::default(); assert!(overlap.validate_tinker_access().is_ok()); assert_eq!(overlap.connectors_for(PlanLocation::Town)[0].access, cell); assert_eq!( melee_geometry(&overlap.state, &overlap.catalog, player, target), Ok(MeleeGeometry::Adjacent) ); assert_eq!(overlap.contextual_melee_target(player), Some(target)); assert_eq!( overlap.contextual_intent(), Some(ContextualIntent::OpenTinkerIdentify) ); assert_eq!( physical.adapt(Input::Confirm, &overlap), ControllerEvent::OpenTinkerIdentify );
overlap .catalog .locations .get_mut(&PlanLocation::Town) .unwrap() .interactions .get_mut(&cell_key(cell)) .unwrap() .retain( |entry| !matches!(&entry.kind, InteractionKind::Service { id } if id == "tinker"), ); assert!(overlap.validate_tinker_access().is_err()); assert_eq!(overlap.contextual_melee_target(player), Some(target)); let transition = CommittedAction::UseTransition { transition_id }; assert_eq!( overlap.contextual_intent(), Some(ContextualIntent::Commit(transition.clone())) ); assert_eq!( physical.adapt(Input::Confirm, &overlap), ControllerEvent::Dispatch { action: transition, source: DispatchSource::Play } );
overlap .catalog .locations .get_mut(&PlanLocation::Town) .unwrap() .connectors .clear(); assert!(overlap.connectors_for(PlanLocation::Town).is_empty()); assert_eq!( melee_geometry(&overlap.state, &overlap.catalog, player, target), Ok(MeleeGeometry::Adjacent) ); let attack = CommittedAction::ResolveAttack { target_actor: target, }; assert_eq!( overlap.contextual_intent(), Some(ContextualIntent::Commit(attack.clone())) ); assert_eq!( physical.adapt(Input::Confirm, &overlap), ControllerEvent::Dispatch { action: attack, source: DispatchSource::Play } ); assert!(overlap.contextual_melee_hint().is_some());
let mut game = ratcatcher_wield_session(140, Mode::Classic); game.synthetic_tinker_setup_for_test(); assert_eq!( game.contextual_intent(), Some(ContextualIntent::OpenTinkerIdentify) ); let mut controller = PlayController::default(); assert_eq!( controller.adapt(Input::Confirm, &game), ControllerEvent::OpenTinkerIdentify ); controller.board_open_succeeded(); assert!(!matches!( controller.adapt(Input::Confirm, &game), ControllerEvent::OpenTinkerIdentify )); let cell = game.transfer_player_cell().unwrap(); let entries = game .catalog .locations .get_mut(&PlanLocation::Town) .unwrap() .interactions .get_mut(&cell_key(cell)) .unwrap(); let mut overlap = entries[0].clone(); overlap.kind = InteractionKind::Inn; entries.push(overlap.clone()); overlap.kind = InteractionKind::NoticeBoard; entries.push(overlap); assert_eq!( game.contextual_intent(), Some(ContextualIntent::Commit(CommittedAction::UseService { service: Service::Inn })) ); let player = game.player_id().unwrap(); game.state .accept_contract(RATS_CONTRACT, player, 1, 25, 1, MILTHOLLOW, 1) .unwrap(); game.state .transition_location(RunLocation::Deep(1)) .unwrap(); game.state .advance_contract_objective(RATS_CONTRACT, player, 1) .unwrap(); game.state .transition_location(RunLocation::Town(MILTHOLLOW.into())) .unwrap(); assert_eq!( game.contextual_intent(), Some(ContextualIntent::Commit(CommittedAction::TurnIn { contract_id: RATS_CONTRACT.into() })) ); }
#[test] fn tinker_rejections_preserve_state() { for wrong_town in [false, true] { let mut game = ratcatcher_wield_session(140, Mode::Classic); let id = game.synthetic_tinker_setup_for_test()[0]; let mut image = game.state.to_image().unwrap(); if wrong_town { image.run_location = RunLocation::Town("elsewhere".into()); } else { let player = game.player_id().unwrap(); let actor = image.actors.iter_mut().find(|actor| actor.id == player).unwrap(); actor.position = actor.position.checked_translate([1000, 0, 0]).unwrap(); } game.state = State::from_image(image).unwrap(); let before = transfer_snapshot(&game, id); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: id }) .unwrap_err() .code, if wrong_town { "wrong-town" } else { "interaction-out-of-range" } ); assert_eq!(transfer_snapshot(&game, id), before); } for count in [0, 1, 2, 255] { let mut game = ratcatcher_wield_session(140, Mode::Classic); let id = game.synthetic_tinker_setup_for_test()[0]; game.state.idf.insert("hymn_card_blank".into(), count); let before = transfer_snapshot(&game, id); let wallet = game.tinker_wallet_cu().unwrap(); let result = game.dispatch(CommittedAction::TinkerIdentify { item_id: id }); if count < 2 { result.unwrap(); assert_eq!(game.state.idf["hymn_card_blank"], 2); assert_eq!(game.tinker_wallet_cu().unwrap(), wallet - 15); } else { assert_eq!(result.unwrap_err().class, SessionErrorClass::InputRejected); assert_eq!(transfer_snapshot(&game, id), before); assert_eq!(game.state.idf["hymn_card_blank"], count); } } for template in [ CORPSE_TEMPLATE, "missing_catalog_template", "hymn_card_blank", ] { let mut game = ratcatcher_wield_session(140, Mode::Classic); let id = game.synthetic_tinker_setup_for_test()[0]; let player = game.player_id().unwrap(); let item = game .state .packs .get_mut(&player) .unwrap() .items .get_mut(&id) .unwrap(); item.template = template.to_owned(); item.identified = template == "hymn_card_blank"; let before = transfer_snapshot(&game, id); let wallet = game.tinker_wallet_cu().unwrap(); let idf = game.state.idf.clone(); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: id }) .unwrap_err() .class, SessionErrorClass::InputRejected ); assert_eq!(transfer_snapshot(&game, id), before); assert_eq!(game.tinker_wallet_cu().unwrap(), wallet); assert_eq!(game.state.idf, idf); } let mut game = ratcatcher_wield_session(140, Mode::Classic); let ids = game.synthetic_tinker_setup_for_test(); let player = game.player_id().unwrap(); for id in [0, u64::MAX, starter_item(&game).0] { let before = transfer_snapshot(&game, ids[0]); let wallet = game.tinker_wallet_cu().unwrap(); let idf = game.state.idf.clone(); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: id }) .unwrap_err() .class, SessionErrorClass::InputRejected ); assert_eq!(transfer_snapshot(&game, ids[0]), before); assert_eq!(game.tinker_wallet_cu().unwrap(), wallet); assert_eq!(game.state.idf, idf); } let amount = game.tinker_wallet_cu().unwrap(); game.state.spend_copper(player, amount).unwrap(); let before = transfer_snapshot(&game, ids[0]); assert!(game.tinker_eligible(ids[0])); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: ids[0] }) .unwrap_err() .code, "tinker-insufficient-funds" ); assert_eq!(transfer_snapshot(&game, ids[0]), before); assert!(game .execute_nonrecording(player, &CommittedAction::TinkerIdentify { item_id: ids[0] }) .is_err()); }
#[test] fn tinker_virtual_event_capacity_atomic() { let mut game = ratcatcher_wield_session(140, Mode::Classic); let id = game.synthetic_tinker_setup_for_test()[0]; game.events = (0..MAX_SESSION_EVENTS) .map(|_| SessionEvent::action(0, 20, CommittedAction::TinkerIdentify { item_id: id })) .collect(); let before = transfer_snapshot(&game, id); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: id }) .unwrap_err() .code, "journal-capacity" ); assert_eq!(transfer_snapshot(&game, id), before); }
#[test] fn tinker_candidate_current_story_bounds_atomic() { for checkpoint_metadata in [false, true] { let mut game = ratcatcher_wield_session(140, Mode::Story); let id = game.synthetic_tinker_setup_for_test()[0]; let player = game.player_id().unwrap(); if checkpoint_metadata { game.story_checkpoint .as_mut() .unwrap() .state .image .packs .get_mut(&player) .unwrap() .items .values_mut() .next() .unwrap() .tags .push("x".repeat(4097)); } else { game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&id) .unwrap() .tags .push("x".repeat(4097)); } let before = transfer_snapshot(&game, id); let wallet = game.tinker_wallet_cu().unwrap(); let idf = game.state.idf.clone(); let pack = game.player_pack().unwrap().clone(); assert!(game .dispatch(CommittedAction::TinkerIdentify { item_id: id }) .is_err()); assert_eq!(transfer_snapshot(&game, id), before); assert_eq!(game.tinker_wallet_cu().unwrap(), wallet); assert_eq!(game.state.idf, idf); assert_eq!(game.player_pack().unwrap(), &pack); } for mode in [Mode::Classic, Mode::Story] { let mut game = ratcatcher_wield_session(140, mode); let id = game.synthetic_tinker_setup_for_test()[0]; let count = game.player_pack().unwrap().items.len(); game.synthetic_add_carried_weapons_for_test(8_193 - count); let before = transfer_snapshot(&game, id); let wallet = game.tinker_wallet_cu().unwrap(); let idf = game.state.idf.clone(); let pack = game.player_pack().unwrap().clone(); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: id }) .unwrap_err() .code, "transfer-limit" ); assert_eq!(transfer_snapshot(&game, id), before); assert_eq!(game.tinker_wallet_cu().unwrap(), wallet); assert_eq!(game.state.idf, idf); assert_eq!(game.player_pack().unwrap(), &pack); } let mut game = ratcatcher_wield_session(140, Mode::Story); let id = game.synthetic_tinker_setup_for_test()[0]; game.story_checkpoint.as_mut().unwrap().after_event_count = 1; let before = transfer_snapshot(&game, id); let wallet = game.tinker_wallet_cu().unwrap(); let idf = game.state.idf.clone(); assert_eq!( game.dispatch(CommittedAction::TinkerIdentify { item_id: id }) .unwrap_err() .class, SessionErrorClass::Runtime ); assert_eq!(transfer_snapshot(&game, id), before); assert_eq!(game.tinker_wallet_cu().unwrap(), wallet); assert_eq!(game.state.idf, idf); }
#[test] fn tinker_paid_exact_item_atomic_success() { let mut game = ratcatcher_wield_session(140, Mode::Story); let ids = game.synthetic_tinker_setup_for_test(); let player = game.player_id().unwrap(); let before = game.state.to_image().unwrap(); let history = game.events.clone(); let checkpoint = game .story_checkpoint .as_ref() .map(|checkpoint| (checkpoint.after_event_count, checkpoint.state.hash)); let witness = game.checkpoint_witness.borrow().clone(); let outcome = game .dispatch(CommittedAction::TinkerIdentify { item_id: ids[1] }) .unwrap(); assert!( matches!(outcome, ActionOutcome::TinkerIdentified { item_id, cost_cu: 15 } if item_id == ids[1]) ); assert_eq!( game.tinker_wallet_cu().unwrap(), before.wallets[&player].copper() - 15 ); assert!(ids.iter().all(|id| game.tinker_known(*id))); let after = game.state.to_image().unwrap(); // The tinker's twenty authored world ticks legitimately evolve the world // (item clocks, NPC intent, rot). Conservation still holds exactly: the // paid identification duplicates or loses no item. let total = |image: &hexwork_core::checkpoint::StateImage| { image.items.len() + image .packs .values() .map(|pack| pack.items.len()) .sum::<usize>() }; assert_eq!(total(&after), total(&before)); assert_eq!( after.wallets[&player].scrip(), before.wallets[&player].scrip() ); let mut expected_idf = before.idf.clone(); expected_idf.insert("hymn_card_blank".into(), 2); assert_eq!(after.idf, expected_idf); assert_eq!( game.story_checkpoint .as_ref() .map(|checkpoint| (checkpoint.after_event_count, checkpoint.state.hash)), checkpoint ); assert_eq!(*game.checkpoint_witness.borrow(), witness); assert_eq!(after.tick, before.tick + 20); assert_eq!(game.events[..history.len()], history); assert_eq!(game.events.len(), history.len() + 1); assert_eq!( game.events.last(), Some(&SessionEvent::action( before.tick, before.tick + 20, CommittedAction::TinkerIdentify { item_id: ids[1] } )) ); }
#[test] fn fixed_contextual_committed_access_and_stable_melee_geometry() { let mut town = session(223); let player = town.player_id().unwrap(); let access = town.catalog.location(PlanLocation::Town).unwrap().interactions.values().flatten() .find(|e| matches!(e.kind, InteractionKind::Inn)).unwrap().access; let feet = cell_pos(access); town.state.actors.iter_mut().find(|a| a.id == player).unwrap().position = feet; assert!(town.require_access(player,PlanLocation::Town,|k| matches!(k,InteractionKind::Inn)).is_ok()); assert!(matches!(town.contextual_intent(),Some(ContextualIntent::Commit(CommittedAction::UseService { service: Service::Inn })))); town.state.actors.iter_mut().find(|a| a.id == player).unwrap().position = feet.checked_translate([751,0,0]).unwrap(); assert!(town.require_access(player,PlanLocation::Town,|k| matches!(k,InteractionKind::Inn)).is_err()); assert!(!matches!(town.contextual_intent(),Some(ContextualIntent::Commit(CommittedAction::UseService { service: Service::Inn })))); let (mut game, player, target, _) = synthetic_polearm_lane(); assert_eq!(game.contextual_melee_target(player),Some(target)); let old_hp = game.state.actors.iter().find(|a| a.id == target).unwrap().hp; game.dispatch(CommittedAction::ResolveAttack { target_actor: target }).unwrap(); assert!(game.state.actors.iter().find(|a| a.id == target).unwrap().hp < old_hp); let actor = game.state.actors.iter_mut().find(|a| a.id == target).unwrap(); actor.position = actor.position.checked_translate([10_000,0,0]).unwrap(); let before = game.state.diagnostic_hash(); assert!(game.dispatch(CommittedAction::ResolveAttack { target_actor: target }).is_err()); assert_eq!(game.state.diagnostic_hash(),before); }
#[test] fn heading_bins_match_core_table_compass() { // Guard the explicit game table against the canonical core unit vectors. for (direction, expect) in [ (Direction::North, [0i64, -1i64]), (Direction::East, [1, 0]), (Direction::South, [0, 1]), (Direction::West, [-1, 0]), (Direction::NorthEast, [1, -1]), (Direction::SouthEast, [1, 1]), (Direction::SouthWest, [-1, 1]), (Direction::NorthWest, [-1, -1]), ] { let unit = heading_for(&direction).unit_vector(); assert_eq!(unit[0].signum(), expect[0], "{direction:?} x"); assert_eq!(unit[1].signum(), expect[1], "{direction:?} y"); } }
/// Cleared, grounded lane around the real player for movement samples. /// Synthetic geometry only; never generated-route evidence. fn movement_lane(seed: u64) -> (GameSession, u64) { let mut game = session(seed); let player = game.player_id().unwrap(); let base = game.transfer_player_cell().unwrap(); for x in -3..=3 { for y in -3..=3 { game.state.grid.set_world( base[0] + x, base[1] + y, base[2] - 1, Voxel { material: hexwork_core::voxel::material::STONE, occ: 255, ..Voxel::default() }, ); for z in 0..=1 { let cell = [base[0] + x, base[1] + y, base[2] + z]; let mut voxel = game.state.grid.get_world(cell[0], cell[1], cell[2]); voxel.material = hexwork_core::voxel::material::AIR; voxel.occ = 0; game.state.grid.set_world(cell[0], cell[1], cell[2], voxel); } } } (game, player) }
#[test] fn movement_unobstructed_advances_300mm_in_exactly_one_tick() { let (mut game, player) = movement_lane(223); let before_tick = game.state.tick; let before = game .state .actors .iter() .find(|a| a.id == player) .unwrap() .position .millimetres(); game.dispatch(CommittedAction::Move(Direction::East)).unwrap(); assert_eq!(game.state.tick, before_tick + 1, "one Move spends exactly one tick"); let after = game .state .actors .iter() .find(|a| a.id == player) .unwrap() .position .millimetres(); assert_eq!( [after[0] - before[0], after[1] - before[1], after[2] - before[2]], [300, 0, 0], "east must advance exactly 300mm of the 3000mm/s sample" ); assert_eq!(game.events.len(), 1, "exactly one journal event per Move"); assert_eq!(game.events[0].pre_tick, before_tick); assert_eq!(game.events[0].post_tick, before_tick + 1); }
#[test] fn blocked_move_still_spends_exactly_one_tick_without_transit() { let (mut game, player) = movement_lane(223); let base = game.transfer_player_cell().unwrap(); for z in 0..=1 { let mut voxel = game.state.grid.get_world(base[0] + 1, base[1], base[2] + z); voxel.material = hexwork_core::voxel::material::STONE; voxel.occ = 255; game.state.grid.set_world(base[0] + 1, base[1], base[2] + z, voxel); } let before_tick = game.state.tick; let before = game.state.actors.iter().find(|a| a.id == player).unwrap().position; game.dispatch(CommittedAction::Move(Direction::East)).unwrap(); assert_eq!(game.state.tick, before_tick + 1, "a blocked Move still spends exactly one tick"); let actor = game.state.actors.iter().find(|a| a.id == player).unwrap(); assert!(actor.alive, "a blocked move is not a death"); assert_eq!(actor.position.cell(), before.cell(), "no teleport into the wall cell"); assert_eq!(game.events.len(), 1); assert_eq!(game.events[0].pre_tick, before_tick); assert_eq!(game.events[0].post_tick, before_tick + 1); }
#[test] fn movement_tick_is_save_replay_equivalent() { // Replay reconstructs the generated world from the pack, so this fixture // must not modify the grid; determinism, not translation, is the invariant. let mut original = session(223); original.dispatch(CommittedAction::Move(Direction::East)).unwrap(); original.dispatch(CommittedAction::Move(Direction::North)).unwrap(); original.dispatch(CommittedAction::Wait).unwrap(); for event in original.journal() { if matches!(&event.kind, SessionEventKind::Action(CommittedAction::Move(_))) { assert_eq!(event.post_tick, event.pre_tick + 1, "each Move spans exactly one tick"); } } let bytes = original.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(replayed.journal(), original.journal()); assert_eq!(replayed.state.tick, original.state.tick); assert_eq!( replayed.state.try_hash_state().unwrap(), original.state.try_hash_state().unwrap() ); }
fn ratcatcher_wield_session(seed: u64, mode: Mode) -> GameSession { let pack = RuntimePack::load(content_root()).unwrap(); let identity = identity( &pack, seed, mode, Calling::Ratcatcher, InitialLineage::default(), ); GameSession::from_identity(pack, identity).unwrap() }
/// Synthetic geometry fixture: real generated actors/kit, deliberately relocated /// actors and cleared lane. This is not generated-route process evidence. fn synthetic_polearm_lane() -> (GameSession, u64, u64, Cell) { let mut game = ratcatcher_wield_session(384, Mode::Classic); let pike = carried_pike(&game); game.dispatch(CommittedAction::Wield { item_id: pike }) .unwrap(); descend_to(&mut game, 1); let player = game.player_id().unwrap(); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Monster) }) .unwrap() .id; let base = game.transfer_player_cell().unwrap(); for x in -3..=3 { for y in -3..=3 { game.state.grid.set_world(base[0]+x, base[1]+y, base[2]-1, Voxel { material: hexwork_core::voxel::material::STONE, occ:255, ..Voxel::default() }); for z in 0..=1 { let cell = [base[0] + x, base[1] + y, base[2] + z]; let mut voxel = game.state.grid.get_world(cell[0], cell[1], cell[2]); voxel.material = hexwork_core::voxel::material::AIR; voxel.occ = 0; game.state.grid.set_world(cell[0], cell[1], cell[2], voxel); } } } for actor in &mut game.state.actors { if actor.id == target { actor.position = cell_pos([base[0] + 2, base[1], base[2]]); } else if actor.id != player && checked_actor_cell(actor.position).is_some_and(|cell| { cell[2] == base[2] && (i64::from(cell[0]) - i64::from(base[0])).abs() <= 3 && (i64::from(cell[1]) - i64::from(base[1])).abs() <= 3 }) { actor.position = cell_pos([base[0] + 5, base[1] + 5, base[2]]); } } (game, player, target, base) }
#[test] fn polearm_surface_reach_is_clear_and_non_piercing() { let (base_game, player, target, base) = synthetic_polearm_lane(); assert_eq!( melee_geometry(&base_game.state, &base_game.catalog, player, target), Ok(MeleeGeometry::Extended) ); for offset in [[3, 0, 0], [2, 2, 0], [2, 1, 0], [2, 0, 1], [0, 0, 0]] { let mut game = clone_session(&base_game); game.state .actors .iter_mut() .find(|actor| actor.id == target) .unwrap() .position = cell_pos([ base[0] + offset[0], base[1] + offset[1], base[2] + offset[2], ]); let expected_in_range = matches!(offset, [2,2,0] | [2,1,0] | [0,0,0]); assert_eq!(melee_geometry(&game.state, &game.catalog, player, target).is_ok(), expected_in_range, "offset {offset:?}"); } for x in 0..=2 { for z in 0..=1 { let mut game = clone_session(&base_game); let cell = [base[0] + x, base[1], base[2] + z]; let mut voxel = game.state.grid.get_world(cell[0], cell[1], cell[2]); // Core solidity is `material != AIR`, so a solid wall needs a real // material, not just the legacy occupancy flag. voxel.material = hexwork_core::voxel::material::STONE; voxel.occ = 1; game.state.grid.set_world(cell[0], cell[1], cell[2], voxel); assert_eq!(melee_geometry(&game.state, &game.catalog, player, target).is_err(), z == 0, "500mm LOS blocked {x}/{z}"); } } let mut blocked = clone_session(&base_game); let blocker = blocked .state .actors .iter_mut() .find(|actor| actor.id != player && actor.id != target && actor.alive && actor.hp > 0) .unwrap(); blocker.position = cell_pos([base[0] + 1, base[1], base[2]]); let blocker_id = blocker.id; assert!(melee_geometry(&blocked.state, &blocked.catalog, player, target).is_err()); // Synthetic pending-death boundary: alive remains authoritative until // reconciliation, even when HP has reached zero. blocked .state .actors .iter_mut() .find(|actor| actor.id == blocker_id) .unwrap() .hp = 0; assert!(melee_geometry(&blocked.state, &blocked.catalog, player, target).is_err()); blocked .state .actors .iter_mut() .find(|actor| actor.id == blocker_id) .unwrap() .alive = false; assert_eq!( melee_geometry(&blocked.state, &blocked.catalog, player, target), Ok(MeleeGeometry::Extended) ); let mut unarmed = clone_session(&base_game); unarmed .state .packs .get_mut(&player) .unwrap() .equipped_weapon = None; assert_eq!( melee_geometry(&unarmed.state, &unarmed.catalog, player, target), Err(MeleeGeometryError::OutOfRange) ); // Synthetic blade eligibility: keep the instance but remove its polearm tag. let pike = carried_pike(&base_game); let mut blade = clone_session(&base_game); blade .state .packs .get_mut(&player) .unwrap() .items .get_mut(&pike) .unwrap() .tags .retain(|tag| tag != "polearm"); assert_eq!( melee_geometry(&blade.state, &blade.catalog, player, target), Err(MeleeGeometryError::OutOfRange) ); // Existing adjacent diagonal sides still block, while cardinal adjacency // does not acquire the extended endpoint-clearance requirement. let mut adjacent = clone_session(&base_game); adjacent .state .actors .iter_mut() .find(|actor| actor.id == target) .unwrap() .position = cell_pos([base[0] + 1, base[1] + 1, base[2]]); assert_eq!( melee_geometry(&adjacent.state, &adjacent.catalog, player, target), Ok(MeleeGeometry::Adjacent) ); let mut voxel = adjacent .state .grid .get_world(base[0] + 1, base[1], base[2] + 1); // Core solidity is `material != AIR`; the legacy occupancy flag alone is // not a wall for the melee line of sight. voxel.material = hexwork_core::voxel::material::STONE; voxel.occ = 1; adjacent .state .grid .set_world(base[0] + 1, base[1], base[2] + 1, voxel); assert_eq!(melee_geometry(&adjacent.state, &adjacent.catalog, player, target), Ok(MeleeGeometry::Adjacent)); adjacent.state.grid.set_world(base[0]+1,base[1],base[2],voxel); assert_eq!(melee_geometry(&adjacent.state, &adjacent.catalog, player, target), Err(MeleeGeometryError::CornerBlocked)); adjacent.state.grid.set_world(base[0]+1,base[1],base[2],Voxel::default()); adjacent .state .actors .iter_mut() .find(|actor| actor.id == target) .unwrap() .position = cell_pos([base[0] + 1, base[1], base[2]]); assert_eq!( melee_geometry(&adjacent.state, &adjacent.catalog, player, target), Ok(MeleeGeometry::Adjacent) ); // Synthetic active bounds exclude body endpoint or all new head samples. for head_only in [false, true] { let mut game = clone_session(&base_game); let bounds = &mut game .catalog .locations .get_mut(&PlanLocation::Deep(1)) .unwrap() .bounds; if head_only { bounds.max[2] = base[2]; } else { bounds.max[0] = base[0] + 1; } assert!(melee_geometry(&game.state, &game.catalog, player, target).is_err()); } for bad in [hexwork_core::spatial::MIN_POSITION_MM, hexwork_core::spatial::MAX_POSITION_MM] { let mut game = clone_session(&base_game); let actor = game.state.actors.iter_mut().find(|actor| actor.id == target).unwrap(); let mut mm = actor.position.millimetres(); mm[0] = bad; actor.position = FixedVec3::new(mm).unwrap(); assert!(melee_geometry(&game.state, &game.catalog, player, target).is_err()); } }
#[test] fn polearm_target_priority_and_return_strikes_are_honest() { let (mut game, player, target, base) = synthetic_polearm_lane(); assert_eq!(game.contextual_melee_target(player), Some(target)); assert!(game .contextual_melee_hint() .unwrap() .contains("beyond its return reach")); let hp = game .state .actors .iter() .find(|actor| actor.id == player) .unwrap() .hp; let causes = game.state.causes.len(); let first = game.resolve_attack(player, target).unwrap(); assert_eq!(game.state.causes.len(), causes + 1); assert!(!game .state .causes .links() .last() .unwrap() .detail .contains("retaliat")); assert!(matches!( first, ActionOutcome::AttackResolved { damage: 3, killed: false, retaliation_damage: None, .. } )); assert_eq!( game.state .actors .iter() .find(|actor| actor.id == player) .unwrap() .hp, hp ); let other = game .state .actors .iter() .filter(|actor| { actor.id != target && actor.alive && actor.hp > 3 && game .state .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Monster) }) .map(|actor| actor.id) .min() .unwrap(); game.state .actors .iter_mut() .find(|actor| actor.id == other) .unwrap() .position = cell_pos([base[0], base[1] + 1, base[2]]); assert_eq!(game.contextual_melee_target(player), Some(other)); // Equal-range ties are actor-ID ordered, independent of vector order. let mut tied = clone_session(&game); tied.state .actors .iter_mut() .find(|actor| actor.id == target) .unwrap() .position = cell_pos([base[0] + 1, base[1], base[2]]); tied.state.actors.reverse(); assert_eq!( tied.contextual_melee_target(player), Some(target.min(other)) ); tied.state .actors .iter_mut() .find(|actor| actor.id == target) .unwrap() .position = cell_pos([base[0] + 2, base[1], base[2]]); tied.state .actors .iter_mut() .find(|actor| actor.id == other) .unwrap() .position = cell_pos([base[0], base[1] + 2, base[2]]); assert_eq!( tied.contextual_melee_target(player), Some(target.min(other)) ); assert!(game.contextual_melee_hint().unwrap().contains("returns 3")); assert!(matches!( game.resolve_attack(player, other).unwrap(), ActionOutcome::AttackResolved { retaliation_damage: Some(3), .. } )); assert_eq!( game.state .actors .iter() .find(|actor| actor.id == player) .unwrap() .hp, hp - 3 ); assert!(matches!( game.resolve_attack(player, target).unwrap(), ActionOutcome::AttackResolved { killed: true, retaliation_damage: None, .. } )); }
fn carried_pike(game: &GameSession) -> u64 { game.player_pack() .unwrap() .items .values() .find(|item| item.has_tag("polearm")) .unwrap() .id }
#[test] fn wield_exact_owned_weapon_preflight_is_atomic() { let mut game = ratcatcher_wield_session(381, Mode::Story); let player = game.player_id().unwrap(); let pike = carried_pike(&game); let nonweapon = game .inventory_view(32) .unwrap() .entries .into_iter() .find(|entry| !entry.wieldable) .unwrap() .instance_id; for (id, code) in [ (u64::MAX, "wield-item-missing"), (nonweapon, "wield-not-weapon"), ] { let before = transfer_snapshot(&game, pike); let pack = game.player_pack().unwrap().clone(); let error = game .dispatch(CommittedAction::Wield { item_id: id }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::InputRejected, code) ); assert_eq!(transfer_snapshot(&game, pike), before); assert_eq!(game.player_pack().unwrap(), &pack); } game.dispatch(CommittedAction::Wield { item_id: pike }) .unwrap(); let before = transfer_snapshot(&game, pike); assert_eq!( game.dispatch(CommittedAction::Wield { item_id: pike }) .unwrap_err() .code, "wield-already" ); assert_eq!(transfer_snapshot(&game, pike), before);
// Synthetic curse/blessing fixture, not ordinary acquisition evidence. let incoming = game.synthetic_add_carried_weapons_for_test(1)[0]; game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&pike) .unwrap() .cursed = true; let before = transfer_snapshot(&game, incoming); let pack = game.player_pack().unwrap().clone(); assert_eq!( game.dispatch(CommittedAction::Wield { item_id: incoming }) .unwrap_err() .code, "wield-stuck" ); assert_eq!(transfer_snapshot(&game, incoming), before); assert_eq!(game.player_pack().unwrap(), &pack); game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&incoming) .unwrap() .blessed = true; let items = game.player_pack().unwrap().items.clone(); game.dispatch(CommittedAction::Wield { item_id: incoming }) .unwrap(); assert_eq!(game.player_pack().unwrap().wielded().unwrap().id, incoming); assert_eq!(game.player_pack().unwrap().items, items);
// Synthetic malformed retained checkpoint tests virtual-event admission. let mut invalid = ratcatcher_wield_session(382, Mode::Story); let pike = carried_pike(&invalid); invalid.story_checkpoint.as_mut().unwrap().after_event_count = 1; let before = transfer_snapshot(&invalid, pike); let error = invalid .dispatch(CommittedAction::Wield { item_id: pike }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Runtime, "transfer-preflight") ); assert_eq!(transfer_snapshot(&invalid, pike), before); // Synthetic oversized carried pack exercises structural admission after // candidate wield; the live equipment pointer and witness must stay put. let mut capacity = ratcatcher_wield_session(385, Mode::Story); let pike = carried_pike(&capacity); let count = capacity.player_pack().unwrap().items.len(); capacity.synthetic_add_carried_weapons_for_test(8_193 - count); let before = transfer_snapshot(&capacity, pike); let error = capacity .dispatch(CommittedAction::Wield { item_id: pike }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Runtime, "transfer-limit") ); assert_eq!(transfer_snapshot(&capacity, pike), before); // Wield is carried equipment, not a standing/support ground transfer. let mut unsupported = ratcatcher_wield_session(386, Mode::Classic); let pike = carried_pike(&unsupported); let cell = unsupported.transfer_player_cell().unwrap(); let mut below = unsupported .state .grid .get_world(cell[0], cell[1], cell[2] - 1); below.occ = 0; unsupported .state .grid .set_world(cell[0], cell[1], cell[2] - 1, below); unsupported .dispatch(CommittedAction::Wield { item_id: pike }) .unwrap(); assert_eq!( unsupported.player_pack().unwrap().wielded().unwrap().id, pike ); // Synthetic saturated journal exercises admission before equipment mutation. let pike = carried_pike(&invalid); invalid.events = (0..MAX_SESSION_EVENTS) .map(|_| SessionEvent::action(0, 10, CommittedAction::Wield { item_id: pike })) .collect(); let before = transfer_snapshot(&invalid, pike); assert_eq!( invalid .dispatch(CommittedAction::Wield { item_id: pike }) .unwrap_err() .code, "journal-capacity" ); assert_eq!(transfer_snapshot(&invalid, pike), before); }
#[test] fn wield_inventory_flags_follow_swap_paging_drop_and_private_names() { let mut game = ratcatcher_wield_session(388, Mode::Classic); let player = game.player_id().unwrap(); let first = carried_pike(&game); // Synthetic paged pack, explicitly distinct from ordinary acquisition. let ids = game.synthetic_add_carried_weapons_for_test(8); let last = *ids.last().unwrap(); game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&last) .unwrap() .identified = false; let title = game.inventory_item(last).unwrap().unwrap().tooltip.title; assert!( !game .inventory_item(last) .unwrap() .unwrap() .effective_identified ); game.dispatch(CommittedAction::Wield { item_id: first }) .unwrap(); assert!(game.inventory_item(first).unwrap().unwrap().wielded); let outcome = game .dispatch(CommittedAction::Wield { item_id: last }) .unwrap(); assert!( matches!(outcome,ActionOutcome::Wielded { ref item_name, .. } if *item_name == title) ); assert!(!game.inventory_item(first).unwrap().unwrap().wielded); let page = game .inventory_page(InventoryPageQuery::AtOrAfter(last)) .unwrap(); assert_eq!( page.entries .iter() .filter(|entry| entry.wielded) .map(|entry| entry.instance_id) .collect::<Vec<_>>(), vec![last] ); assert!( !game .inventory_item(last) .unwrap() .unwrap() .effective_identified ); game.dispatch(CommittedAction::Drop { item_id: last }) .unwrap(); assert!(game .inventory_view(32) .unwrap() .entries .iter() .all(|entry| !entry.wielded)); let ground = game.ground_item(last).unwrap().unwrap().0; assert!(!ground.wieldable && !ground.wielded); assert_eq!(ground.tooltip.title, title); }
#[test] fn wield_valid_corpse_rejects_but_missing_template_is_fatal() { let mut game = ratcatcher_wield_session(387, Mode::Classic); let player = game.player_id().unwrap(); let pike = carried_pike(&game); // Synthetic projection/preflight boundary, not an acquired corpse fixture. game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&pike) .unwrap() .template = CORPSE_TEMPLATE.into(); assert_eq!( game.validate_wield_selection(pike).unwrap_err().class, SessionErrorClass::InputRejected ); assert_eq!( game.validate_wield_selection(pike).unwrap_err().code, "wield-not-weapon" ); game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&pike) .unwrap() .template = "missing-wield-template".into(); assert_eq!( game.validate_wield_selection(pike).unwrap_err().class, SessionErrorClass::Runtime ); assert_eq!( game.validate_wield_selection(pike).unwrap_err().code, "wield-template-missing" ); }
#[test] fn wield_timed_save_load_and_replay_preserve_slot() { for mode in [Mode::Classic, Mode::Story] { let mut game = ratcatcher_wield_session(383, mode); let pike = carried_pike(&game); if mode == Mode::Story { game.checkpoint_witness.borrow_mut().take(); } let witness = game.checkpoint_witness.borrow().clone(); let items = game.player_pack().unwrap().items.clone(); let before_tick = game.tick(); let outcome = game .dispatch(CommittedAction::Wield { item_id: pike }) .unwrap(); assert!( matches!(outcome, ActionOutcome::Wielded { item_id, ref item_name } if item_id == pike && !item_name.is_empty() && item_name.len() <= TRANSFER_ITEM_NAME_LIMIT) ); assert_eq!(outcome.elapsed_ticks(), 10); assert_eq!( game.action_elapsed_ticks(&CommittedAction::Wield { item_id: pike }), 10 ); assert_eq!(game.tick(), before_tick + 10); assert_eq!(game.events.len(), 1); assert_eq!( game.events[0], SessionEvent::action( before_tick, before_tick + 10, CommittedAction::Wield { item_id: pike } ) ); assert_eq!(game.player_pack().unwrap().items, items); assert_eq!(*game.checkpoint_witness.borrow(), witness); let hash = game.canonical_hash().unwrap(); let restored = GameSession::restore_from_bytes( content_root(), &game.encode_current_run().unwrap(), mode, ) .unwrap(); let replayed = GameSession::replay_from_bytes( content_root(), &game.encode_replay().unwrap(), mode, ) .unwrap(); for result in [&restored, &replayed] { assert_eq!(result.canonical_hash().unwrap(), hash); assert_eq!(result.events, game.events); assert_eq!(result.player_pack().unwrap().items, items); assert_eq!(result.player_pack().unwrap().wielded().unwrap().id, pike); } } }
fn move_loose_item_to_player(game: &mut GameSession, item_id: u64) { let cell = tuple(game.transfer_player_cell().unwrap()); game.state.ground_items.insert(item_id, cell); }
#[test] fn transfer_dispatch_drop_then_pickup_conserves_exact_item_and_timed_events() { let mut game = session(360); let player = game.player_id().unwrap(); let (item_id, original) = starter_item(&game); let before_tick = game.tick(); let before_next_id = game.state.to_image().unwrap().next_id; let before_total = game.state.items.len() + game .state .packs .values() .map(|pack| pack.items.len()) .sum::<usize>();
let dropped = game.dispatch(CommittedAction::Drop { item_id }).unwrap(); assert!(matches!( dropped, ActionOutcome::Dropped { item_id: actual, ref item_name, } if actual == item_id && !item_name.is_empty() && item_name.len() <= TRANSFER_ITEM_NAME_LIMIT )); assert_eq!(dropped.elapsed_ticks(), 10); assert_eq!( game.action_elapsed_ticks(&CommittedAction::Drop { item_id }), 10 ); assert_eq!(game.tick(), before_tick + 10); assert_eq!(game.state.items[&item_id], original); assert_eq!( game.state.ground_items[&item_id], tuple(game.transfer_player_cell().unwrap()) ); assert!(!game.state.packs[&player].items.contains_key(&item_id));
let picked = game.dispatch(CommittedAction::Pickup { item_id }).unwrap(); assert!(matches!( picked, ActionOutcome::PickedUp { item_id: actual, ref item_name, } if actual == item_id && !item_name.is_empty() && item_name.len() <= TRANSFER_ITEM_NAME_LIMIT )); assert_eq!(picked.elapsed_ticks(), 10); assert_eq!( game.action_elapsed_ticks(&CommittedAction::Pickup { item_id }), 10 ); assert_eq!(game.tick(), before_tick + 20); assert_eq!(game.state.packs[&player].items[&item_id], original); assert!(!game.state.items.contains_key(&item_id)); assert!(!game.state.ground_items.contains_key(&item_id)); assert_eq!(game.state.to_image().unwrap().next_id, before_next_id); assert_eq!( game.state.items.len() + game .state .packs .values() .map(|pack| pack.items.len()) .sum::<usize>(), before_total ); assert_eq!(game.events.len(), 2); assert!(matches!( game.events[0].kind, SessionEventKind::Action(CommittedAction::Drop { item_id: actual }) if actual == item_id )); assert!(matches!( game.events[1].kind, SessionEventKind::Action(CommittedAction::Pickup { item_id: actual }) if actual == item_id )); assert_eq!(game.events[0].pre_tick, before_tick); assert_eq!(game.events[0].post_tick, before_tick + 10); assert_eq!(game.events[1].pre_tick, before_tick + 10); assert_eq!(game.events[1].post_tick, before_tick + 20); }
#[test] fn transfer_errors_keep_frozen_class_code_and_entire_session_unchanged() { let mut game = session(361); let player = game.player_id().unwrap(); let (item_id, _) = starter_item(&game); let missing = transfer_snapshot(&game, item_id); let error = game .dispatch(CommittedAction::Pickup { item_id: u64::MAX }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::InputRejected, "pickup-target-missing") ); assert_eq!(transfer_snapshot(&game, item_id), missing);
game.dispatch(CommittedAction::Drop { item_id }).unwrap(); let cell = game.transfer_player_cell().unwrap(); game.state .ground_items .insert(item_id, (cell[0] + 1, cell[1], cell[2])); let elsewhere = transfer_snapshot(&game, item_id); let error = game .dispatch(CommittedAction::Pickup { item_id }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::InputRejected, "pickup-target-elsewhere") ); assert_eq!(transfer_snapshot(&game, item_id), elsewhere);
let error = game .dispatch(CommittedAction::Drop { item_id }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::InputRejected, "drop-item-missing") ); assert_eq!(transfer_snapshot(&game, item_id), elsewhere);
game.state.ground_items.insert(item_id, tuple(cell)); game.dispatch(CommittedAction::Pickup { item_id }).unwrap(); game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&item_id) .unwrap() .cursed = true; let stuck = transfer_snapshot(&game, item_id); let error = game .dispatch(CommittedAction::Drop { item_id }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::InputRejected, "drop-item-stuck") ); assert_eq!(transfer_snapshot(&game, item_id), stuck); }
#[test] fn transfer_never_reveals_another_pack_owner() { let mut game = session(373); let player = game.player_id().unwrap(); let other_owner = game .state .actors .iter() .map(|actor| actor.id) .find(|actor_id| *actor_id != player) .unwrap(); let other_owner_name = game .state .identity(other_owner) .unwrap() .display_name .clone(); let (item_id, selected) = starter_item(&game); let mut image = game.state.to_image().unwrap(); let removed = image .packs .get_mut(&player) .unwrap() .items .remove(&item_id) .unwrap(); assert_eq!(removed, selected); image.packs.entry(other_owner).or_default().add(removed); let expected_next_id = image.next_id; game.state = State::from_image(image).unwrap();
let before_other_pack = game.state.packs[&other_owner].clone(); let before_selected = game.state.packs[&other_owner].items[&item_id].clone(); let before_history = game.events.clone(); let before_witness = game.checkpoint_witness.borrow().clone(); let error = game .dispatch(CommittedAction::Pickup { item_id }) .unwrap_err(); assert_eq!(error.class, SessionErrorClass::InputRejected); assert_eq!(error.code, "pickup-target-missing"); assert_eq!(error.detail, "selected ground item is gone"); assert!(!error.detail.contains(&other_owner.to_string())); assert!(!error.detail.contains(&other_owner_name)); assert_eq!(game.state.packs[&other_owner], before_other_pack); assert_eq!( game.state.packs[&other_owner].items[&item_id], before_selected ); assert!(!game.state.packs[&player].items.contains_key(&item_id)); assert!(!game.state.items.contains_key(&item_id)); assert!(!game.state.ground_items.contains_key(&item_id)); assert_eq!(game.state.to_image().unwrap().next_id, expected_next_id); assert_eq!(game.events, before_history); assert_eq!(*game.checkpoint_witness.borrow(), before_witness);
game.events = (0..MAX_SESSION_EVENTS) .map(|_| SessionEvent::action(game.tick(), game.tick() + 1, CommittedAction::Wait)) .collect(); let before_full_journal_pack = game.state.packs[&other_owner].clone(); let before_full_journal_witness = game.checkpoint_witness.borrow().clone(); let error = game .dispatch(CommittedAction::Pickup { item_id }) .unwrap_err(); assert_eq!(error.class, SessionErrorClass::Runtime); assert_eq!(error.code, "journal-capacity"); assert_eq!(game.events.len(), MAX_SESSION_EVENTS); assert_eq!(game.state.packs[&other_owner], before_full_journal_pack); assert_eq!( *game.checkpoint_witness.borrow(), before_full_journal_witness ); }
#[test] fn transfer_geometry_matches_occupancy_policy_and_checked_coordinates() { let base = session(362); let (item_id, _) = starter_item(&base); for (label, mutate) in [("body", 0_i8), ("head", 1_i8), ("support", -1_i8)] { let mut game = clone_session(&base); let cell = game.transfer_player_cell().unwrap(); let z = match mutate { -1 => cell[2] - 1, 0 => cell[2], 1 => cell[2] + 1, _ => unreachable!(), }; let mut voxel = game.state.grid.get_world(cell[0], cell[1], z); voxel.occ = u8::from(mutate >= 0); game.state.grid.set_world(cell[0], cell[1], z, voxel); let before = transfer_snapshot(&game, item_id); let error = game .dispatch(CommittedAction::Drop { item_id }) .unwrap_err(); assert_eq!(error.code, "transfer-location-blocked", "{label}"); assert_eq!(error.class, SessionErrorClass::InputRejected); assert_eq!(transfer_snapshot(&game, item_id), before); }
let mut fluid_and_actor = clone_session(&base); let cell = fluid_and_actor.transfer_player_cell().unwrap(); let mut body = fluid_and_actor .state .grid .get_world(cell[0], cell[1], cell[2]); body.fluid_amt = u8::MAX; fluid_and_actor .state .grid .set_world(cell[0], cell[1], cell[2], body); let player = fluid_and_actor.player_id().unwrap(); let other = fluid_and_actor .state .actors .iter_mut() .find(|actor| actor.id != player) .unwrap(); other.position = cell_pos(cell); fluid_and_actor .dispatch(CommittedAction::Drop { item_id }) .unwrap();
for position in [ [hexwork_core::spatial::MIN_POSITION_MM, 500, 0], [hexwork_core::spatial::MAX_POSITION_MM, 500, 0], [500, 500, -1_000_000], ] { let mut invalid = clone_session(&base); let player = invalid.player_id().unwrap(); invalid .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = FixedVec3::new(position).unwrap(); let before_actors = invalid .state .actors .iter() .map(|actor| (actor.id, actor.position.millimetres(), actor.hp, actor.alive)) .collect::<Vec<_>>(); let before_history = invalid.events.clone(); let before_checkpoint = invalid.story_checkpoint.as_ref().map(|checkpoint| { ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, ) }); let before_witness = invalid.checkpoint_witness.borrow().clone(); let before_carried = invalid.state.packs[&player].items[&item_id].clone(); let error = invalid .dispatch(CommittedAction::Drop { item_id }) .unwrap_err(); assert!(matches!((error.class,error.code), (SessionErrorClass::InputRejected,"transfer-location-blocked") | (SessionErrorClass::Runtime,"runtime-invalid") | (SessionErrorClass::Runtime,"transfer-runtime-invalid")), "{error:?}"); assert_eq!( invalid .state .actors .iter() .map(|actor| { (actor.id, actor.position.millimetres(), actor.hp, actor.alive) }) .collect::<Vec<_>>(), before_actors ); assert_eq!(invalid.events, before_history); assert_eq!( invalid.story_checkpoint.as_ref().map(|checkpoint| ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, )), before_checkpoint ); assert_eq!(*invalid.checkpoint_witness.borrow(), before_witness); assert_eq!(invalid.state.packs[&player].items[&item_id], before_carried); assert!(!invalid.state.items.contains_key(&item_id)); assert!(!invalid.state.ground_items.contains_key(&item_id)); } }
#[test] fn transfer_structural_preflight_rejections_preserve_live_session() { let mut capacity = session(363); let player = capacity.player_id().unwrap(); let (_, prototype) = starter_item(&capacity); let mut image = capacity.state.to_image().unwrap(); let mut next = image.next_id.max(100_000); while image.packs[&player].items.len() < 8_192 { let mut item = prototype.clone(); item.id = next; image.packs.get_mut(&player).unwrap().add(item); next += 1; } image.next_id = next; capacity.state = State::from_image(image).unwrap(); let ground_id = *capacity.state.items.keys().next().unwrap(); move_loose_item_to_player(&mut capacity, ground_id); let before_hash = capacity.canonical_hash().unwrap(); let before_loose = capacity.state.items[&ground_id].clone(); let before_ground_cell = capacity.state.ground_items[&ground_id]; let before_player_pack_has_selected = capacity.state.packs[&player].items.contains_key(&ground_id); let before_next_id = capacity.state.to_image().unwrap().next_id; let before_history = capacity.events.clone(); let before_checkpoint = capacity.story_checkpoint.as_ref().map(|checkpoint| { ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, ) }); let before_witness = capacity.checkpoint_witness.borrow().clone(); let error = capacity .dispatch(CommittedAction::Pickup { item_id: ground_id }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Runtime, "transfer-limit") ); assert_eq!(capacity.canonical_hash().unwrap(), before_hash); assert_eq!(capacity.state.items[&ground_id], before_loose); assert_eq!(capacity.state.ground_items[&ground_id], before_ground_cell); assert_eq!( capacity.state.packs[&player].items.contains_key(&ground_id), before_player_pack_has_selected ); assert!(!before_player_pack_has_selected); assert_eq!(capacity.state.to_image().unwrap().next_id, before_next_id); assert_eq!(capacity.events, before_history); assert_eq!( capacity.story_checkpoint.as_ref().map(|checkpoint| ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, )), before_checkpoint ); assert_eq!(*capacity.checkpoint_witness.borrow(), before_witness);
let mut malformed_story = session_from_lineage(364, Mode::Story, InitialLineage::default()); let (item_id, _) = starter_item(&malformed_story); malformed_story .story_checkpoint .as_mut() .unwrap() .after_event_count = 1; let before = transfer_snapshot(&malformed_story, item_id); let error = malformed_story .dispatch(CommittedAction::Drop { item_id }) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Runtime, "transfer-preflight") ); assert_eq!(transfer_snapshot(&malformed_story, item_id), before); }
#[test] fn transfer_preserves_existing_verified_story_witness_before_persistence() { let mut game = session_from_lineage(374, Mode::Story, InitialLineage::default()); let expected = game.checkpoint_witness.borrow().clone().unwrap(); let expected_fields = ( expected.count, expected.tick, expected.hash, expected.prefix_digest, expected.verified_event_count, expected.verified_history_digest, ); let (item_id, _) = starter_item(&game);
game.dispatch(CommittedAction::Drop { item_id }).unwrap(); let after_drop = game.checkpoint_witness.borrow().clone().unwrap(); assert_eq!( ( after_drop.count, after_drop.tick, after_drop.hash, after_drop.prefix_digest, after_drop.verified_event_count, after_drop.verified_history_digest, ), expected_fields );
game.dispatch(CommittedAction::Pickup { item_id }).unwrap(); let after_pickup = game.checkpoint_witness.borrow().clone().unwrap(); assert_eq!( ( after_pickup.count, after_pickup.tick, after_pickup.hash, after_pickup.prefix_digest, after_pickup.verified_event_count, after_pickup.verified_history_digest, ), expected_fields ); }
#[test] fn transfer_preserves_lazy_checkpoint_witness_and_roundtrips_run_replay() { for mode in [Mode::Classic, Mode::Story] { let mut game = session_from_lineage(365, mode, InitialLineage::default()); if mode == Mode::Story { game.checkpoint_witness.borrow_mut().take(); } let witness_before = game.checkpoint_witness.borrow().clone(); let (item_id, _) = starter_item(&game); game.dispatch(CommittedAction::Drop { item_id }).unwrap(); game.dispatch(CommittedAction::Pickup { item_id }).unwrap(); assert_eq!(*game.checkpoint_witness.borrow(), witness_before); let expected_hash = game.canonical_hash().unwrap(); let run = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &run, mode).unwrap(); assert_eq!(restored.canonical_hash().unwrap(), expected_hash); assert_eq!(restored.events, game.events); let replay = game.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay, mode).unwrap(); assert_eq!(replayed.canonical_hash().unwrap(), expected_hash); assert_eq!(replayed.events, game.events); } }
fn replace_player_inventory(game: &mut GameSession, count: usize) -> Vec<u64> { let player = game.player_id().unwrap(); let prototype = game.state.packs[&player] .items .values() .next() .unwrap() .clone(); let mut image = game.state.to_image().unwrap(); let carried = image.packs.get_mut(&player).unwrap(); carried.items.clear(); let mut ids = Vec::with_capacity(count); for index in 0..count { let id = 10_000 + u64::try_from(index).unwrap() * 2; let mut instance = prototype.clone(); instance.id = id; carried.add(instance); ids.push(id); } image.next_id = ids.last().map_or(10_000, |id| id + 1); game.state = State::from_image(image).unwrap(); ids }
fn install_ground_pile(game: &mut GameSession, count: usize, first_id: u64) -> Vec<u64> { let (_, prototype) = starter_item(game); let cell = tuple(game.transfer_player_cell().unwrap()); let mut image = game.state.to_image().unwrap(); let mut ids = Vec::with_capacity(count); for offset in 0..count { let id = first_id + u64::try_from(offset).unwrap() * 2; let mut item = prototype.clone(); item.id = id; image.items.insert(id, item); image.ground_items.insert(id, cell); ids.push(id); } image.next_id = image.next_id.max(ids.last().map_or(first_id, |id| id + 1)); game.state = State::from_image(image).unwrap(); ids }
// ---- docs/fluid §4.2-4.4: stage-2 pending-action session coverage ----
fn player_ground_item(game: &mut GameSession) -> u64 { install_ground_pile(game, 1, 900_000)[0] }
#[test] fn timed_transfer_preserves_intervening_world() { let mut game = session(601); let player = game.player_id().unwrap(); let item = player_ground_item(&mut game); let start = game.tick(); assert!(matches!( game.admit_action(&CommittedAction::Pickup { item_id: item }) .unwrap(), ActionAdmission::Admitted { total_ticks: 10 } )); assert_eq!(game.pending_remaining_ticks(), Some(10)); assert!( !game.state.packs[&player].items.contains_key(&item), "the effect is deferred, not immediate" ); for step in 1..=10u64 { let progress = game.advance_tick().unwrap(); if step < 10 { assert!(matches!(progress, TickProgress::Advanced { .. }), "step {step}"); assert_eq!(game.pending_remaining_ticks(), Some(10 - step)); } else { assert!(matches!(progress, TickProgress::Completed { .. })); } } assert_eq!(game.tick(), start + 10, "ten world ticks elapsed"); assert!(!game.has_pending_action()); assert!(game.state.packs[&player].items.contains_key(&item)); let last = game.events.last().unwrap(); assert_eq!( last.post_tick - last.pre_tick, 10, "journal span equals the authored cost" ); }
#[test] fn death_on_completion_tick_suppresses_effect() { let mut game = session(602); let player = game.player_id().unwrap(); let item = player_ground_item(&mut game); game.admit_action(&CommittedAction::Pickup { item_id: item }) .unwrap(); for _ in 0..9 { game.advance_tick().unwrap(); } assert_eq!(game.pending_remaining_ticks(), Some(1)); game.state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .hp = 0; assert!(matches!( game.advance_tick().unwrap(), TickProgress::LifecycleRequired { .. } )); assert!(!game.has_pending_action(), "death aborts the pending action"); assert!( game.state.items.contains_key(&item), "no effect is applied on the death tick" ); assert!(!game.state.packs[&player].items.contains_key(&item)); }
#[test] fn timed_action_target_disappears() { let mut game = session(603); let player = game.player_id().unwrap(); let item = player_ground_item(&mut game); game.admit_action(&CommittedAction::Pickup { item_id: item }) .unwrap(); for _ in 0..5 { game.advance_tick().unwrap(); } let events_before = game.events.len(); // The exact target leaves the cell mid-action; it must not retarget. game.state.ground_items.insert(item, (900, 900, 9)); let mut last = None; while game.has_pending_action() { last = Some(game.advance_tick().unwrap()); } let outcome = match last.unwrap() { TickProgress::Completed { outcome, .. } => outcome, other => panic!("expected completion, got {other:?}"), }; assert!(outcome.is_err(), "a stale target reports failure"); assert_eq!( game.events.len(), events_before, "a failed action is not journaled" ); assert!(game.state.items.contains_key(&item)); assert!(!game.state.packs[&player].items.contains_key(&item)); }
#[test] fn timed_transfer_currency_atomicity() { let mut game = session(604); let player = game.player_id().unwrap(); let item = player_ground_item(&mut game); let wallet = game.state.wallet(player).unwrap().copper(); let tick = game.tick(); // A request rejected before commitment spends no ticks and no currency. assert!(game .admit_action(&CommittedAction::Pickup { item_id: u64::MAX }) .is_err()); assert_eq!(game.tick(), tick, "a rejected request spends no ticks"); assert_eq!(game.state.wallet(player).unwrap().copper(), wallet); // A committed pickup never debits currency either. game.admit_action(&CommittedAction::Pickup { item_id: item }) .unwrap(); while game.has_pending_action() { game.advance_tick().unwrap(); } assert_eq!(game.state.wallet(player).unwrap().copper(), wallet); assert!(game.state.packs[&player].items.contains_key(&item)); }
#[test] fn timed_melee_exchange_once() { let mut game = session(605); descend_to(&mut game, 1); let target = game .state .actors .iter() .find(|actor| { game.state .identity(actor.id) .is_some_and(|identity| identity.template_id == "cable_rat") }) .unwrap() .id; let access = adjacent_access(&game, actor_cell(&game.state, target).unwrap()); route(&mut game, access); let hp_before = game .state .actors .iter() .find(|actor| actor.id == target) .unwrap() .hp; let outcome = game .dispatch(CommittedAction::ResolveAttack { target_actor: target, }) .unwrap(); match outcome { ActionOutcome::AttackResolved { damage, retaliation_damage, .. } => { assert_eq!(damage, PLAYER_STRIKE_DAMAGE, "exactly one outgoing strike"); assert_eq!( retaliation_damage, Some(PLAYER_STRIKE_DAMAGE), "exactly one reciprocal exchange, not two" ); } other => panic!("expected an attack outcome, got {other:?}"), } if let Some(after) = game .state .actors .iter() .find(|actor| actor.id == target) .map(|actor| actor.hp) { assert_eq!( hp_before - after, PLAYER_STRIKE_DAMAGE, "the target takes one strike, not two" ); } }
#[test] fn pending_action_pause_save_resume() { let mut game = session(606); let player = game.player_id().unwrap(); let item = player_ground_item(&mut game); game.admit_action(&CommittedAction::Pickup { item_id: item }) .unwrap(); for _ in 0..3 { game.advance_tick().unwrap(); } // "Process close": the pending slot is part of the persisted state image. let image = game.state.to_image().unwrap(); assert_eq!(image.pending.len(), 1, "exactly one pending slot is saved"); let mut resumed = clone_session(&game); resumed.state = State::from_image(image).unwrap(); assert_eq!(resumed.pending_remaining_ticks(), Some(7)); assert!(!resumed.state.packs[&player].items.contains_key(&item)); while resumed.has_pending_action() { resumed.advance_tick().unwrap(); } assert!( resumed.state.packs[&player].items.contains_key(&item), "the resumed action completes exactly once" ); }
#[test] fn inn_unattended_npc_motion_canonical() { let mut game = session(607); let inn = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::Inn) }); route(&mut game, inn); let player = game.player_id().unwrap(); let start = game.tick(); let player_before = actor_cell(&game.state, player).unwrap(); game.admit_action(&CommittedAction::UseService { service: Service::Inn, }) .unwrap(); for _ in 0..5 { game.advance_tick().unwrap(); } assert_eq!(game.tick(), start + 5, "five unattended ticks ran"); assert_eq!( actor_cell(&game.state, player).unwrap(), player_before, "the resting player never moves during unattended work" ); assert_eq!( game.pending_remaining_ticks(), Some(services::sleep_ticks() - 5), "remaining Inn debt is tracked" ); }
#[test] fn inn_chunked_equals_uninterrupted_hash() { let mut whole = session(608); let inn = interaction_access(&whole, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::Inn) }); route(&mut whole, inn); let mut chunked = clone_session(&whole); // Uninterrupted: one call drives the whole authored Inn. assert!(matches!( whole .dispatch(CommittedAction::UseService { service: Service::Inn }) .unwrap(), ActionOutcome::Slept { .. } )); // Chunked: at most five unattended ticks per worker iteration. chunked .admit_action(&CommittedAction::UseService { service: Service::Inn, }) .unwrap(); while chunked.has_pending_action() { for _ in 0..5 { if !chunked.has_pending_action() { break; } chunked.advance_tick().unwrap(); } } assert_eq!(chunked.tick(), whole.tick()); assert_eq!( chunked.canonical_hash().unwrap(), whole.canonical_hash().unwrap(), "chunked Inn equals the uninterrupted Inn hash" ); }
#[test] fn inn_process_close_reload_remaining_ticks() { let mut game = session(609); let inn = interaction_access(&game, PlanLocation::Town, |kind| { matches!(kind, InteractionKind::Inn) }); route(&mut game, inn); let player = game.player_id().unwrap(); let price = services::price(Service::Inn).copper; let wallet = game.state.wallet(player).unwrap().copper(); game.admit_action(&CommittedAction::UseService { service: Service::Inn, }) .unwrap(); assert_eq!( game.state.wallet(player).unwrap().copper(), wallet - price, "the Inn is debited exactly once at admission" ); for _ in 0..5 { game.advance_tick().unwrap(); } // "Process close" then reload: the remaining debt survives, no re-debit. let image = game.state.to_image().unwrap(); let mut resumed = clone_session(&game); resumed.state = State::from_image(image).unwrap(); assert_eq!( resumed.pending_remaining_ticks(), Some(services::sleep_ticks() - 5) ); assert_eq!( resumed.state.wallet(player).unwrap().copper(), wallet - price, "reload performs no second payment" ); }
#[test] fn ground_target_query_counts_work_preserves_order_and_reaches_beyond_64() { let mut game = session(366); let player_cell = game.transfer_player_cell().unwrap(); let ids = install_ground_pile(&mut game, 70, 100_000); let remote_id = game .state .ground_items .iter() .find(|(_, cell)| **cell != tuple(player_cell)) .map(|(&id, _)| id) .unwrap(); let total_ground = game.state.ground_items.len();
let probes = std::cell::Cell::new(GroundQueryProbes::default()); let first = game .ground_target_with_probes(GroundTargetQuery::First, Some(&probes)) .unwrap() .unwrap(); assert_eq!(first.entry.instance_id, ids[0]); assert!(!first.has_before); assert!(first.has_after); assert_eq!(first.cell, player_cell); assert_eq!( probes.get(), GroundQueryProbes { visited: total_ground, projected: 1, direct_lookup: 1, } );
let beyond = game .ground_target(GroundTargetQuery::After(ids[64])) .unwrap() .unwrap(); assert_eq!(beyond.entry.instance_id, ids[65]); assert!(beyond.has_before); assert!(beyond.has_after); let previous = game .ground_target(GroundTargetQuery::Before(ids[65])) .unwrap() .unwrap(); assert_eq!(previous.entry.instance_id, ids[64]); assert!(game.ground_item(remote_id).unwrap().is_none()); }
#[test] fn ground_target_direct_lookup_is_bounded_factual_and_owner_private() { let mut game = session(367); let player = game.player_id().unwrap(); let carried_id = *game.state.packs[&player].items.keys().next().unwrap(); assert!(game.ground_item(carried_id).unwrap().is_none());
let ids = install_ground_pile(&mut game, 3, 200_000); let probes = std::cell::Cell::new(GroundQueryProbes::default()); let (entry, cell) = game .ground_item_with_probes(ids[1], Some(&probes)) .unwrap() .unwrap(); assert_eq!(entry.instance_id, ids[1]); assert_eq!(cell, game.transfer_player_cell().unwrap()); assert_eq!( probes.get(), GroundQueryProbes { visited: 0, projected: 1, direct_lookup: 1, } );
let corpse_id = 300_000; let mut corpse = game.state.items[&ids[1]].clone(); corpse.id = corpse_id; corpse.template = CORPSE_TEMPLATE.to_owned(); corpse.material = hexwork_core::voxel::material::FLESH; let cell = tuple(game.transfer_player_cell().unwrap()); let mut image = game.state.to_image().unwrap(); image.items.insert(corpse_id, corpse); image.ground_items.insert(corpse_id, cell); image.next_id = image.next_id.max(corpse_id + 1); game.state = State::from_image(image).unwrap(); let corpse = game.ground_item(corpse_id).unwrap().unwrap().0; assert_eq!(corpse.tooltip.title, "Corpse"); assert!(corpse .tooltip .lines .iter() .any(|line| line.contains("FLESH")));
game.state.items.get_mut(&ids[0]).unwrap().template = "missing-ordinary-template".into(); let error = game.ground_item(ids[0]).unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Content, "item-template-missing") );
game.state.ground_items.remove(&ids[2]); let error = game.ground_item(ids[2]).unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Runtime, "ground-item-pair-invalid") ); }
#[test] fn ground_target_rejects_oversize_before_scan_and_outside_location_is_none() { let mut oversized = session(368); oversized.state.ground_items.clear(); for id in 1..=u64::try_from(MAX_ROWS + 1).unwrap() { oversized.state.ground_items.insert(id, (0, 0, 0)); } let probes = std::cell::Cell::new(GroundQueryProbes::default()); let error = oversized .ground_target_with_probes(GroundTargetQuery::First, Some(&probes)) .unwrap_err(); assert_eq!( (error.class, error.code), (SessionErrorClass::Runtime, "ground-target-limit") ); assert_eq!(probes.get(), GroundQueryProbes::default());
let mut outside = session(369); let player = outside.player_id().unwrap(); outside .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap() .position = FixedVec3::new([1_000_000_000; 3]).unwrap(); assert!(outside .ground_target(GroundTargetQuery::First) .unwrap() .is_none());
let before = outside.state.actors.iter().find(|actor| actor.id == player).unwrap().position; for invalid in [i64::MIN, i64::MAX, hexwork_core::spatial::MAX_POSITION_MM + 1] { assert!(FixedVec3::new([invalid, 0, 0]).is_err()); } assert_eq!(outside.state.actors.iter().find(|actor| actor.id == player).unwrap().position, before); assert!(outside.ground_target(GroundTargetQuery::First).unwrap().is_none()); }
#[test] fn controller_routes_transfer_inputs_as_control_after_board_precedence() { let game = session(370); let mut controller = PlayController::default(); assert_eq!( controller.adapt(Input::Pickup, &game), ControllerEvent::Control(Input::Pickup) ); assert_eq!( controller.adapt(Input::Drop, &game), ControllerEvent::Control(Input::Drop) ); controller.board_open = true; assert_eq!( controller.adapt(Input::Pickup, &game), ControllerEvent::None ); assert_eq!(controller.adapt(Input::Drop, &game), ControllerEvent::None); }
#[test] fn fixed_transfer_cell_floors_negative_and_checks_bounds() { for (mm, floor) in [(-1500,-2),(-1001,-2),(-1000,-1),(-999,-1),(-1,-1),(0,0),(999,0),(1000,1)] { assert_eq!(checked_actor_cell(FixedVec3::new([mm,2490,-1]).unwrap()), Some([floor,2,-1])); } assert!(FixedVec3::from_cell_feet([i32::MAX,0,0]).is_err()); assert!(FixedVec3::new([i64::MIN,0,0]).is_err()); assert!(FixedVec3::new([hexwork_core::spatial::MAX_POSITION_MM,0,0]).unwrap().checked_translate([1,0,0]).is_err()); let game = session(371); assert_eq!( game.check_transfer_geometry([0, 0, i32::MAX]) .unwrap_err() .code, "transfer-location-blocked" ); assert_eq!( game.check_transfer_geometry([0, 0, i32::MIN]) .unwrap_err() .code, "transfer-location-blocked" ); }
#[test] #[ignore = "release-only factual timing harness for the actual transfer path"] fn transfer_actual_candidate_core_virtual_append_release_measurement() { use std::time::{Duration, Instant};
fn summary(mut samples: Vec<Duration>) -> (Duration, Duration, Duration) { assert_eq!(samples.len(), 9); samples.sort_unstable(); (samples[0], samples[4], samples[8]) }
for mode in [Mode::Classic, Mode::Story] { for history_case in [ "real-history-0", "real-alternating-history-512", "synthetic-non-live-non-replayable-canonical-metadata-near-cap", ] { let mut baseline = session_from_lineage(372, mode, InitialLineage::default()); let (item_id, _) = starter_item(&baseline); match history_case { "real-history-0" => {} "real-alternating-history-512" => { for index in 0..512 { let action = if index % 2 == 0 { CommittedAction::Drop { item_id } } else { CommittedAction::Pickup { item_id } }; baseline.dispatch(action).unwrap(); } } "synthetic-non-live-non-replayable-canonical-metadata-near-cap" => { baseline.events = (0..MAX_SESSION_EVENTS - 1) .map(|_| { SessionEvent::action( baseline.tick(), baseline.tick(), CommittedAction::Drop { item_id }, ) }) .collect(); } _ => unreachable!(), } let action = if baseline.state.packs[&baseline.player_id().unwrap()] .items .contains_key(&item_id) { CommittedAction::Drop { item_id } } else { CommittedAction::Pickup { item_id } };
let mut prepare_samples = Vec::with_capacity(9); for _ in 0..9 { let game = clone_session(&baseline); let player = game.player_id().unwrap(); let started = Instant::now(); let prepared = game .prepare_transfer(player, &action, Some(game.tick())) .unwrap(); prepare_samples.push(started.elapsed()); drop(prepared); } let mut dispatch_samples = Vec::with_capacity(9); for _ in 0..9 { let mut game = clone_session(&baseline); let started = Instant::now(); game.dispatch(action.clone()).unwrap(); dispatch_samples.push(started.elapsed()); } let (prepare_min, prepare_median, prepare_max) = summary(prepare_samples); let (dispatch_min, dispatch_median, dispatch_max) = summary(dispatch_samples); eprintln!( "transfer-path-observation mode={mode:?} case={history_case} samples=9 prepare_min={prepare_min:?} prepare_median={prepare_median:?} prepare_max={prepare_max:?} dispatch_min={dispatch_min:?} dispatch_median={dispatch_median:?} dispatch_max={dispatch_max:?} chunks={} note=no-TPS-platform-p99-or-RSS-certification", baseline.state.grid.loaded_count() ); } } }
#[test] fn inventory_pages_reach_items_beyond_64_without_full_pack_projection() { let mut game = session(330); let expected = replace_player_inventory(&mut game, 70); let mut reached = Vec::new(); let mut query = InventoryPageQuery::First; loop { let page = game.inventory_page(query).unwrap(); assert!(page.entries.len() <= INVENTORY_PAGE_SIZE); reached.extend(page.entries.iter().map(|entry| entry.instance_id)); if !page.has_after { break; } query = InventoryPageQuery::After(page.entries.last().unwrap().instance_id); } assert_eq!(reached, expected); assert_eq!(reached.len(), 70); }
#[test] fn inventory_anchor_refresh_handles_removal_and_empty_pack() { let mut game = session(331); let ids = replace_player_inventory(&mut game, 12); let player = game.player_id().unwrap(); game.state .packs .get_mut(&player) .unwrap() .items .remove(&ids[4]);
let refreshed = game .inventory_page(InventoryPageQuery::AtOrAfter(ids[4])) .unwrap(); assert_eq!(refreshed.entries[0].instance_id, ids[5]); let tail = game .inventory_page(InventoryPageQuery::AtOrAfter(u64::MAX)) .unwrap(); assert_eq!( tail.entries .iter() .map(|entry| entry.instance_id) .collect::<Vec<_>>(), ids[6..].to_vec() ); assert!(tail.has_before); assert!(!tail.has_after);
let exhausted_after = game .inventory_page(InventoryPageQuery::After(*ids.last().unwrap())) .unwrap(); assert!(exhausted_after.entries.is_empty()); let exhausted_before = game .inventory_page(InventoryPageQuery::Before(ids[0])) .unwrap(); assert!(exhausted_before.entries.is_empty());
game.state.packs.get_mut(&player).unwrap().items.clear(); for query in [ InventoryPageQuery::First, InventoryPageQuery::AtOrAfter(ids[0]), InventoryPageQuery::After(ids[0]), InventoryPageQuery::Before(ids[0]), ] { let empty = game.inventory_page(query).unwrap(); assert!(empty.entries.is_empty()); assert_eq!(empty.total, 0); assert!(!empty.has_before); assert!(!empty.has_after); } }
#[test] fn inventory_selected_lookup_is_direct_and_read_only() { let mut game = session(332); let ids = replace_player_inventory(&mut game, 70); let before = atomic_snapshot(&game); let probes = std::cell::Cell::new(InventoryQueryProbes::default()); let selected = game .inventory_item_with_probes(ids[65], Some(&probes)) .unwrap() .unwrap(); assert_eq!(selected.instance_id, ids[65]); assert_eq!( probes.get(), InventoryQueryProbes { projected: 1, direct_lookup: 1, ..InventoryQueryProbes::default() } ); probes.set(InventoryQueryProbes::default()); assert!(game .inventory_item_with_probes(u64::MAX, Some(&probes)) .unwrap() .is_none()); assert_eq!(probes.get().direct_lookup, 1); assert_eq!(probes.get().projected, 0); assert_eq!(atomic_snapshot(&game), before); }
#[test] fn inventory_range_queries_inspect_at_most_seven_candidates() { let mut game = session(333); let ids = replace_player_inventory(&mut game, 20); let cases = [ (InventoryPageQuery::First, ids[0]), (InventoryPageQuery::AtOrAfter(ids[3]), ids[3]), (InventoryPageQuery::AtOrAfter(ids[3] + 1), ids[4]), (InventoryPageQuery::AtOrAfter(u64::MAX), ids[14]), (InventoryPageQuery::After(ids[3]), ids[4]), (InventoryPageQuery::Before(ids[10]), ids[4]), ]; for (query, expected_first) in cases { let probes = std::cell::Cell::new(InventoryQueryProbes::default()); let page = game .inventory_page_with_probes(query, Some(&probes)) .unwrap(); assert_eq!(page.entries[0].instance_id, expected_first); assert!(probes.get().page <= INVENTORY_PAGE_SIZE + 1); assert!(probes.get().boundary <= 2); assert!(probes.get().projected <= INVENTORY_PAGE_SIZE); assert_eq!(probes.get().direct_lookup, 0); } }
#[test] fn inventory_page_does_not_project_entries_outside_requested_range() { let mut game = session(334); let ids = replace_player_inventory(&mut game, 8); let player = game.player_id().unwrap(); game.state .packs .get_mut(&player) .unwrap() .items .get_mut(&ids[6]) .unwrap() .template = "poison-template-outside-page".to_owned();
let first = game.inventory_page(InventoryPageQuery::First).unwrap(); assert_eq!(first.entries.len(), INVENTORY_PAGE_SIZE); assert_eq!(first.entries.last().unwrap().instance_id, ids[5]); assert!(first.has_after); assert_eq!( game.inventory_page(InventoryPageQuery::After(ids[5])) .unwrap_err() .code, "item-template-missing" ); }
#[test] fn inventory_projection_caps_logical_use_inspection_and_reports_omissions() { let mut late_use = vec![" \t ".to_owned(); MAX_TOOLTIP_USE_ROWS]; late_use.push("real use after the inspection cap".to_owned()); let ((late_tooltip, late_clipped), late_budget) = bounded_item_tooltip_inner("name", true, &late_use, "IRON"); assert!(late_clipped); assert_eq!(late_budget.logical_use_reads, MAX_TOOLTIP_USE_ROWS); assert!(late_budget.consumed_input <= INVENTORY_TOOLTIP_TEXT_LIMIT); assert!(!late_tooltip .lines .iter() .any(|line| line.contains("real use"))); assert!(!late_tooltip .lines .iter() .any(|line| line.contains("No recorded uses"))); assert!(late_tooltip .lines .contains(&INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned())); assert_eq!( late_tooltip.lines.last().unwrap(), INVENTORY_TOOLTIP_CLIPPED_LINE );
let blank_uses = vec![" \n\t ".to_owned(); MAX_TOOLTIP_USE_ROWS]; let ((blank_tooltip, blank_clipped), blank_budget) = bounded_item_tooltip_inner("name", true, &blank_uses, "IRON"); assert!(!blank_clipped); assert_eq!(blank_budget.logical_use_reads, MAX_TOOLTIP_USE_ROWS); assert!(blank_tooltip .lines .iter() .any(|line| line.contains("No recorded uses"))); assert!(!blank_tooltip .lines .contains(&INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned()));
let exhausted_name = "x".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT); let pending_use = vec!["retained only if budget remains".to_owned()]; let ((exhausted_tooltip, exhausted_clipped), exhausted_budget) = bounded_item_tooltip_inner(&exhausted_name, true, &pending_use, "IRON"); assert!(exhausted_clipped); assert_eq!(exhausted_budget.logical_use_reads, 1); assert!(exhausted_budget.consumed_input <= INVENTORY_TOOLTIP_TEXT_LIMIT); assert!(!exhausted_tooltip .lines .iter() .any(|line| line.contains("No recorded uses"))); assert!(exhausted_tooltip .lines .contains(&INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned()));
let hidden_uses = vec!["secret".to_owned(); MAX_TOOLTIP_USE_ROWS + 1]; let ((hidden_tooltip, hidden_clipped), hidden_budget) = bounded_item_tooltip_inner("unknown", false, &hidden_uses, "IRON"); assert!(!hidden_clipped); assert_eq!(hidden_budget.logical_use_reads, 0); assert!(!hidden_tooltip .lines .iter() .any(|line| line.contains("secret"))); }
#[test] fn inventory_projection_caps_utf8_before_allocation_and_discloses_clipping() { let huge_name = format!("name\n{}", "🜃".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT)); let huge_material = format!("IRON\t{}", "é".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT)); let huge_use = format!("burn\r{}", "界".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT)); for ((tooltip, clipped), budget) in [ bounded_item_tooltip_inner(&huge_name, true, &[], "IRON"), bounded_item_tooltip_inner("name", true, &[], &huge_material), bounded_item_tooltip_inner("name", true, &[huge_use], "IRON"), ] { let projected_bytes = tooltip.title.len() + tooltip.lines.iter().map(String::len).sum::<usize>(); assert!(clipped); assert_eq!( tooltip.lines.last().unwrap(), INVENTORY_TOOLTIP_CLIPPED_LINE ); assert!(projected_bytes <= INVENTORY_TOOLTIP_TEXT_LIMIT); assert!(budget.consumed_input <= INVENTORY_TOOLTIP_TEXT_LIMIT); assert!(tooltip.title.is_char_boundary(tooltip.title.len())); assert!(!tooltip.title.chars().any(char::is_control)); assert!(!tooltip .lines .iter() .any(|line| line.chars().any(char::is_control))); }
let game = session(335); let old = game.inventory_view(1).unwrap().entries.remove(0); let id = old.instance_id; let paged = game .inventory_page(InventoryPageQuery::First) .unwrap() .entries .remove(0); let direct = game.inventory_item(id).unwrap().unwrap(); assert_eq!(old, paged); assert_eq!(old, direct); }
#[test] fn inventory_projection_accepts_carried_corpse_but_rejects_missing_noncorpse_template() { use hexwork_core::voxel::material;
let materials = [ (material::AIR, "AIR"), (material::WOOD, "WOOD"), (material::IRON, "IRON"), (material::COPPER, "COPPER"), (material::STONE, "STONE"), (material::BRASS, "BRASS"), (material::FLESH, "FLESH"), (material::SAINT_BONE, "SAINT_BONE"), (material::GLASS, "GLASS"), (material::ROPE, "ROPE"), (material::PAPER, "PAPER"), ]; let mut game = session(336); let player = game.player_id().unwrap(); let mut image = game.state.to_image().unwrap(); for (material_id, _) in materials { let id = 90_000 + u64::from(material_id); let mut corpse = Instance::corpse(id, [0.0; 3]); corpse.material = material_id; image.packs.get_mut(&player).unwrap().add(corpse); } image.next_id = 90_011; game.state = State::from_image(image).unwrap(); let bytes = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); let canonical = restored.canonical_hash().unwrap(); let idf = restored.state.idf.clone(); for (material_id, material_name) in materials { let entry = restored .inventory_item(90_000 + u64::from(material_id)) .unwrap() .unwrap(); assert_eq!(entry.tooltip.title, "Corpse"); assert_eq!( entry.tooltip.lines, vec!["Remains.".to_owned(), format!("Made of {material_name}.")] ); assert!(!entry.tooltip_clipped); } assert_eq!(restored.state.idf, idf); assert_eq!(restored.canonical_hash().unwrap(), canonical);
let mut invalid_material = session(350); let player = invalid_material.player_id().unwrap(); let id = 91_000; let mut corpse = Instance::corpse(id, [0.0; 3]); corpse.material = 199; invalid_material .state .packs .get_mut(&player) .unwrap() .add(corpse); let error = invalid_material.inventory_item(id).unwrap_err(); assert_eq!(error.class, SessionErrorClass::Runtime); assert_eq!(error.code, "inventory-material-invalid");
let mut missing_template = session(351); let player = missing_template.player_id().unwrap(); let id = missing_template.state.packs[&player] .items .keys() .next() .copied() .unwrap(); missing_template .state .packs .get_mut(&player) .unwrap() .items .get_mut(&id) .unwrap() .template = "ordinary-template-missing".to_owned(); let error = missing_template.inventory_item(id).unwrap_err(); assert_eq!(error.class, SessionErrorClass::Content); assert_eq!(error.code, "item-template-missing"); }
#[test] fn descendant_known_template_changes_real_inventory_knowledge() { let pack = RuntimePack::load(content_root()).unwrap(); let unknown_identity = identity( &pack, 301, Mode::Classic, Calling::Tinker, InitialLineage::default(), ); let mut unknown = GameSession::from_identity(pack.clone(), unknown_identity).unwrap(); let unknown_item = unknown .inventory_view(64) .unwrap() .entries .into_iter() .find(|entry| entry.template_id == "hymn_card_blank") .unwrap(); assert!(!unknown_item.effective_identified); assert!(unknown_item.tooltip.title.contains("unidentified")); unknown.state.idf.insert("hymn_card_blank".into(), 1); assert!( !unknown .inventory_item(unknown_item.instance_id) .unwrap() .unwrap() .effective_identified );
let inherited = InitialLineage { identified_item_templates: vec!["hymn_card_blank".into()], ..InitialLineage::default() }; let known_identity = identity(&pack, 301, Mode::Classic, Calling::Tinker, inherited); let known = GameSession::from_identity(pack, known_identity).unwrap(); let known_item = known.inventory_view(1).unwrap(); assert!(known_item.truncated); let known_item = known .inventory_view(64) .unwrap() .entries .into_iter() .find(|entry| entry.template_id == "hymn_card_blank") .unwrap(); assert!(known_item.effective_identified); assert!(!known_item.tooltip.title.contains("unidentified")); assert_eq!(known.state.idf.get("hymn_card_blank"), Some(&2)); }
#[test] fn initial_lineage_maps_recipes_translations_roundtrip() { let initial = InitialLineage { mapped_regions: vec!["deep:2".into()], recipes: vec!["brine-poultice".into()], translated_hymns: vec!["fiat_lux".into()], ..InitialLineage::default() }; let game = session_from_lineage(302, Mode::Classic, initial.clone()); assert!(game .state .knowledge() .mapped_regions .contains("town:milthollow")); assert!(game.state.knowledge().mapped_regions.contains("deep:2")); assert!(game.state.knowledge().recipes.contains("brine-poultice")); assert!(game.state.knowledge().translated_hymns.contains("fiat_lux")); let bytes = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(restored.identity.initial_lineage, initial); assert_eq!(restored.state.knowledge(), game.state.knowledge()); }
#[test] fn descendant_does_not_inherit_live_inventory_wallet_or_debts() { let initial = InitialLineage { identified_item_templates: vec!["hymn_card_blank".into()], ..InitialLineage::default() }; let game = session_from_lineage(303, Mode::Classic, initial); let player = game.player_id().unwrap(); let templates: Vec<_> = game.state.packs[&player] .items .values() .map(|item| item.template.as_str()) .collect(); assert_eq!(templates, kit(Calling::Lampbearer).items); assert_eq!( game.state.wallet(player).unwrap().copper(), kit(Calling::Lampbearer).stipend_cu ); assert!(game.state.bargains[&player].debts.is_empty()); assert!(game.state.chapel_debt(player).is_none()); }
#[test] fn initial_lineage_does_not_change_world_catalog_rng_or_starting_kit() { let empty = session_from_lineage(304, Mode::Classic, InitialLineage::default()); let inherited = session_from_lineage( 304, Mode::Classic, InitialLineage { mapped_regions: vec!["deep:3".into()], recipes: vec!["brine-poultice".into()], translated_hymns: vec!["fiat_lux".into()], identified_item_templates: vec!["hymn_card_blank".into()], ..InitialLineage::default() }, ); let empty_image = empty.snapshot().unwrap(); let inherited_image = inherited.snapshot().unwrap(); assert_eq!(empty_image.chunks, inherited_image.chunks); assert_eq!(empty_image.rng_master, inherited_image.rng_master); assert_eq!( format!("{:?}", empty_image.actors), format!("{:?}", inherited_image.actors) ); assert_eq!( empty_image.actor_identities, inherited_image.actor_identities ); assert_eq!(empty_image.items, inherited_image.items); assert_eq!(empty_image.packs, inherited_image.packs); assert_eq!( empty.catalog.locations.len(), inherited.catalog.locations.len() ); }
#[test] fn initial_town_learning_is_in_initial_checkpoint() { let story = session_from_lineage(305, Mode::Story, InitialLineage::default()); let checkpoint = story.story_checkpoint.as_ref().unwrap(); assert_eq!(checkpoint.after_event_count, 0); assert!(checkpoint .state .image .knowledge .mapped_regions .contains("town:milthollow")); }
#[test] fn successful_transition_learning_is_in_event_post_state() { let mut story = session_from_lineage(306, Mode::Story, InitialLineage::default()); descend_to(&mut story, 1); assert!(story.state.knowledge().mapped_regions.contains("deep:1")); let transition = story.events.last().unwrap(); assert!(matches!( transition.kind, SessionEventKind::Action(CommittedAction::UseTransition { .. }) )); assert_eq!(transition.post_tick, story.state.tick); return_to_town(&mut story); let checkpoint = story.story_checkpoint.as_ref().unwrap(); assert_eq!(checkpoint.after_event_count as usize, story.events.len()); assert!(checkpoint .state .image .knowledge .mapped_regions .contains("deep:1")); assert!(checkpoint .state .image .knowledge .mapped_regions .contains("town:milthollow")); }
#[test] fn failed_transition_does_not_learn() { let mut game = session(307); let before = game.state.knowledge().clone(); assert_eq!( game.dispatch(CommittedAction::UseTransition { transition_id: u64::MAX, }) .unwrap_err() .code, "transition-unknown" ); assert_eq!(game.state.knowledge(), &before); assert!(!game.state.knowledge().mapped_regions.contains("deep:1")); }
#[test] fn resume_does_not_reapply_initial_lineage() { let initial = InitialLineage { identified_item_templates: vec!["hymn_card_blank".into()], ..InitialLineage::default() }; let mut game = session_from_lineage(308, Mode::Classic, initial); game.state.idf.insert("hymn_card_blank".into(), 7); game.state.learn_mapped_region("deep:4").unwrap(); let bytes = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(restored.state.idf.get("hymn_card_blank"), Some(&7)); assert!(restored.state.knowledge().mapped_regions.contains("deep:4")); }
#[test] fn latest_story_checkpoint_retains_knowledge_after_multiple_returns() { let mut story = session_from_lineage(309, Mode::Story, InitialLineage::default()); descend_to(&mut story, 2); return_to_town(&mut story); let first = story.story_checkpoint.as_ref().unwrap().clone(); assert!(first .state .image .knowledge .mapped_regions .contains("deep:2")); descend_to(&mut story, 3); return_to_town(&mut story); let latest = story.story_checkpoint.as_ref().unwrap(); assert!(latest.after_event_count > first.after_event_count); for id in ["town:milthollow", "deep:1", "deep:2", "deep:3"] { assert!(latest.state.image.knowledge.mapped_regions.contains(id)); } }
#[test] fn v4_replay_preserves_latest_story_checkpoint_reference() { let mut story = session_from_lineage(310, Mode::Story, InitialLineage::default()); descend_to(&mut story, 2); return_to_town(&mut story); descend_to(&mut story, 3); return_to_town(&mut story); let expected = story.story_checkpoint.as_ref().unwrap().clone(); let replay = story.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay, Mode::Story).unwrap(); let actual = replayed.story_checkpoint.as_ref().unwrap(); assert_eq!(actual.after_event_count, expected.after_event_count); assert_eq!(actual.state.hash, expected.state.hash); assert_eq!(actual.state.image.knowledge, expected.state.image.knowledge); }
#[test] fn no_vault_read_replay_uses_complete_identity() { let initial = InitialLineage { mapped_regions: vec!["deep:2".into()], recipes: vec!["brine-poultice".into()], translated_hymns: vec!["fiat_lux".into()], identified_item_templates: vec!["hymn_card_blank".into()], ..InitialLineage::default() }; let mut game = session_from_lineage(311, Mode::Classic, initial.clone()); game.dispatch(CommittedAction::Wait).unwrap(); let bytes = game.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); assert_eq!(replayed.identity.initial_lineage, initial); assert_eq!( replayed.canonical_hash().unwrap(), game.canonical_hash().unwrap() ); }
#[test] fn scheduled_death_is_observed_without_another_input() { // Unit seam: a valid airborne core fixture schedules a lethal fall in // the normal gravity/death phases. End-to-end player death remains // covered by production_hostile_exchange_reaches_replayable_player_death. let mut game = session(312); let player = game.player_id().unwrap(); let actor = game .state .actors .iter_mut() .find(|actor| actor.id == player) .unwrap(); actor.hp = 1; actor.position = actor.position.checked_translate([0, 0, 12_000]).unwrap(); game.state.fall_odo.insert(player, 12_000); let before_events = game.events.len(); let mut died = false; for _ in 0..20 { let outcome = game.advance_one_tick().unwrap(); if matches!( outcome, TickOutcome::Advanced { liveness: PlayerLiveness::Dead, .. } ) { died = true; break; } } assert!(died, "airborne fixture must die during tick processing"); assert_eq!( game.events.len(), before_events, "ticks are not fake actions" ); let dead_tick = game.tick(); assert_eq!( game.advance_one_tick().unwrap(), TickOutcome::LifecycleRequired { tick: dead_tick, liveness: PlayerLiveness::Dead, } ); assert_eq!(game.tick(), dead_tick); }
#[test] fn action_elapsed_time_is_explicit_and_matches_outcomes() { let mut game = session(313); assert_eq!(game.action_elapsed_ticks(&CommittedAction::Wait), 1); let wait = game.dispatch(CommittedAction::Wait).unwrap(); assert_eq!(wait.elapsed_ticks(), 1); assert_eq!( // A real-movement Move admits one sample and steps exactly one tick. game.action_elapsed_ticks(&CommittedAction::Move(Direction::North)), 1 ); assert_eq!( game.diagnostic_hash(), game.state().unwrap().diagnostic_hash() ); }
#[test] fn identity_reference_validation_rejects_before_world_publication() { let pack = RuntimePack::load(content_root()).unwrap(); let mut invalid = identity( &pack, 314, Mode::Classic, Calling::Lampbearer, InitialLineage { mapped_regions: vec!["deep:01".into()], ..InitialLineage::default() }, ); assert_eq!( GameSession::from_identity(pack.clone(), invalid.clone()) .unwrap_err() .code, "knowledge-region-invalid" ); invalid.initial_lineage.mapped_regions = vec!["town:missing".into()]; assert_eq!( GameSession::from_identity(pack.clone(), invalid.clone()) .unwrap_err() .code, "knowledge-region-missing" ); invalid.initial_lineage.mapped_regions.clear(); invalid.initial_lineage.translated_hymns = vec!["missing_hymn".into()]; assert_eq!( GameSession::from_identity(pack, invalid).unwrap_err().code, "knowledge-hymn-missing" ); }
#[test] fn presentation_text_helpers_remain_frontend_ready() { assert!(ActionOutcome::Waited { tick: 7 } .log_text() .contains("tick 7")); assert!(BoardView { index: 0, total: 1, id: "notice".into(), title: "Notice".into(), page: "Terms".into(), } .log_text() .contains("Terms")); }
#[test] fn state_privacy_has_no_public_mutable_escape() { let source = include_str!("session.rs"); let production = source .rsplit_once("\n#[cfg(test)]\nmod tests") .expect("session test module marker") .0; assert!(production.contains("pub fn state(&self) -> Result<&State, SessionError>")); assert!(!production.contains("pub(crate) state: State")); assert!(!production.contains("pub(crate) fn state_mut")); assert!(!production.contains("pub fn state_mut")); }
fn historical_lot(template_id: &str) -> AuctionLot { let origin_run_id = RunId::new(41).unwrap(); let origin_item_id = 9_876_543; let base_price_cu = 12_345; AuctionLot { lot_id: LotId::new(origin_run_id, origin_item_id).unwrap(), origin_run_id, origin_item_id, template_id: template_id.to_owned(), material: 199, identified: true, cursed: true, blessed: true, condition: Condition::Cracked, silvered: true, illegal: true, tags: vec!["heirloom".into(), "post-mutation".into()], fuel: 37, wet_ticks: 88, base_price_cu, buyback_price_cu: services::auction_buyback(base_price_cu), } }
fn assert_lot_fields(actual: &AuctionLot, expected: &AuctionLot) { assert_eq!(actual.lot_id, expected.lot_id); assert_eq!(actual.origin_run_id, expected.origin_run_id); assert_eq!(actual.origin_item_id, expected.origin_item_id); assert_eq!(actual.template_id, expected.template_id); assert_eq!(actual.material, expected.material); assert_eq!(actual.identified, expected.identified); assert_eq!(actual.cursed, expected.cursed); assert_eq!(actual.blessed, expected.blessed); assert_eq!(actual.condition, expected.condition); assert_eq!(actual.silvered, expected.silvered); assert_eq!(actual.illegal, expected.illegal); assert_eq!(actual.tags, expected.tags); assert_eq!(actual.fuel, expected.fuel); assert_eq!(actual.wet_ticks, expected.wet_ticks); assert_eq!(actual.base_price_cu, expected.base_price_cu); assert_eq!(actual.buyback_price_cu, expected.buyback_price_cu); assert_eq!(actual, expected); }
#[test] fn historical_lot_fields_and_quote_survive_run_and_replay_roundtrips() { let pack = RuntimePack::load(content_root()).unwrap(); let template_id = "hymn_card_blank"; let current = pack.item(template_id).expect("canonical lot template"); let lot = historical_lot(template_id); assert_ne!(lot.base_price_cu, current.base_price_cu); let initial = InitialLineage { auction_lots: vec![lot.clone()], ..InitialLineage::default() }; let control = GameSession::from_identity( pack.clone(), identity( &pack, 315, Mode::Classic, Calling::Lampbearer, InitialLineage::default(), ), ) .unwrap(); let run = identity(&pack, 315, Mode::Classic, Calling::Lampbearer, initial); let game = GameSession::from_identity(pack, run).unwrap(); assert_eq!( game.identity.initial_lineage.auction_lots, vec![lot.clone()] ); assert_eq!(game.state.items, control.state.items); assert_eq!(game.state.packs, control.state.packs);
let run_bytes = game.encode_current_run().unwrap(); let restored = GameSession::restore_from_bytes(content_root(), &run_bytes, Mode::Classic).unwrap(); assert_eq!(restored.identity.initial_lineage.auction_lots.len(), 1); assert_lot_fields(&restored.identity.initial_lineage.auction_lots[0], &lot);
let replay_bytes = game.encode_replay().unwrap(); let replayed = GameSession::replay_from_bytes(content_root(), &replay_bytes, Mode::Classic).unwrap(); assert_eq!(replayed.identity.initial_lineage.auction_lots.len(), 1); assert_lot_fields(&replayed.identity.initial_lineage.auction_lots[0], &lot); }
#[test] fn historical_lot_still_rejects_missing_template_provenance_and_recorded_buyback() { let pack = RuntimePack::load(content_root()).unwrap();
let mut missing = historical_lot("missing_historical_template"); let mut run = identity( &pack, 316, Mode::Classic, Calling::Lampbearer, InitialLineage { auction_lots: vec![missing.clone()], ..InitialLineage::default() }, ); assert_eq!( GameSession::from_identity(pack.clone(), run.clone()) .unwrap_err() .code, "initial-lot-template-missing" );
missing.template_id = "hymn_card_blank".into(); missing.origin_item_id += 1; run.initial_lineage.auction_lots = vec![missing]; assert_eq!( GameSession::from_identity(pack.clone(), run.clone()) .unwrap_err() .code, "initial-lineage-invalid" );
let mut wrong_buyback = historical_lot("hymn_card_blank"); wrong_buyback.buyback_price_cu += 1; run.initial_lineage.auction_lots = vec![wrong_buyback]; assert_eq!( GameSession::from_identity(pack, run).unwrap_err().code, "initial-lineage-invalid" ); }
#[test] fn frozen_fault_blocks_mutation_lifecycle_snapshot_and_exports_atomically() { let mut game = session_from_lineage(317, Mode::Story, InitialLineage::default()); let saved_before_fault = game.encode_current_run().unwrap(); let state_hash = game.state.try_hash_state().unwrap(); let events = game.events.clone(); let checkpoint = game.story_checkpoint.as_ref().map(|checkpoint| { ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, format!("{:?}", checkpoint.state.image), ) }); let witness = game.checkpoint_witness.borrow().clone(); let evidence = game.replay_evidence.clone(); let fault = SessionError::runtime("tick-phase-fault", "injected after mutation"); let frozen = fault .clone() .with_context("session-frozen", fault.to_string()); game.latch_postmutation_fault_for_test(fault); let diagnostic_hash = game.diagnostic_hash();
assert_eq!(game.state().unwrap_err(), frozen); assert_eq!(game.inventory_view(1).unwrap_err(), frozen); assert_eq!( game.inventory_page(InventoryPageQuery::First).unwrap_err(), frozen ); assert_eq!(game.inventory_item(0).unwrap_err(), frozen); assert_eq!(game.snapshot().unwrap_err(), frozen); assert_eq!(game.encode_current_run().unwrap_err(), frozen); assert_eq!(game.encode_replay().unwrap_err(), frozen); assert_eq!( game.encode_replay_with_limits(ReplayLimits::default()) .unwrap_err(), frozen ); assert_eq!(game.resolve_story_death().unwrap_err(), frozen); assert_eq!(game.resolve_story_death_inner(false).unwrap_err(), frozen); assert_eq!(game.dispatch(CommittedAction::Wait).unwrap_err(), frozen); assert_eq!(game.advance_one_tick().unwrap_err(), frozen);
assert_eq!(game.state.try_hash_state().unwrap(), state_hash); assert_eq!(game.events, events); assert_eq!( game.story_checkpoint.as_ref().map(|checkpoint| ( checkpoint.after_event_count, checkpoint.state.tick, checkpoint.state.hash, format!("{:?}", checkpoint.state.image), )), checkpoint ); assert_eq!(*game.checkpoint_witness.borrow(), witness); assert_eq!(game.replay_evidence, evidence); assert_eq!(game.diagnostic_hash(), diagnostic_hash); let restored = GameSession::restore_from_bytes(content_root(), &saved_before_fault, Mode::Story) .unwrap(); assert_eq!(restored.frozen_fault, None); assert!(restored.encode_current_run().is_ok()); }}