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.
173 kB · 4333 lines
Rust
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334//! World state: the deterministic sim kernel (docs/04 §4.2–4.3).//!//! Owns the tick counter, master seed, RNG streams, voxel grid, actors,//! connectors, pressure graph, and the cause-link record. `actors` is the sole//! simulation entity authority; `ecs` is dormant infrastructure and receives//! no mirrored entities. All 12 phases run here in fixed order. No I/O. No//! threads. No wall-clock.
use std::collections::BTreeMap;
use crate::actor::Actor;use crate::air::{self, pack_cell};use crate::bargain::Ledger;use crate::casting::{self, CastCtx};use crate::cause::{CauseChain, CauseLink};use crate::character::{record_use, Character, Skill};use crate::checkpoint::{CauseImage, ChunkImage, LawImage, StateImage, StateImageError};use crate::connector::Connector;use crate::dungeon::{Chalk, MulePath, VaultChoice};use crate::ecs::Ecs;use crate::fire;use crate::fluids;use crate::gravity;use crate::hymn::{self, HymnCase};use crate::inventory::Pack;use crate::item::{self, Instance, ItemTemplate};use crate::laws::{self, LawLog};use crate::light::{self, LightSource};use crate::modes::{self, ChapelDebt};use crate::npc::{ShopMemory, Townsfolk};use crate::observation::{ observe, observer_origin, LocationKey, ObservationError, ObservationLimits, ObservationMemory, ObservationRow,};use crate::pending::{Pending, PendingAction, PendingError};use crate::phases::{Phase, ORDER};use crate::pressure::{PressureEdge, PressureNode};use crate::reputation::Reputation;use crate::rng::{domains, Rng};use crate::rot::{self, CorpseClock};use crate::runtime::{ validate_knowledge_id, ActiveContract, ActorIdentity, ContractError, ContractStatus, ItemTransfer, KnowledgeCategory, KnowledgeError, LocationError, MeleeError, MeleeExchange, MeleeStrike, RunKnowledge, RunLocation, RuntimeInvariantError, TransferError, Wallet, WalletError,};use crate::scent;use crate::schedule::day_phase;use crate::signal;use crate::sound::{self, SoundEvent};use crate::structure;use crate::voxel::ChunkedGrid;
/// Fixed simulation step: 10Hz → 100ms per tick.pub const TICK_DT_SECS: f64 = 0.1;/// Ticks per in-game day at 10Hz (24 in-game minutes per real second would be/// too fast; the day clock below is a schedule driver, not real time).pub const TICKS_PER_DAY: u64 = 8_640;// Nearest inward f32 values at the i32 extrema; enough headroom for the// active-region +/-24 arithmetic performed during one ordinary step.
/// Checked unattended-time preflight failure. The state is unchanged on error.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum UnattendedAdvanceError { TickOverflow, FarFieldOverflow,}
/// Atomic paid unattended-time failure. Both wallet and clocks remain unchanged.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum PaidUnattendedError { Wallet(WalletError), Clock(UnattendedAdvanceError),}
/// Active-region half-extent in voxels around the lead actor. Fluids, fire,/// and signal scan this box; `structure`/`scent` clamp it to their 64-span/// bound internally (deterministic truncation, documented). Sized so the/// full-region scan stays affordable: 49³ ≈ 118k cells worst case, and each/// phase only READS empty cells (writes are budget-capped at 4k).pub const ACTIVE_HALF_EXTENT: i32 = 24;
/// Deferred per-system spillover carried to the next tick (docs/04 §4.2:/// capped phases defer, never drop). Signal/scent report a bool; gas/light/// report tail counts re-queued here.#[derive(Clone, Debug, Default)]pub struct Spill { /// One desired world heading per actor, consumed once by Move. pub desired_moves: BTreeMap<u64, crate::locomotion::MovementSample>, /// Swept body cells awaiting this tick's hazard phase; deduplicated per actor. pub crossed_cells: BTreeMap<u64, std::collections::BTreeSet<(i32,i32,i32)>>, /// Gas flow triples left unprocessed (`flows[consumed..]` semantics: /// parent rebuilds flows each tick and skips the first `gas_skip`). pub gas_flows: Vec<(u64, u64, f32)>, /// Light needs a re-run when the flood exceeded budget. pub light_pending: bool, /// Signal wave was truncated; re-run next tick. pub signal_pending: bool, /// Scent pass was truncated; re-run next tick. pub scent_pending: bool,}
/// Abstract far-field record for unloaded towns (docs/04 §4.3): schedules/// and prices advance cheaply without voxels. Full town sim lands in Phase 9;/// the kernel already ticks its clock so gossip latency has a time base.#[derive(Clone, Debug, Default)]pub struct FarField { /// Days elapsed since run start. pub day: u64, /// Tick remainder toward the next day. pub day_tick: u64,}
impl FarField { fn advance(&mut self) { self.day_tick += 1; if self.day_tick >= TICKS_PER_DAY { self.day_tick = 0; self.day += 1; } }
fn checked_advance_by(&self, ticks: u64) -> Option<(u64, u64)> { if self.day_tick >= TICKS_PER_DAY { return None; } let whole_days = ticks / TICKS_PER_DAY; let remainder = ticks % TICKS_PER_DAY; let day_ticks = self.day_tick.checked_add(remainder)?; let carry = day_ticks / TICKS_PER_DAY; let day = self.day.checked_add(whole_days)?.checked_add(carry)?; Some((day, day_ticks % TICKS_PER_DAY)) }}
#[derive(Debug)]struct PreparedDeath { actor_id: u64, actor_index: usize, position: crate::spatial::FixedVec3, corpse: Instance, contracts_to_fail: Vec<String>, death_detail: String,}
#[derive(Debug)]struct PreparedDeathBatch { deaths: Vec<PreparedDeath>, next_id: u64,}
/// Deterministic world kernel. No I/O. No threads. No wall-clock.#[derive(Debug)]pub struct State { pub tick: u64, pub seed: u64, /// Durable Story branch epoch (docs/fluid §7). `0` for a fresh run. Story /// resurrection increments it and restamps the restored observation /// provenance so a descriptor, target, observation, or queued input from a /// rolled-back branch cannot pass as current. Part of the canonical hash, /// the checkpoint image, and the wire. pub branch_epoch: u64, rng_master: Rng, /// Dormant infrastructure. [`State::actors`] is the sole simulation /// entity authority and production actor spawns never mirror into ECS. pub ecs: Ecs, pub grid: ChunkedGrid, /// Sole authoritative collection of simulation entities. pub actors: Vec<Actor>, actor_identities: BTreeMap<u64, ActorIdentity>, wallets: BTreeMap<u64, Wallet>, contracts: BTreeMap<String, ActiveContract>, run_location: RunLocation, pub connectors: Vec<Connector>, pub pressure_nodes: Vec<PressureNode>, pub pressure_edges: Vec<PressureEdge>, pub causes: CauseChain, pub far: FarField, /// First-time law registry (§4.10.5 "no silent rules"). The kernel gates /// every display-facing hazard link on `first_time` so laws speak once /// per run while replays keep every occurrence. pub laws: LawLog, /// Per-actor metres descended, owned here and passed to /// `gravity::step_falling_with_mass` every Move tick (total-height /// damage). Clear an actor's entry on teleport. pub fall_odo: BTreeMap<u64, u64>, /// Live light sources: lantern cone anchor + carried glow + flora. /// The Light phase consumes this list every tick. pub lights: Vec<LightSource>, /// Sound queue drained by the Sound phase each tick. pub sounds: Vec<SoundEvent>, /// Corpse clocks owned by the Rot phase (positions → burst spawns). pub corpses: BTreeMap<(i32, i32, i32), CorpseClock>, /// Live signal origins (frayed cable, hymn-case discharge — Phase 8 hook). pub signal_sources: Vec<(i32, i32, i32)>, /// Deferred spillover re-queued next tick. pub spill: Spill, /// Character sheets keyed by actor id (docs/03 §3.1). Spawned actors get /// a Character at chargen/spawn; growth accrues via `record_use` at the /// call sites below. pub characters: BTreeMap<u64, Character>, /// Per-delver standing keyed by actor id (docs/03 §3.1: ledgers follow /// the DELVER — grudges remember faces; knowledge follows the LINEAGE /// via `hexwork-save::knowledge`). Town gossip (Phase 9) moves ledger /// entries between delvers at 3-day latency. pub reputations: BTreeMap<u64, Reputation>, /// Live item instances keyed by instance id (docs/03 §3.4). Templates /// live in content data; this table owns the per-run state (condition, /// fuel, curses). Corpse-drop on death mints corpse-tagged instances. pub items: BTreeMap<u64, Instance>, /// Canonical positions for loose item instances. Keys always reference /// [`State::items`]; carried items instead live exclusively in a Pack. pub ground_items: BTreeMap<u64, (i32, i32, i32)>, /// HAUL packs keyed by actor id (docs/03 §3.2: weight + bulk + noise). pub packs: BTreeMap<u64, Pack>, /// Core-owned deterministic map, recipe, and translation knowledge for /// this run. Mutation is restricted to validated learning operations. knowledge: RunKnowledge, /// Per-delver identification working book (docs/03 §3.4). Template ids /// learned here merge into the lineage vault at death (Phase 11); /// learning persists, gear does not. Stored as template → UsesSeen /// count (mirrors `hexwork-content` IdfBook without the content dep; /// Unknown≡absent, Tried≡1, Known≡2+ — see identify.rs ladder). pub idf: BTreeMap<String, u8>, /// Hymn-cases keyed by actor id (docs/03 §3.5: loadout strategy). /// Cast intents queue here; the Hymns phase resolves them. pub hymn_cases: BTreeMap<u64, HymnCase>, /// Pending cast intents: (caster, hymn template, target cell). Queued by /// prayer/hymn actions (Phase 9 UI); drained by the Hymns phase. pub cast_queue: Vec<(u64, String, [i32; 3])>, /// Per-delver bargain ledgers keyed by actor id (docs/03 §3.5: debts in /// words). Numeric god-credit lives in `reputations`; this is the TEXT /// side. Never enters the lineage vault (debts are gear-like, §3.9). pub bargains: BTreeMap<u64, Ledger>, /// Deep-weather conduct modifier, -20..=+20 (docs/03 §3.5: copper + /// weather shape the roll). Shallows 0, Works drift, Deep storms /// (Phase 10 owns the weather sim; Phase 8 seeds + reads). pub deep_weather: i8, /// Townsfolk records keyed by npc actor id (docs/03 §3.6). NPCs ARE /// actors (they move, block, die) with Townsfolk records — no Character /// (they don't grow by PC rules), no packs (infinite-ish stock + /// restock clock, documented in the wiring plan). pub npcs: BTreeMap<u64, Townsfolk>, /// Shop memories keyed by shop-floor anchor cell (docs/03 §3.6: /// NetHack-law ledgers/memory/grudges — shop-LOCAL; town ledgers stay /// town-global in `reputations`, never double-punish). pub shops: BTreeMap<(i32, i32, i32), ShopMemory>, /// Gossip packets in flight (docs/02 §4: 3-day latency). Drained in the /// Rot phase via `gossip_arrived`; ledger deltas land on arrival. pub gossip: Vec<GossipPacket>, /// Player chalk marks (docs/03 §3.7: no compass — chalk + annotations + /// mule-pathing replace markers). Per-delver; dust-capped at 64. pub chalk: Chalk, /// The mule's path (docs/01: you will lose the mule). The mule IS an /// actor; this is its chalk-following route. None = unhitched/gone. pub mule: Option<MulePath>, /// The Vault Choice, set once at floor 12 (docs/03 §3.7: SEAL vs CROWN, /// NG+ boss-haunt reads it). None = undecided. Private so callers cannot /// bypass [`State::set_vault_choice`]'s write-once invariant. vault_choice: Option<VaultChoice>, /// Authoritative Story resurrection labor, keyed by the indebted actor. chapel_debts: BTreeMap<u64, ChapelDebt>, next_id: u64, /// When true, the outer loop advances exactly one tick per Shift press /// (roguelike step mode, docs/03 §3.2). The kernel just exposes the /// single-step primitive; key handling lives in `hexwork-term`/`cli`. pub step_mode: bool, /// The single pending committed-action slot (docs/fluid §4.3). At most one /// operation is in flight; it stores a typed, candidate-free descriptor and /// the authored remaining ticks — never a cloned `State`. Admission and /// finalization policy belong to the caller (stage 2); the kernel owns the /// slot's shape, hash, image projection, and validation invariants. pub pending: Option<Pending>, /// Canonical player terrain-observation memory (docs/fluid §5), keyed by /// active location + voxel boundary face. Bounded per location and in /// aggregate; refreshed at admitted world-tick boundaries, on initial /// location setup, and on explicit arrival. Private so it can only change /// through the canonical observation pass or the explicit epoch/clear /// lifecycle hooks below. observations: ObservationMemory, /// Non-canonical light-bake memo (docs/03 §3.3 law 1 wiring): the source /// set of the last completed `Light` pass. The lamp list is owned by /// [`State::lights`] and the outer loop moves a carried lamp by overwriting /// that list in place, so the phase keeps its own record of what was baked /// to know when a lamp appeared, moved, or was doused and therefore when a /// full re-bake (plus clearing the departed lamps' old boxes) is required. /// This is a pure derived cache: it is excluded from the checkpoint image, /// the canonical hash, and the wire, and [`State::from_image`] rebuilds it /// empty, so the first post-restore light phase re-bakes exactly the field a /// live run holds without perturbing persisted or replay bytes. light_baked: Vec<LightSource>,}
/// Canonical hymn-id → verb map (mirrors assets/data/hymns.toml verbs)./// New hymns extend BOTH this table and the TOML; the content-shape test/// pins them together (wiring-plan §1)./// Canonical persistence/replay hash protocol version. Increment whenever any/// domain, field order, enum tag, or primitive encoding changes.pub const CANONICAL_HASH_VERSION: u64 = 0x8005_0005; // development fixed schema, NOT final v5/// Appended canonical hash domain for [`RunKnowledge`]. Domains 0..=46 retain/// their v3 order and primitive encodings; this is the sole v4 addition.pub const RUN_KNOWLEDGE_HASH_DOMAIN: u64 = 47;/// Appended canonical hash domain for the single pending-action slot/// (docs/fluid §4.3). One outer row: presence, start tick, remaining ticks,/// operation tag, and every captured target id. Absent = zero rows.pub const PENDING_HASH_DOMAIN: u64 = 48;/// Appended canonical hash domain for the canonical player terrain-observation/// memory (docs/fluid §5). One outer row per location, then one row per reached/// boundary face carrying its last-seen appearance, observation tick, and/// branch epoch. Absent = zero rows.pub const OBSERVATION_HASH_DOMAIN: u64 = 49;/// Appended canonical hash domain for the durable Story branch epoch/// (docs/fluid §7). One outer row: the epoch scalar. `0` for a fresh run; a/// Story resurrection increments it. Hashing it means a rolled-back branch and/// its restored successor cannot collide even when their record contents and/// ticks agree.pub const BRANCH_EPOCH_HASH_DOMAIN: u64 = 50;
/// Release-build budget for explicitly hashing a fully populated 27-chunk/// persistence snapshot. Canonical hashing is intentionally a persistence,/// checkpoint, and replay boundary operation—not tick or render work. Parent/// callers must snapshot/hash off the tick/render path and may move this work/// to a background worker when their ownership model permits it.pub const CANONICAL_HASH_RELEASE_BUDGET_MS: u64 = 250;
struct CanonicalHasher { hash: u64,}
impl CanonicalHasher { fn new() -> Self { let mut hasher = Self { hash: 0xcbf2_9ce4_8422_2325, }; hasher.u64(0x4845_5857_4f52_4b00); // "HEXWORK\0" hasher.u64(CANONICAL_HASH_VERSION); hasher }
fn domain(&mut self, tag: u64, len: usize) { self.u64(0xd04a_1e00_0000_0000 | tag); self.u64(len as u64); }
fn u64(&mut self, value: u64) { self.hash ^= value; self.hash = self.hash.wrapping_mul(0x100_0000_01b3); }
fn bool(&mut self, value: bool) { self.u64(u64::from(value)); }
fn bytes(&mut self, bytes: &[u8]) { self.u64(bytes.len() as u64); for &byte in bytes { self.u64(u64::from(byte)); } }
fn text(&mut self, text: &str) { self.bytes(text.as_bytes()); }
fn option_u64(&mut self, value: Option<u64>) { match value { None => self.u64(0), Some(value) => { self.u64(1); self.u64(value); } } }
fn cell(&mut self, cell: (i32, i32, i32)) { self.u64(cell.0 as u64); self.u64(cell.1 as u64); self.u64(cell.2 as u64); }
fn item(&mut self, item: &Instance) { self.u64(item.id); self.text(&item.template); self.u64(u64::from(item.material)); self.bool(item.identified); self.bool(item.cursed); self.bool(item.blessed); self.u64(u64::from(item.condition.canonical_tag())); self.bool(item.silvered); self.bool(item.illegal); self.domain(1, item.tags.len()); for tag in &item.tags { self.text(tag); } self.u64(u64::from(item.fuel)); self.u64(item.wet_ticks); }
fn finish(self) -> u64 { self.hash }}
fn template_to_verb(template: &str) -> String { match template { "fiat_lux" => "FIAT LUX", "aperi" => "APERI", "pondus" => "PONDUS", "sile" => "SILE", "fluctus" => "FLUCTUS", "memoria" => "MEMORIA", "caro_vale" => "CARO VALE", other => other, } .to_string()}
/// Gossip packet in flight (docs/02 §4: steal in Milthollow, Copperwash/// hears in 3 days). Queued by ledger-changing deeds; drained in Rot via/// `schedule::gossip_arrived`; the delta lands on the target town ledger.#[derive(Clone, Debug, PartialEq, Eq)]pub struct GossipPacket { /// Town id where the deed happened. pub from: String, /// Town id that hears about it. pub to: String, /// FarField day the packet was sent. pub sent_day: u64, /// Ledger delta to apply on arrival (grudges travel, faces blur). pub ledger_delta: i16,}
impl State { pub fn new(seed: u64) -> Self { let mut rng_master = Rng::from_seed(seed); // Draw once so `fork` streams diverge from the raw seed. let _ = rng_master.next_u64(); let _ = domains::WORLDGEN_SEED; Self { tick: 0, seed, branch_epoch: 0, rng_master, ecs: Ecs::new(), grid: ChunkedGrid::new(), actors: Vec::new(), actor_identities: BTreeMap::new(), wallets: BTreeMap::new(), contracts: BTreeMap::new(), // Content chooses the starting town id before live play. run_location: RunLocation::Uninitialized, connectors: Vec::new(), pressure_nodes: Vec::new(), pressure_edges: Vec::new(), causes: CauseChain::new(), far: FarField::default(), laws: LawLog::new(), fall_odo: BTreeMap::new(), lights: Vec::new(), sounds: Vec::new(), corpses: BTreeMap::new(), signal_sources: Vec::new(), spill: Spill::default(), characters: BTreeMap::new(), reputations: BTreeMap::new(), items: BTreeMap::new(), ground_items: BTreeMap::new(), packs: BTreeMap::new(), knowledge: RunKnowledge::new(), idf: BTreeMap::new(), hymn_cases: BTreeMap::new(), cast_queue: Vec::new(), bargains: BTreeMap::new(), deep_weather: 0, npcs: BTreeMap::new(), shops: BTreeMap::new(), gossip: Vec::new(), chalk: Chalk::new(), mule: None, vault_choice: None, chapel_debts: BTreeMap::new(), next_id: 1, step_mode: false, pending: None, observations: ObservationMemory::new(), light_baked: Vec::new(), } }
fn alloc_id(&mut self) -> u64 { let id = self.next_id; self.next_id = self .next_id .checked_add(1) .expect("authoritative id space exhausted"); id }
/// Read the run's irrevocable Vault Choice, or `None` before floor 12. #[must_use] pub fn vault_choice(&self) -> Option<VaultChoice> { self.vault_choice }
/// Commit the Vault Choice exactly once. /// /// A second attempt cannot replace the first choice and returns that /// original choice as the error, allowing UI callers to explain what was /// already sworn without reading or mutating internal state directly. pub fn set_vault_choice(&mut self, choice: VaultChoice) -> Result<(), VaultChoice> { if let Some(original) = self.vault_choice { return Err(original); } self.vault_choice = Some(choice); Ok(()) }
/// Read an actor's outstanding Story resurrection labor. #[must_use] pub fn chapel_debt(&self, actor_id: u64) -> Option<&ChapelDebt> { self.chapel_debts.get(&actor_id) }
/// Assign the canonical one-burial resurrection debt to a living actor. pub fn assign_chapel_debt(&mut self, actor_id: u64) -> Result<(), modes::ChapelDebtError> { let actor = self .actors .iter() .find(|actor| actor.id == actor_id) .ok_or(modes::ChapelDebtError::UnknownActor)?; if !actor.alive { return Err(modes::ChapelDebtError::DeadActor); } self.chapel_debts .insert(actor_id, modes::resurrection_debt()); Ok(()) }
/// Work one burial. The row is removed exactly when the debt is paid. pub fn work_chapel_debt(&mut self, actor_id: u64) -> Result<bool, modes::ChapelDebtError> { let debt = self .chapel_debts .get_mut(&actor_id) .ok_or(modes::ChapelDebtError::NoDebt)?; let paid = modes::debt_paid(debt); if paid { self.chapel_debts.remove(&actor_id); } Ok(paid) }
/// Admit exactly one player sample for the next world tick. Does not advance time. /// The session must call `step()` once even if collision blocks the sample. pub fn admit_player_movement(&mut self, actor_id: u64, heading: Option<crate::heading::Heading>) -> Result<(), crate::locomotion::MovementAdmissionError> { use crate::locomotion::{MovementAdmissionError as Error, MovementSample}; self.validate_runtime().map_err(|_| Error::InvalidState)?; if !self.actors.iter().any(|a| a.id == actor_id && a.alive) || !self.actor_identities.get(&actor_id).is_some_and(|i| i.role == crate::runtime::ActorRole::Player) { return Err(Error::MissingLivePlayer); } if self.spill.desired_moves.contains_key(&actor_id) { return Err(Error::AlreadyAdmitted); } self.spill.desired_moves.insert(actor_id, MovementSample::player(heading)); Ok(()) }
/// Compatibility spawn path. New content-aware callers should use /// [`State::spawn_actor_with_identity`]. Both paths create exactly one /// authoritative [`Actor`] and never create an ECS entity. pub fn spawn_actor(&mut self, pos: crate::spatial::FixedVec3) -> u64 { self.spawn_actor_with_identity(pos, ActorIdentity::legacy()) }
/// Spawn one authoritative actor and attach runtime metadata keyed by the /// same stable id. ECS remains dormant: no mirrored entity is created. pub fn spawn_actor_with_identity(&mut self, pos: crate::spatial::FixedVec3, identity: ActorIdentity) -> u64 { let id = self.alloc_id(); let mut actor = Actor::new(id, pos); actor.hp = 10; self.actors.push(actor); self.actor_identities.insert(id, identity); self.wallets.insert(id, Wallet::default()); id }
/// Spawn one loose content-derived item at a world cell and return its /// stable authoritative id. Conversion errors do not consume an id. pub fn spawn_ground_item( &mut self, template: &ItemTemplate, position: (i32, i32, i32), ) -> Result<u64, String> { let id = self.next_id; let instance = item::spawn(template, id)?; let allocated = self.alloc_id(); debug_assert_eq!(allocated, id); self.items.insert(id, instance); self.ground_items.insert(id, position); Ok(id) }
/// Spawn one content-derived item directly into an existing actor pack and /// return its stable authoritative id. Conversion and ownership checks run /// before the id is consumed. pub fn spawn_carried_item( &mut self, actor_id: u64, template: &ItemTemplate, ) -> Result<u64, String> { if !self.actors.iter().any(|actor| actor.id == actor_id) { return Err(format!("unknown item owner {actor_id}")); } if !self.packs.contains_key(&actor_id) { return Err(format!("actor {actor_id} has no pack")); } let id = self.next_id; let instance = item::spawn(template, id)?; let allocated = self.alloc_id(); debug_assert_eq!(allocated, id); self.packs .get_mut(&actor_id) .expect("pack preflighted") .add(instance); Ok(id) }
/// Move one loose item at the living actor's rounded cell into their pack. /// Every rejection occurs before the first ownership-table mutation. pub fn pickup_ground_item( &mut self, actor_id: u64, item_id: u64, ) -> Result<ItemTransfer, TransferError> { self.validate_runtime() .map_err(TransferError::InvalidRuntime)?; let actor = self .actors .iter() .find(|actor| actor.id == actor_id) .ok_or(TransferError::UnknownActor)?; if !actor.alive || actor.hp <= 0 { return Err(TransferError::DeadActor); } if !self.packs.contains_key(&actor_id) { return Err(TransferError::MissingPack); } let [x, y, z] = actor.position.cell(); let cell = (x, y, z); if !self.items.contains_key(&item_id) { return Err(TransferError::GroundItemMissing); } if self.ground_items.get(&item_id) != Some(&cell) { return Err(TransferError::GroundItemElsewhere); }
let item = self.items.remove(&item_id).expect("loose item preflighted"); let removed_cell = self .ground_items .remove(&item_id) .expect("ground location validated with runtime"); debug_assert_eq!(removed_cell, cell); let replaced = self .packs .get_mut(&actor_id) .expect("pack preflighted") .items .insert(item_id, item); debug_assert!(replaced.is_none()); Ok(ItemTransfer { item_id, cell }) }
/// Move one carried item to the living actor's rounded cell. /// Every rejection occurs before the first ownership-table mutation. pub fn drop_carried_item( &mut self, actor_id: u64, item_id: u64, ) -> Result<ItemTransfer, TransferError> { self.validate_runtime() .map_err(TransferError::InvalidRuntime)?; let actor = self .actors .iter() .find(|actor| actor.id == actor_id) .ok_or(TransferError::UnknownActor)?; if !actor.alive || actor.hp <= 0 { return Err(TransferError::DeadActor); } let pack = self .packs .get(&actor_id) .ok_or(TransferError::MissingPack)?; let [x, y, z] = actor.position.cell(); let cell = (x, y, z); let item = pack .items .get(&item_id) .ok_or(TransferError::CarriedItemMissing)?; if item.sticks() { return Err(TransferError::Stuck); }
let pack = self.packs.get_mut(&actor_id).expect("pack preflighted"); let item = pack .items .remove(&item_id) .expect("carried item preflighted"); if pack.equipped_weapon == Some(item_id) { pack.equipped_weapon = None; } if pack.equipped_armor == Some(item_id) { pack.equipped_armor = None; } let replaced_item = self.items.insert(item_id, item); debug_assert!(replaced_item.is_none()); let replaced_cell = self.ground_items.insert(item_id, cell); debug_assert!(replaced_cell.is_none()); Ok(ItemTransfer { item_id, cell }) }
/// Read the sole authoritative working-run map/recipe/translation book. #[must_use] pub const fn knowledge(&self) -> &RunKnowledge { &self.knowledge }
/// Learn one canonical mapped-region ID. Relearning is an idempotent no-op. pub fn learn_mapped_region(&mut self, id: impl Into<String>) -> Result<bool, KnowledgeError> { self.knowledge.learn_mapped_region(id.into()) }
/// Learn one canonical recipe ID. Recipe IDs remain distinct from item /// template IDs in the identification book. pub fn learn_recipe(&mut self, id: impl Into<String>) -> Result<bool, KnowledgeError> { self.knowledge.learn_recipe(id.into()) }
/// Learn one canonical translated-hymn ID. Content existence validation is /// deliberately a session/content concern; core validates only ID shape. pub fn learn_translation(&mut self, id: impl Into<String>) -> Result<bool, KnowledgeError> { self.knowledge.learn_translation(id.into()) }
/// Establish known item-template state for inheritance or paid identification /// without reducing a higher existing count. Production `idf` represents /// effective template knowledge as a count >= 2; this monotonic raise records /// knowledge, not physical use, and emits no use event or RNG draw. /// Returns the retained count after the raise. pub fn initialize_known_template( &mut self, template_id: impl Into<String>, ) -> Result<u8, KnowledgeError> { let template_id = template_id.into(); validate_knowledge_id(KnowledgeCategory::IdentifiedTemplates, &template_id)?; let uses = self.idf.entry(template_id).or_insert(2); if *uses < 2 { *uses = 2; } Ok(*uses) }
/// Whether this concrete item is effectively identified by either its own /// flag or the run's per-template use-count book. #[must_use] pub fn is_effectively_identified(&self, instance: &Instance) -> bool { instance.identified || self .idf .get(&instance.template) .is_some_and(|uses| *uses >= 2) }
/// Canonically ordered known item-template rows for typed lineage /// extraction. Tried rows (count 1) remain in `idf` but are omitted here. pub fn known_templates(&self) -> impl Iterator<Item = &str> { self.idf .iter() .filter(|(_, uses)| **uses >= 2) .map(|(template, _)| template.as_str()) }
#[must_use] pub fn identity(&self, actor_id: u64) -> Option<&ActorIdentity> { self.actor_identities.get(&actor_id) }
pub fn identity_mut(&mut self, actor_id: u64) -> Option<&mut ActorIdentity> { self.actor_identities.get_mut(&actor_id) }
#[must_use] pub fn wallet(&self, actor_id: u64) -> Option<&Wallet> { self.wallets.get(&actor_id) }
pub fn earn_copper(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { self.wallets .get_mut(&actor_id) .ok_or(WalletError::InsufficientFunds)? .checked_earn_copper(amount) }
pub fn earn_scrip(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { self.wallets .get_mut(&actor_id) .ok_or(WalletError::InsufficientFunds)? .checked_earn_scrip(amount) }
pub fn spend_copper(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { self.wallets .get_mut(&actor_id) .ok_or(WalletError::InsufficientFunds)? .checked_spend_copper(amount) }
pub fn spend_scrip(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { self.wallets .get_mut(&actor_id) .ok_or(WalletError::InsufficientFunds)? .checked_spend_scrip(amount) }
#[must_use] pub fn contract(&self, contract_id: &str) -> Option<&ActiveContract> { self.contracts.get(contract_id) }
pub fn contracts(&self) -> impl Iterator<Item = (&str, &ActiveContract)> { self.contracts .iter() .map(|(id, contract)| (id.as_str(), contract)) }
#[allow(clippy::too_many_arguments)] pub fn accept_contract( &mut self, id: impl Into<String>, taker: u64, floor: u8, pay_cu: u32, objective_target: u32, reward_town: impl Into<String>, standing_delta: i16, ) -> Result<(), ContractError> { self.validate_runtime() .map_err(ContractError::InvalidRuntime)?; let id = id.into(); let reward_town = reward_town.into(); if id.trim().is_empty() { return Err(ContractError::BlankId); } if self.contracts.contains_key(&id) { return Err(ContractError::DuplicateId); } if !(1..=12).contains(&floor) { return Err(ContractError::InvalidFloor); } if objective_target == 0 { return Err(ContractError::InvalidObjectiveTarget); } if reward_town.trim().is_empty() { return Err(ContractError::BlankRewardTown); } let actor = self .actors .iter() .find(|actor| actor.id == taker) .ok_or(ContractError::UnknownTaker)?; if !actor.alive { return Err(ContractError::DeadTaker); } if self.run_location != RunLocation::Town(reward_town.clone()) { return Err(ContractError::WrongLocation); } self.contracts.insert( id.clone(), ActiveContract { id, taker, accepted_tick: self.tick, floor, pay_cu, objective_target, objective_progress: 0, status: ContractStatus::Active, reward_town, standing_delta, }, ); Ok(()) }
pub fn advance_contract_objective( &mut self, contract_id: &str, taker: u64, amount: u32, ) -> Result<(), ContractError> { self.validate_runtime() .map_err(ContractError::InvalidRuntime)?; let contract = self .contracts .get(contract_id) .ok_or(ContractError::NotFound)?; if contract.taker != taker { return Err(ContractError::WrongTaker); } if contract.status != ContractStatus::Active { return Err(ContractError::NotActive); } if self.run_location != RunLocation::Deep(contract.floor) { return Err(ContractError::WrongLocation); } let contract = self .contracts .get_mut(contract_id) .expect("contract checked above"); contract.objective_progress = contract .objective_progress .saturating_add(amount) .min(contract.objective_target); if contract.objective_progress == contract.objective_target { contract.status = ContractStatus::Completed; } Ok(()) }
/// Resolve one zero-tick reciprocal melee exchange as a checked transaction. /// The attacker strikes first; a surviving defender retaliates exactly once. pub fn resolve_melee_exchange( &mut self, attacker_id: u64, defender_id: u64, damage: i32, retaliation_damage: i32, defender_kill_objective: Option<&str>, ) -> Result<MeleeExchange, MeleeError> { self.resolve_melee_exchange_with_retaliation( attacker_id, defender_id, damage, Some(retaliation_damage), defender_kill_objective, ) }
/// Resolve a checked zero-tick exchange with an explicitly optional counter. /// `None` creates no return damage or cause. `Some` must be positive even /// when the outgoing strike kills the defender, who never retaliates. #[allow(clippy::too_many_lines)] pub fn resolve_melee_exchange_with_retaliation( &mut self, attacker_id: u64, defender_id: u64, damage: i32, retaliation_damage: Option<i32>, defender_kill_objective: Option<&str>, ) -> Result<MeleeExchange, MeleeError> { self.validate_runtime() .map_err(MeleeError::InvalidRuntime)?; if attacker_id == defender_id { return Err(MeleeError::SameActor); } if damage <= 0 { return Err(MeleeError::NonPositiveDamage { strike: MeleeStrike::Outgoing, }); } if retaliation_damage.is_some_and(|actual| actual <= 0) { return Err(MeleeError::NonPositiveDamage { strike: MeleeStrike::Retaliation, }); }
let attacker_index = self .actors .iter() .position(|actor| actor.id == attacker_id) .ok_or(MeleeError::UnknownAttacker)?; let defender_index = self .actors .iter() .position(|actor| actor.id == defender_id) .ok_or(MeleeError::UnknownDefender)?; let attacker = &self.actors[attacker_index]; let defender = &self.actors[defender_index]; if !attacker.alive || attacker.hp <= 0 { return Err(MeleeError::DeadAttacker); } if !defender.alive || defender.hp <= 0 { return Err(MeleeError::DeadDefender); }
let defender_hp = defender.hp.saturating_sub(damage); let defender_killed = defender_hp <= 0; let (attacker_hp, retaliation) = if defender_killed { (attacker.hp, None) } else if let Some(actual) = retaliation_damage { (attacker.hp.saturating_sub(actual), Some(actual)) } else { (attacker.hp, None) }; let attacker_killed = attacker_hp <= 0; let death_id = if defender_killed { Some(defender_id) } else if attacker_killed { Some(attacker_id) } else { None };
let objective_progress = if let Some(contract_id) = defender_kill_objective { if !defender_killed { return Err(MeleeError::ObjectiveRequiresDefenderDeath); } let contract = self .contracts .get(contract_id) .ok_or(MeleeError::InvalidObjective(ContractError::NotFound))?; if contract.taker != attacker_id { return Err(MeleeError::InvalidObjective(ContractError::WrongTaker)); } if contract.status != ContractStatus::Active { return Err(MeleeError::InvalidObjective(ContractError::NotActive)); } if self.run_location != RunLocation::Deep(contract.floor) { return Err(MeleeError::InvalidObjective(ContractError::WrongLocation)); } if contract.objective_progress >= contract.objective_target { return Err(MeleeError::ObjectiveComplete); } Some((contract.objective_progress + 1, contract.objective_target)) } else { None };
let prepared_deaths = if let Some(actor_id) = death_id { let unrelated_pending = self .actors .iter() .filter(|actor| actor.alive && actor.hp <= 0) .count() as u64; let reserved_end = self .next_id .checked_add(1) .and_then(|next| next.checked_add(unrelated_pending)); if !reserved_end.is_some_and(|end| end < u64::MAX) { return Err(MeleeError::IdExhausted); } Some(self.prepare_deaths(&[actor_id])?) } else { None };
let cause_count = 1 + usize::from(retaliation.is_some()) + prepared_deaths .as_ref() .map_or(0, |batch| batch.deaths.len()); let outgoing_cause = format!("actor {attacker_id} strikes actor {defender_id} ({damage} damage)"); let retaliation_cause = retaliation.map(|actual| { format!("actor {defender_id} retaliates against actor {attacker_id} ({actual} damage)") }); self.causes .try_reserve(cause_count) .map_err(|_| MeleeError::AllocationFailed)?;
self.actors[defender_index].hp = defender_hp; self.causes .push(CauseLink::new(self.tick, "melee", outgoing_cause)); if let Some(actual) = retaliation { self.actors[attacker_index].hp = attacker_hp; self.causes.push(CauseLink::new( self.tick, "melee", retaliation_cause.expect("retaliation detail precomputed"), )); debug_assert_eq!(Some(actual), retaliation_damage); } if let Some(prepared) = prepared_deaths { self.commit_deaths(prepared, false); } if let (Some(contract_id), Some((current, target))) = (defender_kill_objective, objective_progress) { let contract = self .contracts .get_mut(contract_id) .expect("objective contract preflighted"); contract.objective_progress = current; if current == target { contract.status = ContractStatus::Completed; } }
debug_assert!(self.validate_runtime().is_ok()); Ok(MeleeExchange { outgoing_damage: damage, defender_killed, retaliation_damage: retaliation, attacker_killed, objective_progress, }) }
pub fn fail_contract(&mut self, contract_id: &str) -> Result<(), ContractError> { self.validate_runtime() .map_err(ContractError::InvalidRuntime)?; self.fail_contract_without_validation(contract_id) }
/// Apply the open → failed transition without validating the surrounding /// State first. Death phase needs this narrow internal path because marking /// a contracted taker dead temporarily violates the runtime invariant that /// every open contract has a living taker. Callers must restore the full /// invariant before returning control to the outer simulation loop. fn fail_contract_without_validation(&mut self, contract_id: &str) -> Result<(), ContractError> { let contract = self .contracts .get_mut(contract_id) .ok_or(ContractError::NotFound)?; if !matches!( contract.status, ContractStatus::Active | ContractStatus::Completed ) { return Err(ContractError::NotOpen); } contract.status = ContractStatus::Failed; Ok(()) }
pub fn turn_in_contract(&mut self, contract_id: &str) -> Result<(), ContractError> { self.validate_runtime() .map_err(ContractError::InvalidRuntime)?; let contract = self .contracts .get(contract_id) .ok_or(ContractError::NotFound)?; if contract.status != ContractStatus::Completed { return Err(ContractError::NotCompleted); } if self.run_location != RunLocation::Town(contract.reward_town.clone()) { return Err(ContractError::WrongLocation); } let taker = contract.taker; let pay_cu = contract.pay_cu; let reward_town = contract.reward_town.clone(); let standing_delta = contract.standing_delta;
let wallet = self .wallets .get(&taker) .ok_or(ContractError::MissingWallet)?; wallet .copper() .checked_add(pay_cu) .ok_or(ContractError::PayoutOverflow)?; let reputation = self .reputations .get(&taker) .ok_or(ContractError::MissingReputation)?; reputation .ledger(&reward_town) .checked_add(standing_delta) .ok_or(ContractError::StandingOverflow)?;
self.wallets .get_mut(&taker) .expect("wallet preflighted") .checked_earn_copper(pay_cu) .expect("wallet addition preflighted"); self.reputations .get_mut(&taker) .expect("reputation preflighted") .adjust_ledger(&reward_town, standing_delta); self.contracts .get_mut(contract_id) .expect("contract checked above") .status = ContractStatus::TurnedIn; Ok(()) }
#[must_use] pub fn run_location(&self) -> &RunLocation { &self.run_location }
/// Select the initial nonblank town after content planning/loading. pub fn initialize_town(&mut self, town: impl Into<String>) -> Result<(), LocationError> { self.validate_runtime() .map_err(LocationError::InvalidRuntime)?; let town = town.into(); if town.trim().is_empty() { return Err(LocationError::BlankTown); } if self.run_location != RunLocation::Uninitialized { return Err(LocationError::IllegalTransition); } self.run_location = RunLocation::Town(town); // Initial location setup is an explicit observation boundary (docs §5.1). self.refresh_observations(); Ok(()) }
/// Move one legal edge: Town→Deep1, Deep1→Town, or adjacent Deep floors. pub fn transition_location(&mut self, next: RunLocation) -> Result<(), LocationError> { self.validate_runtime() .map_err(LocationError::InvalidRuntime)?; if let RunLocation::Town(town) = &next { if town.trim().is_empty() { return Err(LocationError::BlankTown); } } if matches!(next, RunLocation::Deep(depth) if !(1..=12).contains(&depth)) { return Err(LocationError::InvalidDepth); } let legal = match (&self.run_location, &next) { (RunLocation::Town(_), RunLocation::Deep(1)) => true, (RunLocation::Deep(1), RunLocation::Town(_)) => true, (RunLocation::Deep(from), RunLocation::Deep(to)) => from.abs_diff(*to) == 1, _ => false, }; if !legal { return Err(LocationError::IllegalTransition); } // Explicit lifecycle boundary (docs §5.2): the departing location's // observation memory is cleared, then the arrival pass re-observes. let departing = LocationKey::from_run_location(&self.run_location); self.observations.clear_location(&departing); self.run_location = next; self.refresh_observations(); Ok(()) }
/// Checkpoint/load-only setter. It validates the restored value but does /// not enforce gameplay adjacency; call [`State::validate_runtime`] after /// restoring every remaining table. pub fn restore_run_location(&mut self, location: RunLocation) -> Result<(), LocationError> { match &location { RunLocation::Town(town) if town.trim().is_empty() => { return Err(LocationError::BlankTown) } RunLocation::Deep(depth) if !(1..=12).contains(depth) => { return Err(LocationError::InvalidDepth) } _ => {} } self.run_location = location; Ok(()) }
/// Immutable projection of the canonical player terrain-observation memory /// (docs/fluid §5). Rendering and the map read detached, bounded values from /// this borrow; no live-grid handle crosses the projection boundary. #[must_use] pub fn observations(&self) -> &ObservationMemory { &self.observations }
/// Bounded, canonical-order rows of the observation memory, for persistence /// and checkpoint projection. #[must_use] pub fn observation_rows(&self) -> Vec<ObservationRow> { self.observations.rows() }
/// Authoritative feet position of the live player actor, if any. fn player_feet(&self) -> Option<crate::spatial::FixedVec3> { self.actors .iter() .find(|actor| { actor.alive && self .actor_identities .get(&actor.id) .is_some_and(|identity| identity.role == crate::runtime::ActorRole::Player) }) .map(|actor| actor.position) }
/// Run the canonical omnidirectional observation pass from the live player /// actor's authoritative position and admit the reached boundary faces for /// the current location (docs/fluid §5.1). /// /// This is independent of render frame rate and window size: it samples at /// an admitted world-tick boundary and is not affected by camera frustum. /// Every produced record is stamped with the live [`State::branch_epoch`], /// so a discovery made after a Story rollback carries the restored branch's /// epoch and cannot alias an old branch's discovery at a reused tick /// (docs/fluid §7). /// /// Returns the number of newly observed faces. A capacity rejection leaves /// the memory unchanged (unknown/stale, never wrong); the caller decides /// whether that is fatal. pub fn observe_player(&mut self) -> Result<usize, ObservationError> { self.observe_player_with_epoch(self.branch_epoch) }
/// [`State::observe_player`] with an explicit branch epoch. pub fn observe_player_with_epoch(&mut self, branch_epoch: u64) -> Result<usize, ObservationError> { let Some(feet) = self.player_feet() else { return Ok(0); }; let location = LocationKey::from_run_location(&self.run_location); if location.is_uninitialized() { return Ok(0); } let Ok(origin) = observer_origin(feet) else { return Ok(0); }; let reached = observe(&self.grid, origin, self.tick, branch_epoch); self.observations .admit(&location, &reached, ObservationLimits::canonical()) }
/// Restamp every retained observation record with a new branch epoch /// (docs/fluid §7: rollback restamps so a stale descriptor, target, or /// observation cannot pass as current). Stage 2 calls this on resurrection. pub fn restamp_observation_epoch(&mut self, branch_epoch: u64) { self.observations.restamp_epoch(branch_epoch); }
/// Clear the observation memory at an explicit lifecycle boundary. Arrival /// (§5.2: the cache is cleared or restamped only at its explicit lifecycle /// boundary) uses this before re-observing the new location. pub fn clear_observations(&mut self) { self.observations.clear_all(); }
/// Run the canonical observation pass as a best-effort tick-boundary /// refresh, stamping the live branch epoch. A capacity rejection retains the /// prior memory rather than evicting remembered faces, so a saturated memory /// degrades to blank for the overflow faces and never to a wrong remembered /// value. fn refresh_observations(&mut self) { let _ = self.observe_player_with_epoch(self.branch_epoch); }
/// Validate canonical authority after world planning/load and before /// persistence/replay hashing. Public legacy tables remain mutable, so the /// parent MUST call this after assembling or restoring State. pub fn validate_runtime(&self) -> Result<(), RuntimeInvariantError> { self.knowledge .validate() .map_err(RuntimeInvariantError::Knowledge)?; for template_id in self.idf.keys() { validate_knowledge_id(KnowledgeCategory::IdentifiedTemplates, template_id) .map_err(RuntimeInvariantError::Knowledge)?; } if !self.ecs.world().is_empty() { return Err(RuntimeInvariantError::EcsNotEmpty { entities: self.ecs.world().len(), }); } let mut actor_ids = BTreeMap::new(); let mut max_live_id = 0; for actor in &self.actors { if actor.id == 0 { return Err(RuntimeInvariantError::ZeroStableId { table: "actors" }); } if actor_ids.insert(actor.id, actor.alive).is_some() { return Err(RuntimeInvariantError::DuplicateActorId { actor_id: actor.id }); } max_live_id = max_live_id.max(actor.id); actor.body.bounds(actor.position).map_err(|_| RuntimeInvariantError::InvalidFixedGeometry { table: "actors", row: actor.id })?; if !self.actor_identities.contains_key(&actor.id) { return Err(RuntimeInvariantError::MissingIdentity { actor_id: actor.id }); } if !self.wallets.contains_key(&actor.id) { return Err(RuntimeInvariantError::MissingWallet { actor_id: actor.id }); } } for id in self.actor_identities.keys() { if !actor_ids.contains_key(id) { return Err(RuntimeInvariantError::OrphanIdentity { actor_id: *id }); } } for id in self.wallets.keys() { if !actor_ids.contains_key(id) { return Err(RuntimeInvariantError::OrphanWallet { actor_id: *id }); } } for (table, ids) in [ ( "fall_odo", self.fall_odo.keys().copied().collect::<Vec<_>>(), ), ("characters", self.characters.keys().copied().collect()), ("reputations", self.reputations.keys().copied().collect()), ("packs", self.packs.keys().copied().collect()), ("hymn_cases", self.hymn_cases.keys().copied().collect()), ("bargains", self.bargains.keys().copied().collect()), ("npcs", self.npcs.keys().copied().collect()), ] { if let Some(id) = ids.into_iter().find(|id| !actor_ids.contains_key(id)) { return Err(RuntimeInvariantError::OrphanActorMetadata { table, actor_id: id, }); } } let mut item_ids = BTreeMap::new(); for (key, item) in &self.items { if *key == 0 || item.id == 0 { return Err(RuntimeInvariantError::ZeroStableId { table: "items" }); } if actor_ids.contains_key(key) { return Err(RuntimeInvariantError::StableIdCollision { id: *key, first: "actors", second: "items", }); } if *key != item.id { return Err(RuntimeInvariantError::ItemKeyMismatch { table: "items", key: *key, embedded: item.id, }); } item_ids.insert(item.id, ()); max_live_id = max_live_id.max(item.id); if !self.ground_items.contains_key(key) { return Err(RuntimeInvariantError::LooseItemMissingGround { item_id: *key }); } } for item_id in self.ground_items.keys() { if !self.items.contains_key(item_id) { return Err(RuntimeInvariantError::DanglingGroundItem { item_id: *item_id }); } } const CANONICAL_FACTIONS: [crate::reputation::Faction; 5] = [ crate::reputation::Faction::Lampwrights, crate::reputation::Faction::Congregation, crate::reputation::Faction::Wardens, crate::reputation::Faction::Audit, crate::reputation::Faction::Choir, ]; const CANONICAL_GODS: [crate::reputation::GodId; 5] = [ crate::reputation::GodId::Lamplighter, crate::reputation::GodId::Tollkeeper, crate::reputation::GodId::Choir, crate::reputation::GodId::Rustmother, crate::reputation::GodId::Audit, ]; for (actor_id, reputation) in &self.reputations { for (index, ((found, _), expected)) in reputation .factions .iter() .zip(CANONICAL_FACTIONS) .enumerate() { if *found != expected { return Err(RuntimeInvariantError::NonCanonicalReputationArray { actor_id: *actor_id, table: "factions", index, expected: expected.canonical_tag(), found: found.canonical_tag(), }); } } for (index, ((found, _), expected)) in reputation.god_credit.iter().zip(CANONICAL_GODS).enumerate() { if *found != expected { return Err(RuntimeInvariantError::NonCanonicalReputationArray { actor_id: *actor_id, table: "god_credit", index, expected: expected.canonical_tag(), found: found.canonical_tag(), }); } } } for (actor_id, pack) in &self.packs { for (key, item) in &pack.items { if *key == 0 || item.id == 0 { return Err(RuntimeInvariantError::ZeroStableId { table: "packs" }); } if actor_ids.contains_key(key) { return Err(RuntimeInvariantError::StableIdCollision { id: *key, first: "actors", second: "packs", }); } if *key != item.id { return Err(RuntimeInvariantError::ItemKeyMismatch { table: "packs", key: *key, embedded: item.id, }); } if item_ids.insert(item.id, ()).is_some() { return Err(RuntimeInvariantError::DuplicateItemId { item_id: item.id }); } max_live_id = max_live_id.max(item.id); } for item_id in [pack.equipped_weapon, pack.equipped_armor] .into_iter() .flatten() { if !pack.items.contains_key(&item_id) { return Err(RuntimeInvariantError::DanglingEquippedItem { actor_id: *actor_id, item_id, }); } } } for connector in &self.connectors { for (field, value) in [ ("from[0]", connector.from[0]), ("from[1]", connector.from[1]), ("from[2]", connector.from[2]), ("to[0]", connector.to[0]), ("to[1]", connector.to[1]), ("to[2]", connector.to[2]), ("length", connector.length), ("load_kg", connector.load_kg), ] { if !value.is_finite() { return Err(RuntimeInvariantError::NonFiniteField { table: "connectors", row: connector.id, field, }); } } if connector.length < 0.0 || connector.length < connector.depth() { return Err(RuntimeInvariantError::InvalidFloatRange { table: "connectors", row: connector.id, field: "length", }); } if connector.load_kg < 0.0 { return Err(RuntimeInvariantError::InvalidFloatRange { table: "connectors", row: connector.id, field: "load_kg", }); } } for node in &self.pressure_nodes { if !node.pressure.is_finite() { return Err(RuntimeInvariantError::NonFiniteField { table: "pressure_nodes", row: node.id, field: "pressure", }); } } for (index, edge) in self.pressure_edges.iter().enumerate() { if !edge.openness.is_finite() { return Err(RuntimeInvariantError::NonFiniteField { table: "pressure_edges", row: index as u64, field: "openness", }); } if !(0.0..=1.0).contains(&edge.openness) { return Err(RuntimeInvariantError::InvalidFloatRange { table: "pressure_edges", row: index as u64, field: "openness", }); } } for (id, sample) in &self.spill.desired_moves { if !self.actors.iter().any(|a| a.id == *id && a.alive) { return Err(RuntimeInvariantError::OrphanActorMetadata { table: "spill.desired_moves", actor_id: *id }); } if !sample.valid() { return Err(RuntimeInvariantError::InvalidFixedGeometry { table: "spill.desired_moves", row: *id }); } } for (id,cells) in &self.spill.crossed_cells { if !self.actors.iter().any(|a| a.id == *id) { return Err(RuntimeInvariantError::OrphanActorMetadata { table: "spill.crossed_cells", actor_id: *id }); } for &(x,y,z) in cells { if crate::spatial::FixedVec3::from_cell_feet([x,y,z]).is_err() { return Err(RuntimeInvariantError::InvalidFixedGeometry {table:"spill.crossed_cells",row:*id}); } } } // Fall distances are unsigned millimetres; no nonfinite/negative encoding exists. for (index, (_, _, flow)) in self.spill.gas_flows.iter().enumerate() { if !flow.is_finite() { return Err(RuntimeInvariantError::NonFiniteField { table: "spill.gas_flows", row: index as u64, field: "flow", }); } } for (actor_id, pack) in &self.packs { if !pack.max_weight_kg.is_finite() { return Err(RuntimeInvariantError::NonFiniteField { table: "packs", row: *actor_id, field: "max_weight_kg", }); } if pack.max_weight_kg <= 0.0 { return Err(RuntimeInvariantError::InvalidFloatRange { table: "packs", row: *actor_id, field: "max_weight_kg", }); } } for (caster, _, _) in &self.cast_queue { if !actor_ids.contains_key(caster) { return Err(RuntimeInvariantError::OrphanActorMetadata { table: "cast_queue", actor_id: *caster, }); } } for shop in self.shops.values() { for id in shop .broke .keys() .chain(shop.stole.keys()) .chain(shop.killed.keys()) { if !actor_ids.contains_key(id) { return Err(RuntimeInvariantError::OrphanActorMetadata { table: "shops", actor_id: *id, }); } } } for (actor_id, debt) in &self.chapel_debts { let Some(alive) = actor_ids.get(actor_id) else { return Err(RuntimeInvariantError::OrphanChapelDebt { actor_id: *actor_id, }); }; if !alive { return Err(RuntimeInvariantError::DeadChapelDebtor { actor_id: *actor_id, }); } if debt.burials_owed == 0 { return Err(RuntimeInvariantError::ZeroChapelDebt { actor_id: *actor_id, }); } if debt.chapel != modes::LAMPLIGHTER_CHAPEL { return Err(RuntimeInvariantError::NonCanonicalChapelDebt { actor_id: *actor_id, }); } } for link in self.causes.links() { if laws::canonical_rule_tag(link.rule).is_none() { return Err(RuntimeInvariantError::UnknownStaticRule { rule: link.rule.to_owned(), }); } } for rule in self.laws.seen.keys() { if laws::canonical_rule_tag(rule).is_none() { return Err(RuntimeInvariantError::UnknownStaticRule { rule: (*rule).to_owned(), }); } } for (key, contract) in &self.contracts { if key != &contract.id { return Err(RuntimeInvariantError::ContractKeyMismatch { key: key.clone(), embedded: contract.id.clone(), }); } if contract.id.trim().is_empty() { return Err(RuntimeInvariantError::InvalidContractId { id: contract.id.clone(), }); } if !(1..=12).contains(&contract.floor) { return Err(RuntimeInvariantError::InvalidContractFloor { id: contract.id.clone(), floor: contract.floor, }); } if contract.objective_target == 0 { return Err(RuntimeInvariantError::InvalidContractTarget { id: contract.id.clone(), }); } if contract.objective_progress > contract.objective_target { return Err(RuntimeInvariantError::InvalidContractProgress { id: contract.id.clone(), }); } if contract.reward_town.trim().is_empty() { return Err(RuntimeInvariantError::InvalidRewardTown { id: contract.id.clone(), }); } let Some(alive) = actor_ids.get(&contract.taker) else { return Err(RuntimeInvariantError::ContractTakerMissing { id: contract.id.clone(), taker: contract.taker, }); }; if matches!( contract.status, ContractStatus::Active | ContractStatus::Completed ) && !alive { return Err(RuntimeInvariantError::ContractTakerDead { id: contract.id.clone(), taker: contract.taker, }); } } match &self.run_location { RunLocation::Town(town) if town.trim().is_empty() => { return Err(RuntimeInvariantError::InvalidRunLocation) } RunLocation::Deep(depth) if !(1..=12).contains(depth) => { return Err(RuntimeInvariantError::InvalidRunLocation) } _ => {} } if self.tick == u64::MAX { return Err(RuntimeInvariantError::TickExhausted); } if self.far.day_tick >= TICKS_PER_DAY { return Err(RuntimeInvariantError::InvalidFarDayTick { day_tick: self.far.day_tick, }); } if self.far.day == u64::MAX && self.far.day_tick == TICKS_PER_DAY - 1 { return Err(RuntimeInvariantError::FarDayExhausted); } if self.next_id == u64::MAX { return Err(RuntimeInvariantError::NextIdExhausted); } let pending_corpse_allocations = self .actors .iter() .filter(|actor| actor.alive && actor.hp <= 0) .count() as u64; if self .next_id .checked_add(pending_corpse_allocations) .is_none() { return Err(RuntimeInvariantError::NextIdExhausted); } if self.next_id <= max_live_id { return Err(RuntimeInvariantError::NextIdNotAboveLiveId { next_id: self.next_id, live_id: max_live_id, }); } self.validate_pending().map_err(RuntimeInvariantError::Pending)?; self.validate_observations()?; Ok(()) }
/// Validate the canonical observation memory: the bounded per-location and /// aggregate caps, cap consistency, and provenance (every retained record's /// location must be a well-formed live Town/Deep key, never the reserved /// uninitialized location). fn validate_observations(&self) -> Result<(), RuntimeInvariantError> { self.observations .validate(ObservationLimits::canonical()) .map_err(RuntimeInvariantError::Observation) }
/// Validate the single pending-action slot against the live world. /// /// Enforces: `remaining <= total`; the operation starts at or before the /// current tick; every captured target id resolves to a live entity (no /// zero sentinels, no dangling ids); canonical ids are non-blank; and the /// Inn descriptor agrees with the slot's own countdown and the authored day. fn validate_pending(&self) -> Result<(), PendingError> { let Some(pending) = &self.pending else { return Ok(()); }; let total = pending.action.total_ticks(); if pending.remaining_ticks > total { return Err(PendingError::RemainingExceedsTotal { remaining: pending.remaining_ticks, total, }); } if pending.start_tick > self.tick { return Err(PendingError::StartTickInFuture { start_tick: pending.start_tick, tick: self.tick, }); } match &pending.action { PendingAction::Move { .. } | PendingAction::Wait | PendingAction::UseService { .. } => {} PendingAction::InspectBoard { town } => { if town.trim().is_empty() { return Err(PendingError::BlankIdentifier { table: "pending.inspect_board.town", }); } } PendingAction::AcceptContract { id } => { if id.trim().is_empty() { return Err(PendingError::BlankIdentifier { table: "pending.accept_contract.id", }); } } PendingAction::UseTransition { transition_id } => { if *transition_id == 0 { return Err(PendingError::ZeroTarget { table: "pending.transition_id", }); } } PendingAction::ResolveAttack { target_actor } => { if *target_actor == 0 { return Err(PendingError::ZeroTarget { table: "pending.target_actor", }); } if !self.actors.iter().any(|actor| actor.id == *target_actor) { return Err(PendingError::UnknownTarget { table: "pending.target_actor", id: *target_actor, }); } } PendingAction::Pickup { item_id } => { if *item_id == 0 { return Err(PendingError::ZeroTarget { table: "pending.item_id", }); } // Pickup targets a loose item on the ground. `self.items` holds // exactly the ground/loose instances (each mirrored in // `self.ground_items`); carried instances live in packs and are // not pickable. A carried id here is a dangling ground target. if !self.items.contains_key(item_id) { return Err(PendingError::UnknownTarget { table: "pending.item_id", id: *item_id, }); } } PendingAction::Drop { item_id } | PendingAction::Wield { item_id } | PendingAction::TinkerIdentify { item_id } => { if *item_id == 0 { return Err(PendingError::ZeroTarget { table: "pending.item_id", }); } // Drop/Wield/TinkerIdentify act on what the actor carries. // Carried instances live in `packs[actor].items`, never in // `self.items` (which holds only ground/loose instances), so // resolve against the packs. The descriptor captures no actor, // so any pack holding the id is the correct target class. if !self .packs .values() .any(|pack| pack.items.contains_key(item_id)) { return Err(PendingError::UnknownTarget { table: "pending.item_id", id: *item_id, }); } } PendingAction::TurnIn { contract_id } => { if contract_id.trim().is_empty() { return Err(PendingError::BlankIdentifier { table: "pending.turn_in.contract_id", }); } } PendingAction::Inn(inn) => { let authored = crate::services::sleep_ticks(); if inn.total_ticks != authored { return Err(PendingError::InnTotalNotAuthored { total: inn.total_ticks, authored, }); } if inn.remaining_ticks != pending.remaining_ticks { return Err(PendingError::InnRemainingMismatch { slot_remaining: pending.remaining_ticks, inn_remaining: inn.remaining_ticks, }); } } } Ok(()) }
/// Ensure the 3×3×3 active chunk set around a chunk coordinate exists. pub fn activate_around(&mut self, cx: i32, cy: i32, cz: i32) { self.grid.activate(cx, cy, cz); }
/// Fork the subsystem stream for one tick phase that needs randomness. pub fn fork_rng(&mut self, domain_tag: u64) -> Rng { self.rng_master.fork(domain_tag) }
/// Push a display-facing hazard link, speaking its law line exactly once /// per run (docs/04 §4.10.5). Every occurrence is recorded for replays; /// the NetHack-voice `explain` line fires on first sight only. pub fn speak_law(&mut self, rule: &'static str) { let tick = self.tick; if self.laws.first_time(rule, tick) { self.causes .push(CauseLink::new(tick, rule, laws::explain(rule))); } }
/// Active region around the lead actor (or origin when actorless). fn active_region(&self) -> ((i32, i32, i32), (i32, i32, i32)) { let (cx, cy, cz) = self .actors .first() .map(|a| { let [x, y, z] = a.position.cell(); (x, y, z) }) .unwrap_or((0, 0, 0)); let e = ACTIVE_HALF_EXTENT; ((cx - e, cy - e, cz - e), (cx + e, cy + e, cz + e)) }
/// Derive the draft field: flow across every pressure edge from endpoint /// pressures (docs/04 §4.3, docs/03 §3.3.3). Index-aligned with /// `pressure_edges`. Hidden vents contribute zero until flour or smoke /// reveals them (Phase 2). Positive = `from` → `to`. pub fn drafts(&self) -> Vec<f32> { self.pressure_edges .iter() .map(|e| { let pf = self .pressure_nodes .iter() .find(|n| n.id == e.from) .map(|n| n.pressure) .unwrap_or(0.0); let pt = self .pressure_nodes .iter() .find(|n| n.id == e.to) .map(|n| n.pressure) .unwrap_or(0.0); e.flow(pf, pt) }) .collect() }
/// Advance exactly one 100ms tick through all 12 phases in order. /// This IS the Shift-step primitive: call once per keypress in step mode. pub fn step(&mut self) { for phase in ORDER { self.run_phase(phase); } self.tick += 1; self.far.advance(); // Canonical observation boundary (docs §5.1): refresh the player's // omnidirectional terrain memory from the authoritative position at // this admitted tick, independent of render frame rate/window size. self.refresh_observations(); // Full canonical hashing is intentionally restricted to explicit // persistence/replay/checkpoint boundaries; a simulation tick never // scans the loaded world merely to discard the digest. }
/// Advance protected off-screen time without scanning every loaded voxel on /// every tick. This is the deterministic inn-sleep path: it runs NPC /// schedule intents, live-item drying/rust, corpse clocks/bursts, gossip, /// and the authoritative tick/FarField clocks. It deliberately omits the /// spatial fluids, gas, fire, light, sound, and structure phases while the /// player is safely lodged; ordinary active play continues to use `step`. pub fn preflight_unattended( &self, ticks: u64, ) -> Result<(u64, u64, u64), UnattendedAdvanceError> { let final_tick = self .tick .checked_add(ticks) .ok_or(UnattendedAdvanceError::TickOverflow)?; let (final_day, final_day_tick) = self .far .checked_advance_by(ticks) .ok_or(UnattendedAdvanceError::FarFieldOverflow)?; Ok((final_tick, final_day, final_day_tick)) }
pub fn advance_unattended(&mut self, ticks: u64) -> Result<(), UnattendedAdvanceError> { let final_clocks = self.preflight_unattended(ticks)?; self.advance_unattended_preflighted(ticks, final_clocks); Ok(()) }
/// Debit and advance as one transaction. All fallible checks happen before /// mutation; the private commit cannot fail after the wallet changes. pub fn spend_copper_and_advance_unattended( &mut self, actor_id: u64, amount: u32, ticks: u64, ) -> Result<(), PaidUnattendedError> { let final_clocks = self .preflight_unattended(ticks) .map_err(PaidUnattendedError::Clock)?; self.wallets .get_mut(&actor_id) .ok_or(PaidUnattendedError::Wallet(WalletError::InsufficientFunds))? .checked_spend_copper(amount) .map_err(PaidUnattendedError::Wallet)?; self.advance_unattended_preflighted(ticks, final_clocks); Ok(()) }
fn advance_unattended_preflighted( &mut self, ticks: u64, (final_tick, final_day, final_day_tick): (u64, u64, u64), ) { for _ in 0..ticks { self.advance_unattended_tick_body(); } debug_assert_eq!(self.tick, final_tick); debug_assert_eq!(self.far.day, final_day); debug_assert_eq!(self.far.day_tick, final_day_tick); }
/// Advance exactly one unattended tick (docs/fluid §4.4). /// /// This is the single-tick primitive a caller chunks (the caller owns the /// `≤5`-per-iteration bound, [`crate::pacing::MAX_TICKS_PER_ITERATION`]). /// It advances ONLY the unattended whitelist: canonical NPC intent plus /// canonical NPC locomotion (desired moves drained through the real Move /// sweep — intent never writes positions), live-item and corpse clocks, and /// gossip. It never runs a normal hazard phase, so the player resting at the /// inn anchor is neither moved nor exposed. Preflight runs first, so an /// exhausted tick or FarField clock returns an error with no mutation. pub fn advance_unattended_tick(&mut self) -> Result<(), UnattendedAdvanceError> { self.preflight_unattended(1)?; self.advance_unattended_tick_body(); Ok(()) }
fn advance_unattended_tick_body(&mut self) { self.phase_intent(); self.apply_desired_moves(); // Unattended scope has no hazard phase; do not retain stale swept exposure. self.spill.crossed_cells.clear();
for (id, instance) in &mut self.items { let wet = self .ground_items .get(id) .is_some_and(|cell| self.grid.get_world(cell.0, cell.1, cell.2).fluid_amt > 0); item::rust_tick(instance, wet); if !wet { item::dry(instance); } } for pack in self.packs.values_mut() { for instance in pack.items.values_mut() { item::dry(instance); } }
if !self.corpses.is_empty() { let tick = self.tick; let mut rng = self.fork_rng(domains::GOSSIP); let _ = rot::step_rot( &mut self.grid, &mut self.corpses, &mut rng, &mut self.causes, tick, ); } self.drain_gossip(); self.tick += 1; self.far.advance(); }
fn run_phase(&mut self, phase: Phase) { match phase { Phase::Intent => self.phase_intent(), Phase::Hymns => self.phase_hymns(), Phase::Move => self.phase_move(), Phase::Fluids => self.phase_fluids(), Phase::Gas => self.phase_gas(), Phase::Fire => self.phase_fire(), Phase::Signal => self.phase_signal(), Phase::Light => self.phase_light(), Phase::Sound => self.phase_sound(), Phase::Structure => self.phase_structure(), Phase::Rot => self.phase_rot(), Phase::Death => self.phase_death(), } }
/// Intent phase (docs/03 §3.6, docs/04 §4.2): the sim runs without the /// player. Day phase from the FarField clock; per npc, stimulus SHOULD /// be pre-sorted by live Actor distance (ties stay deterministic in /// decide either way) — here the sounds queue drains loudest-first. /// WalkTo applies 1m steps (Move-path nudge shape); Work/Sleep are /// no-ops with NO practice hooks (NPCs don't grow); Flee/Investigate /// move at speed 2. fn phase_intent(&mut self) { use crate::npc::Intent; let tick = self.tick; let phase = day_phase(self.far.day_tick); // Stimulus: queued sound positions, loudest-first, pre-sorted by // live distance per npc below (decide tie-breaks deterministically). let stimulus: Vec<(i32, i32, i32)> = self.sounds.iter().map(|e| (e.x, e.y, e.z)).collect(); let ids: Vec<u64> = self.npcs.keys().copied().collect(); // Phase label for the log (dusk belongs to lamplighters). let _ = phase; for id in ids { let Some(npc) = self.npcs.get(&id) else { continue; }; // Pre-sort stimulus by live Actor distance (Manhattan from the // npc's actor pos; home-anchored fallback inside decide). let here = self .actors .iter() .find(|a| a.id == id) .map(|a| a.position.cell()) .unwrap_or([npc.home.0, npc.home.1, npc.home.2]); let mut sorted = stimulus.clone(); sorted.sort_by_key(|p| { (i64::from(p.0) - i64::from(here[0])).abs() + (i64::from(p.1) - i64::from(here[1])).abs() + (i64::from(p.2) - i64::from(here[2])).abs() }); let intent = crate::npc::decide(npc, tick, &sorted); match intent { Intent::WalkTo(t) | Intent::Investigate(t) | Intent::Flee(t) => { let speed = match intent { Intent::Flee(_) | Intent::Investigate(_) => 2000, _ => 1000, }; if let Some(a) = self.actors.iter().find(|a| a.id == id) { if let Ok(target) = crate::spatial::FixedVec3::from_cell_feet([t.0, t.1, t.2]) { let heading = crate::locomotion::heading_toward(a.position, target); self.spill.desired_moves.entry(id).or_insert(crate::locomotion::MovementSample { heading, speed_mm_s: speed, stop_at: Some(target) }); } } } Intent::Work | Intent::Sleep | Intent::Trade(_) => {} } } // Cantor trigger (docs/03 §3.8: the Choir sings your name after // floor 9 — diegetic difficulty warning, beloved). Any actor below // the Deep threshold (z < -90, the depth-9 line) hears it once per // run (law-gated). Depth-gate helper lives in worldgen::deep; // core must not dep worldgen (arch §4.1), so the z-threshold here // mirrors choir_territory(depth >= 9) — pinned by the EA gate test. // FIRST-SIGHT ONLY: the law registry gates the line; the per-tick // cause-link records every hearing for replays (docs/04 §4.10.5). let deep_enough = self.actors.iter().any(|a| a.position.millimetres()[2] < -90_000); if deep_enough { self.speak_law("sound travels"); if self.laws.first_seen("sound travels") == Some(tick) { self.causes.push(CauseLink::new( tick, "sound travels", "the Choir sings your name — turn back or descend forever", )); } } }
/// Hymns phase (docs/03 §3.5): drain the cast queue. For each intent: /// conduct roll (Aether + card + copper + weather) → deterministic /// resolve off the COMBAT stream → Cast applies the verb outcome, /// Miscast maps via MiscastKind (KernelPanic spawns a cherub hook). /// Casts practice Hymns (success AND failure teach — NetHack law). /// Tenday bargain debts tick here too (collections come due). fn phase_hymns(&mut self) { let tick = self.tick; let weather = self.deep_weather; let intents = std::mem::take(&mut self.cast_queue); for (caster, template, target) in intents { let Some(case) = self.hymn_cases.get(&caster) else { continue; }; let Some(card) = case.cards.iter().find(|c| c.template == template) else { continue; }; let card = card.clone(); let aether = self .characters .get(&caster) .map(|c| crate::character::attr(c, crate::character::Attribute::Aether)) .unwrap_or(8); // Copper near the caster conducts hymns (docs/03 §3.5). let copper_near = self.copper_near_caster(caster); let chance = hymn::conduct_chance( card.conduct_base, &hymn::ConductMods { aether, card_worn: false, copper_near, deep_weather: weather, }, ); let mut rng = self.fork_rng(domains::COMBAT); let roll = (rng.below(100)) as u8; match hymn::resolve(roll, chance, &card.miscasts) { hymn::CastOut::Cast => { self.apply_verb(caster, &template, target); self.speak_law("heat rises"); if let Some(c) = self.characters.get_mut(&caster) { record_use(c, Skill::Hymns); } } hymn::CastOut::Miscast(outcome) => { let kind = casting::miscast_kind(&outcome); self.causes.push(CauseLink::new( tick, "wild surge", format!("the {template} misfires ({outcome})"), )); if matches!(kind, casting::MiscastKind::KernelPanic) { // Everyone groans: cherub hook via signal sources. let pos = self .actors .iter() .find(|a| a.id == caster) .map(|a| a.position.cell()) .unwrap_or([0, 0, 8]); self.signal_sources.push((pos[0], pos[1], pos[2])); self.causes.push(CauseLink::new( tick, "shock grounds", "a kernel-panicked cherub audits the air — everyone groans", )); } if let Some(c) = self.characters.get_mut(&caster) { record_use(c, Skill::Hymns); } } } } // Tenday bargain debts come due (collections encounters parent-side; // the kernel only speaks the law line once per run). let due_count: usize = self .bargains .values() .map(|ledger| crate::bargain::due(ledger, tick).len()) .sum(); for _ in 0..due_count { self.speak_law("waste nothing"); } }
/// True when copper (wire, pipe, rail) sits near the caster. fn copper_near_caster(&self, caster: u64) -> bool { let Some(a) = self.actors.iter().find(|a| a.id == caster) else { return false; }; let [cx, cy, cz] = a.position.cell(); for dz in -2..=2 { for dy in -2..=2 { for dx in -2..=2 { let m = self.grid.get_world(cx + dx, cy + dy, cz + dz).material; if m == crate::voxel::material::COPPER || m == crate::voxel::material::BRASS { return true; } } } } false }
/// Map a cast verb outcome onto sim mutations (docs/03 §3.5). /// Templates key by hymn id (`fiat_lux`); the verb table resolves via /// the canonical id→verb map (mirrors hymns.toml; new hymns extend it). fn apply_verb(&mut self, caster: u64, template: &str, target: [i32; 3]) { let tick = self.tick; let Some(verb) = casting::parse_verb(&template_to_verb(template)) else { return; }; let cpos = self .actors .iter() .find(|a| a.id == caster) .map(|a| a.position) .unwrap_or_else(|| crate::spatial::FixedVec3::from_cell_feet([0, 0, 8]).expect("constant position")); let Ok(fixed_target) = crate::spatial::FixedVec3::from_cell_feet(target) else { return; }; let ctx = CastCtx { caster_pos: cpos, target: fixed_target, mass_kg: 80.0, water_present: false, ward_present: false, }; match verb { casting::Verb::FiatLux => { let out = casting::fiat_lux(&ctx); self.lights.push(crate::light::LightSource { x: out.at.0, y: out.at.1, z: out.at.2, radius: out.radius, warm: out.warm, }); } casting::Verb::Aperi => { let out = casting::aperi(&ctx); if out.loud { // The Audit hears: loud sound + law line. self.sounds.push(crate::sound::SoundEvent { x: target[0], y: target[1], z: target[2], loudness: 24, timbre: 0, }); self.speak_law("sound travels"); } } casting::Verb::Pondus => { // Falling solved or caused: seed the odometer hook (up = // feather, down = stone). Direction from template naming is // parent-side; default down (stone) is the safe reading. let out = casting::pondus(&ctx, false); let _ = out; } casting::Verb::Sile => { let out = casting::sile(&ctx); let _ = out; // Silence bubble: swallow pending sounds near target. self.sounds.clear(); } casting::Verb::Fluctus => { let out = casting::fluctus(&ctx); let _ = out; } casting::Verb::Memoria => { let out = casting::memoria(&ctx); if out.drops_flammables { // The ink costs: drop flammable-carrying state is // inventory parent-side (Phase 9 action). self.causes.push(CauseLink::new( tick, "heat rises", "the recall ink takes all flammables as its fee", )); } } casting::Verb::CaroVale => { let out = casting::caro_vale(&ctx); let _ = out; self.causes.push(CauseLink::new( tick, "waste nothing", "the corpse chooses its one honest answer", )); } } }
fn apply_desired_moves(&mut self) { use crate::{heading::MovementRemainder, locomotion_grid as policy}; let samples = std::mem::take(&mut self.spill.desired_moves); let starts: BTreeMap<_,_> = self.actors.iter().filter(|a| a.alive).filter_map(|a| policy::body_box(a.position,a.body).ok().map(|b| (a.id,b))).collect(); let grounded: BTreeMap<_,_> = self.actors.iter().map(|a| (a.id,policy::supported(&self.grid,a.position,a.body))).collect(); let mut accepted = starts.clone(); for _ in 0..crate::locomotion::SUBSTEPS_PER_100_MS { for (id,sample) in &samples { let Some(actor) = self.actors.iter_mut().find(|a| a.id == *id && a.alive) else { continue; }; if !sample.valid() { continue; } actor.heading = sample.heading; if matches!(actor.stance, crate::actor::Stance::Rope | crate::actor::Stance::Climb) { continue; } let Ok((xy,trial)) = actor.movement_remainder.project(sample.heading,sample.speed_mm_s) else { continue; }; let mut delta = [xy[0],xy[1],0]; let mut arrived=false; if let Some(target)=sample.stop_at { let a=actor.position.millimetres();let b=target.millimetres(); for axis in 0..2 { let remaining=b[axis]-a[axis]; delta[axis]=if remaining>=0 {delta[axis].clamp(0,remaining)} else {delta[axis].clamp(remaining,0)}; } arrived=(0..2).all(|axis|a[axis]+delta[axis]==b[axis]); } let occupied: Vec<_> = starts.iter().chain(accepted.iter()).filter(|(other,_)| **other != *id).map(|(_,b)| *b).collect(); match policy::substep(&self.grid,actor.position,actor.body,delta,&occupied,grounded[id]) { Ok((result,cells)) => { let before = actor.position.millimetres(); let after = result.position.millimetres(); let mut fractions = if arrived {[0;2]} else {trial.numerators()}; for axis in 0..2 { if after[axis]-before[axis] != delta[axis] { fractions[axis]=0; } } actor.movement_remainder = MovementRemainder::new(fractions).expect("trial remainder"); actor.position = result.position; if let Ok(b) = policy::body_box(actor.position,actor.body) { accepted.insert(*id,b); } self.spill.crossed_cells.entry(*id).or_default().extend(cells); } Err(_) => { actor.movement_remainder = MovementRemainder::ZERO; } } } } }
fn phase_move(&mut self) { self.apply_desired_moves(); // Rope anchors on connectors snap before falls (rope docs order). // Snapshot connector loads first: apply_load mutates causes. let tick = self.tick; let anchor_loads: Vec<(u64, f32, crate::connector::ConnectorKind)> = self .connectors .iter() .map(|c| (c.id, c.load_kg, c.kind)) .collect(); for (id, load_kg, kind) in anchor_loads { if kind == crate::connector::ConnectorKind::RopeAnchor && load_kg > 0.0 { let connector = self.connectors.iter().find(|c| c.id == id).unwrap(); let mut rope = crate::rope::from_connector(connector); crate::rope::apply_load(&mut rope, load_kg, &mut self.causes, tick); if rope.cut { self.speak_law("the rope gives way"); } } } let tick = self.tick; let masses = BTreeMap::new(); // Seed climbers' Ropework before the descent: airborne actors on // ropes practice the craft (Phase 6 easy call site; swim/sneak/etc. // hook in at their own phases — see wiring plan §2). let rope_users: Vec<u64> = self .actors .iter() .filter(|a| a.alive && a.stance == crate::actor::Stance::Rope) .map(|a| a.id) .collect(); let before_fall: BTreeMap<_,_> = self.actors.iter().map(|a|(a.id,a.position)).collect(); gravity::step_falling_with_mass( &mut self.actors, &self.grid, &masses, &mut self.fall_odo, &mut self.causes, tick, ); for actor in &self.actors { let from=before_fall[&actor.id]; if from!=actor.position { if let Ok(cells)=crate::locomotion_grid::vertical_cells(from,actor.position,actor.body) { self.spill.crossed_cells.entry(actor.id).or_default().extend(cells); } } } for id in rope_users { if let Some(c) = self.characters.get_mut(&id) { record_use(c, Skill::Ropework); } } // Falls survived are Dungeoneering lessons: actors that descended // this tick (odometer non-empty) read shafts better next time. let descended: Vec<u64> = self.fall_odo.keys().copied().collect(); for id in descended { if let Some(c) = self.characters.get_mut(&id) { record_use(c, Skill::Dungeoneering); } } }
fn phase_fluids(&mut self) { let (rmin, rmax) = self.active_region(); let tick = self.tick; let mut rng = self.fork_rng(domains::FLUIDS); let (_updated, _spill) = fluids::step_fluids(&mut self.grid, rmin, rmax, &mut rng, &mut self.causes, tick); // Fluids reports (updated, spillover); the spillover tail re-runs // implicitly next tick via the region scan (no explicit queue: the // region is re-derived every tick, so deferred cells are revisited). let _ = _spill; let _ = _updated; }
fn phase_gas(&mut self) { // Build flow triples from edges + drafts + cell→volume map. The Gas // module works on packed cell pairs; the pressure graph works on node // ids — the join below maps node ids to representative cells. Full // volume-membership mapping lands with worldgen anchor rooms; until // then each edge maps to the cells adjacent to its endpoint nodes' // anchor positions (nodes carry no positions, so the parent keeps a // parallel anchor table — empty here means still-air buoyancy only). let tick = self.tick; let drafts = self.drafts(); let mut flows: Vec<(u64, u64, f32)> = self .pressure_edges .iter() .zip(drafts.iter()) .filter(|(e, _)| !e.hidden) .filter_map(|(e, &mag)| { let a = self.node_anchor(e.from)?; let b = self.node_anchor(e.to)?; Some((pack_cell(a.0, a.1, a.2), pack_cell(b.0, b.1, b.2), mag)) }) .collect(); flows.append(&mut self.spill.gas_flows); let (_moved, spillover) = air::advect_markers_capped(&mut self.grid, &flows, tick, &mut self.causes); if spillover > 0 { let keep = flows.len().saturating_sub(spillover); self.spill.gas_flows = flows[keep..].to_vec(); } }
/// Anchor cell for a pressure node id. Nodes carry no positions (sound /// module contract); the parent keeps this parallel table. Empty until /// worldgen anchor rooms register volumes (Phase 10) — until then the /// Gas phase runs still-air buoyancy + re-queued spillover only. fn node_anchor(&self, _id: u64) -> Option<(i32, i32, i32)> { None }
fn phase_fire(&mut self) { let crossed = std::mem::take(&mut self.spill.crossed_cells); for actor in self.actors.iter_mut().filter(|a| a.alive) { let [x,y,z] = actor.position.cell(); let burning = |cell: &(i32,i32,i32)| self.grid.get_world(cell.0,cell.1,cell.2).flags & crate::voxel::flags::BURNING != 0; if burning(&(x,y,z)) || crossed.get(&actor.id).is_some_and(|cells| cells.iter().any(burning)) { actor.hp = actor.hp.saturating_sub(1); self.causes.push(CauseLink::new(self.tick,"fire",format!("actor {} crosses flame (1 damage)",actor.id))); } } let (rmin, rmax) = self.active_region(); let tick = self.tick; let mut rng = self.fork_rng(domains::COMBAT); let (_updated, _spill) = fire::step_fire(&mut self.grid, rmin, rmax, &mut rng, &mut self.causes, tick); let _ = (_updated, _spill); // Fragile hymn-cards cook off in heat (docs/03 §3.3 law 5): any // carrier case holding a fragile card near burning cells auto-fires // it at the carrier's feet. Carrier heat = carrier cell BURNING. let hot_carriers: Vec<u64> = self .actors .iter() .filter(|a| { let [x, y, z] = a.position.cell(); let v = self.grid.get_world(x, y, z); v.flags & crate::voxel::flags::BURNING != 0 }) .map(|a| a.id) .collect(); for caster in hot_carriers { let fragile: Vec<String> = self .hymn_cases .get(&caster) .map(|case| { case.cards .iter() .filter(|c| hymn::cooks_off(c, true)) .map(|c| c.template.clone()) .collect() }) .unwrap_or_default(); for template in fragile { let cell = self .actors .iter() .find(|a| a.id == caster) .map(|a| a.position.cell()) .unwrap_or([0, 0, 8]); self.cast_queue.push((caster, template, cell)); } } }
fn phase_signal(&mut self) { let (rmin, rmax) = self.active_region(); let tick = self.tick; let sources = std::mem::take(&mut self.signal_sources); let (_energized, spillover) = signal::step_signal(&self.grid, &sources, rmin, rmax, &mut self.causes, tick); self.signal_sources = sources; self.spill.signal_pending = spillover; }
/// Docs/04 §4.2 phase 8 `Light`: bake the 0–24 voxel field from the live /// source list (docs/03 §3.3.1). /// /// The `light` module's wiring contract is a dirty-only pass each tick plus a /// full pass after the world changes. The source list is owned by /// [`State::lights`] and the outer loop moves a carried lamp by overwriting /// it in place, so this phase detects its own structural change: when the live /// source set differs from the last baked set — a lamp appeared, moved, or was /// doused — it clears the departed lamps' *old* boxes (no stale illumination /// survives a doused or carried-away lamp, docs/03 §3.3.1) and runs a full /// pass. A deferred pass left over from an over-budget tick forces the same /// full re-bake, so deferred light is actually written rather than silently /// dropped (docs/04 §4.2). /// /// Without this, no cell is ever marked [`light::FLAG_LIGHT_DIRTY`] and the /// dirty-only pass has an empty dirty set, so the authoritative voxel field /// stays dark while sources exist — and the canonical observation pass, which /// requires positive light in the air cell off a hit face, records nothing. fn phase_light(&mut self) { let tick = self.tick; // A baked field always reads full brightness at a LOADED source's own // cell (the flood seeds the source unconditionally), so a source that // does not means the field is stale — the source arrived before its // chunk was loaded, or another phase clobbered the cell. Treat that as // a re-bake trigger so this phase is self-healing regardless of the // outer loop's light-registration and chunk-streaming order. let edge = crate::voxel::CHUNK as i32; let stale_source = self.lights.iter().any(|s| { self.grid .is_loaded(s.x.div_euclid(edge), s.y.div_euclid(edge), s.z.div_euclid(edge)) && self.grid.get_world(s.x, s.y, s.z).light != light::MAX_LIGHT }); let rebake = self.spill.light_pending || self.lights != self.light_baked || stale_source; if rebake { for old in &self.light_baked { if !self.lights.contains(old) { let r = old.radius.min(light::MAX_LIGHT_RADIUS) as i32; light::clear_light_box(&mut self.grid, old.x, old.y, old.z, r); } } } let (_updated, spillover) = light::propagate_light_capped(&mut self.grid, &self.lights, !rebake); self.spill.light_pending = spillover > 0; self.light_baked = self.lights.clone(); // First-sight law line for entering lit strangeness is renderer-side; // the kernel records the tick so replays keep it. let _ = tick; }
fn phase_sound(&mut self) { // Drain the queue; nodes[0] is the source room by contract (nodes // carry no positions — the parent orders them source-first). let events = std::mem::take(&mut self.sounds); for ev in &events { let heard = sound::propagate_sound(&self.grid, &self.pressure_edges, &self.pressure_nodes, ev); // AI investigate = argmax excluding the source room (source is // tied-for-loudest by construction; see sound docs MINOR-5). let _ = heard; } }
fn phase_structure(&mut self) { // Tight dirty bounds: callers pass the active region but the module // clamps to its 64-span bound internally (deterministic truncation). let (rmin, rmax) = self.active_region(); let tick = self.tick; structure::step_structure(&mut self.grid, rmin, rmax, &mut self.causes, tick); }
fn phase_rot(&mut self) { let tick = self.tick; let mut rng = self.fork_rng(domains::GOSSIP); let _spawned = rot::step_rot( &mut self.grid, &mut self.corpses, &mut rng, &mut self.causes, tick, ); // Burst positions feed the content-owned corpse-spawn table (Phase // 9/10); until then they resolve as stain + gas + link only. let (rmin, rmax) = self.active_region(); let (_changed, spillover) = scent::step_scent(&mut self.grid, rmin, rmax, tick); self.spill.scent_pending = spillover; self.drain_gossip(); }
fn drain_gossip(&mut self) { // Gossip drain (docs/02 §4: 3-day latency): arrived packets move // town ledgers (grudges travel, faces blur). let day = self.far.day; let mut arrived: Vec<GossipPacket> = Vec::new(); self.gossip.retain(|packet| { if crate::schedule::gossip_arrived(packet.sent_day, day) { arrived.push(packet.clone()); false } else { true } }); for packet in arrived { for reputation in self.reputations.values_mut() { reputation.adjust_ledger(&packet.to, packet.ledger_delta); } self.causes.push(CauseLink::new( self.tick, "sound travels", format!( "word travels {}→{}: the ledger remembers", packet.from, packet.to ), )); } }
fn phase_death(&mut self) { // Snapshot the newly dead first in authoritative actor order. let fresh_deaths: Vec<u64> = self .actors .iter() .filter(|actor| actor.alive && actor.hp <= 0) .map(|actor| actor.id) .collect(); let prepared = self .prepare_deaths(&fresh_deaths) .expect("validated phase-death allocation budget exhausted"); self.causes .try_reserve(prepared.deaths.len()) .expect("validated phase-death cause capacity exhausted"); self.commit_deaths(prepared, true); }
fn prepare_deaths(&self, actor_ids: &[u64]) -> Result<PreparedDeathBatch, MeleeError> { let death_count = u64::try_from(actor_ids.len()).map_err(|_| MeleeError::IdExhausted)?; let next_id = self .next_id .checked_add(death_count) .ok_or(MeleeError::IdExhausted)?; if actor_ids.is_empty() { return Ok(PreparedDeathBatch { deaths: Vec::new(), next_id, }); } let mut deaths = Vec::new(); deaths .try_reserve(actor_ids.len()) .map_err(|_| MeleeError::AllocationFailed)?; for (offset, actor_id) in actor_ids.iter().copied().enumerate() { let actor_index = self .actors .iter() .position(|actor| actor.id == actor_id) .expect("death actor selected from authority"); let actor = &self.actors[actor_index]; let corpse_id = self .next_id .checked_add(u64::try_from(offset).map_err(|_| MeleeError::IdExhausted)?) .ok_or(MeleeError::IdExhausted)?; let mut contracts_to_fail = Vec::new(); let contract_count = self .contracts .values() .filter(|contract| { contract.taker == actor_id && matches!( contract.status, ContractStatus::Active | ContractStatus::Completed ) }) .count(); contracts_to_fail .try_reserve(contract_count) .map_err(|_| MeleeError::AllocationFailed)?; contracts_to_fail.extend( self.contracts .iter() .filter(|(_, contract)| { contract.taker == actor_id && matches!( contract.status, ContractStatus::Active | ContractStatus::Completed ) }) .map(|(id, _)| id.clone()), ); deaths.push(PreparedDeath { actor_id, actor_index, position: actor.position, corpse: Instance::corpse(corpse_id, [0.0; 3]), contracts_to_fail, death_detail: format!("actor {actor_id} fell still"), }); } Ok(PreparedDeathBatch { deaths, next_id }) }
fn commit_deaths(&mut self, prepared: PreparedDeathBatch, broad_chapel_cleanup: bool) { let tick = self.tick; for death in &prepared.deaths { debug_assert_eq!(self.actors[death.actor_index].id, death.actor_id); self.actors[death.actor_index].alive = false; self.causes .push(CauseLink::new(tick, "death", death.death_detail.clone())); } if broad_chapel_cleanup { let actors = &self.actors; self.chapel_debts.retain(|actor_id, _| { actors .iter() .any(|actor| actor.id == *actor_id && actor.alive) }); } else { for death in &prepared.deaths { self.chapel_debts.remove(&death.actor_id); } } for death in &prepared.deaths { for contract_id in &death.contracts_to_fail { if let Some(contract) = self.contracts.get_mut(contract_id) { contract.status = ContractStatus::Failed; } else { debug_assert!(false, "death contract disappeared after preflight"); } } } self.next_id = prepared.next_id; for death in prepared.deaths { let corpse_id = death.corpse.id; self.ground_items.insert( corpse_id, { let [x, y, z] = death.position.cell(); (x, y, z) }, ); self.items.insert(corpse_id, death.corpse); for character in self.characters.values_mut() { record_use(character, Skill::Search); } if let Some(ledger) = self.bargains.remove(&death.actor_id) { let reputation = self.reputations.entry(death.actor_id).or_default(); for debt in ledger.debts { if debt.altar_receipt || debt.boon == crate::bargain::COLLECTIONS_BOON { reputation.adjust_ledger("milthollow", -10); if let Some((_, standing)) = reputation .factions .iter_mut() .find(|(faction, _)| *faction == crate::reputation::Faction::Audit) { standing.debt_cu = standing.debt_cu.saturating_add(50); } } } } } }
/// Produce an exact owned plain-data image. Keeping this explicit literal /// next to `State` construction makes field additions compile-time visible. pub fn to_image(&self) -> Result<StateImage, RuntimeInvariantError> { self.validate_runtime()?; Ok(StateImage { tick: self.tick, seed: self.seed, branch_epoch: self.branch_epoch, rng_master: self.rng_master.canonical_state(), ecs_entity_count: 0, chunks: self .grid .iter_loaded() .map(|(coord, chunk)| ChunkImage { coord: *coord, voxels: chunk.iter().copied().collect(), }) .collect(), actors: self.actors.clone(), actor_identities: self.actor_identities.clone(), wallets: self.wallets.clone(), contracts: self.contracts.clone(), run_location: self.run_location.clone(), connectors: self.connectors.clone(), pressure_nodes: self.pressure_nodes.clone(), pressure_edges: self.pressure_edges.clone(), causes: self .causes .links() .iter() .map(|link| CauseImage { tick: link.tick, rule: link.rule.to_owned(), detail: link.detail.clone(), }) .collect(), far: self.far.clone(), laws: self .laws .seen .iter() .map(|(rule, tick)| LawImage { rule: (*rule).to_owned(), first_seen_tick: *tick, }) .collect(), fall_odo: self.fall_odo.clone(), lights: self.lights.clone(), sounds: self.sounds.clone(), corpses: self.corpses.clone(), signal_sources: self.signal_sources.clone(), spill: self.spill.clone(), characters: self.characters.clone(), reputations: self.reputations.clone(), items: self.items.clone(), ground_items: self.ground_items.clone(), packs: self.packs.clone(), knowledge: self.knowledge.clone(), idf: self.idf.clone(), hymn_cases: self.hymn_cases.clone(), cast_queue: self.cast_queue.clone(), bargains: self.bargains.clone(), deep_weather: self.deep_weather, npcs: self.npcs.clone(), shops: self.shops.clone(), gossip: self.gossip.clone(), chalk: self.chalk.clone(), mule: self.mule.clone(), vault_choice: self.vault_choice, chapel_debts: self.chapel_debts.clone(), next_id: self.next_id, step_mode: self.step_mode, pending: self.pending.iter().cloned().collect(), observations: self.observations.rows(), }) }
/// Restore an exact image without gameplay transitions or per-voxel writes. pub fn from_image(image: StateImage) -> Result<Self, StateImageError> { let StateImage { tick, seed, branch_epoch, rng_master, ecs_entity_count, chunks, actors, actor_identities, wallets, contracts, run_location, connectors, pressure_nodes, pressure_edges, causes, far, laws: law_rows, fall_odo, lights, sounds, corpses, signal_sources, spill, characters, reputations, items, ground_items, packs, knowledge, idf, hymn_cases, cast_queue, bargains, deep_weather, npcs, shops, gossip, chalk, mule, vault_choice, chapel_debts, next_id, step_mode, pending: pending_rows, observations: observation_rows, } = image; if ecs_entity_count != 0 { return Err(StateImageError::NonEmptyEcs { entities: ecs_entity_count, }); } let rng_master = Rng::from_canonical_state(rng_master).map_err(|_| StateImageError::InvalidRng)?; let grid = ChunkedGrid::from_loaded( chunks .into_iter() .map(|row| (row.coord, row.voxels)) .collect(), ) .map_err(StateImageError::Grid)?;
let mut cause_chain = CauseChain::new(); for row in causes { let rule = laws::canonical_rule_tag(&row.rule) .ok_or(StateImageError::UnknownRule { rule: row.rule })?; cause_chain.push(CauseLink::new(row.tick, rule, row.detail)); } let mut law_log = LawLog::new(); let mut previous: Option<String> = None; for row in law_rows { if let Some(prior) = &previous { if row.rule <= *prior { if row.rule == *prior { return Err(StateImageError::DuplicateLaw { rule: row.rule }); } return Err(StateImageError::NonCanonicalLawOrder { previous: prior.clone(), current: row.rule, }); } } let rule = laws::canonical_rule_tag(&row.rule).ok_or_else(|| { StateImageError::UnknownRule { rule: row.rule.clone(), } })?; previous = Some(row.rule); law_log.seen.insert(rule, row.first_seen_tick); }
let pending = match pending_rows.len() { 0 => None, 1 => Some( pending_rows .into_iter() .next() .expect("length checked to be exactly one"), ), count => { return Err(StateImageError::Runtime(RuntimeInvariantError::Pending( PendingError::MultipleSlots { count }, ))) } };
let observations = ObservationMemory::from_rows(observation_rows, ObservationLimits::canonical()) .map_err(StateImageError::Observation)?;
let state = Self { tick, seed, branch_epoch, rng_master, ecs: Ecs::new(), grid, actors, actor_identities, wallets, contracts, run_location, connectors, pressure_nodes, pressure_edges, causes: cause_chain, far, laws: law_log, fall_odo, lights, sounds, corpses, signal_sources, spill, characters, reputations, items, ground_items, packs, knowledge, idf, hymn_cases, cast_queue, bargains, deep_weather, npcs, shops, gossip, chalk, mule, vault_choice, chapel_debts, next_id, step_mode, pending, observations, // Rebuild the non-canonical light-bake memo empty: the image carries // voxel light and the lamp list, not this cache, so the first light // phase after restore re-bakes the same field a live run holds. light_baked: Vec::new(), }; state.validate_runtime().map_err(StateImageError::Runtime)?; Ok(state) }
/// Versioned full canonical persistence/replay hash. This compatibility /// wrapper panics on invalid public legacy state; checkpoint/load callers /// that need a recoverable error use [`State::try_hash_state`]. #[must_use] pub fn hash_state(&self) -> u64 { self.try_hash_state() .expect("State runtime invariants must hold before canonical hashing") }
/// Fallible canonical hash over every future-affecting authoritative field. pub fn try_hash_state(&self) -> Result<u64, RuntimeInvariantError> { self.validate_runtime()?; let mut h = CanonicalHasher::new(); h.domain(0, 6); h.u64(self.tick); h.u64(self.seed); for word in self.rng_master.canonical_state() { h.u64(word); } h.domain(1, 1); // ECS is validated empty and encoded explicitly. h.u64(0);
h.domain(2, self.grid.loaded_count()); for (coord, chunk) in self.grid.iter_loaded() { h.cell(*coord); h.domain(3, chunk.iter().len()); for voxel in chunk.iter() { h.u64(u64::from(voxel.material)); h.u64(u64::from(voxel.occ)); h.u64(u64::from(voxel.fluid_kind)); h.u64(u64::from(voxel.fluid_amt)); h.u64(u64::from(voxel.gas_kind)); h.u64(u64::from(voxel.gas_amt)); h.u64(u64::from(voxel.light)); h.u64(u64::from(voxel.scent)); h.u64(u64::from(voxel.flags)); h.u64(u64::from(voxel.pad)); h.u64(voxel.pressure as u64); h.u64(voxel.flow as u64); h.u64(u64::from(voxel.extra)); } } h.domain(4, self.actors.len()); for actor in &self.actors { h.u64(actor.id); for value in actor.position.millimetres() { h.u64(value as u64); } for value in actor.body.millimetres() { h.u64(value as u64); } h.option_u64(actor.heading.map(|h| u64::from(h.bin()))); for n in actor.movement_remainder.numerators() { h.u64(n as u64); } h.u64(u64::from(actor.stance.canonical_tag())); h.u64(actor.hp as u64); h.bool(actor.alive); } h.domain(5, self.actor_identities.len()); for (id, identity) in &self.actor_identities { h.u64(*id); h.text(&identity.template_id); h.text(&identity.display_name); h.u64(u64::from(identity.role.canonical_tag())); } h.domain(6, self.wallets.len()); for (id, wallet) in &self.wallets { h.u64(*id); h.u64(u64::from(wallet.copper())); h.u64(u64::from(wallet.scrip())); } h.domain(7, self.contracts.len()); for (key, contract) in &self.contracts { h.text(key); h.text(&contract.id); h.u64(contract.taker); h.u64(contract.accepted_tick); h.u64(u64::from(contract.floor)); h.u64(u64::from(contract.pay_cu)); h.u64(u64::from(contract.objective_target)); h.u64(u64::from(contract.objective_progress)); h.u64(u64::from(contract.status.canonical_tag())); h.text(&contract.reward_town); h.u64(contract.standing_delta as u64); } h.domain(8, 1); h.u64(u64::from(self.run_location.canonical_tag())); match &self.run_location { RunLocation::Uninitialized => {} RunLocation::Town(town) => h.text(town), RunLocation::Deep(depth) => h.u64(u64::from(*depth)), } h.domain(9, self.connectors.len()); for connector in &self.connectors { h.u64(connector.id); h.u64(u64::from(connector.kind.canonical_tag())); for value in connector.from.into_iter().chain(connector.to) { h.u64(u64::from(value.to_bits())); } h.u64(u64::from(connector.length.to_bits())); h.u64(u64::from(connector.anchor_quality)); h.u64(u64::from(connector.load_kg.to_bits())); h.bool(connector.blocked); } h.domain(10, self.pressure_nodes.len()); for node in &self.pressure_nodes { h.u64(node.id); h.u64(u64::from(node.pressure.to_bits())); } h.domain(11, self.pressure_edges.len()); for edge in &self.pressure_edges { h.u64(edge.from); h.u64(edge.to); h.u64(u64::from(edge.openness.to_bits())); h.bool(edge.hidden); } h.domain(12, self.causes.len()); for link in self.causes.links() { h.u64(link.tick); h.text(link.rule); h.text(&link.detail); } h.domain(13, 2); h.u64(self.far.day); h.u64(self.far.day_tick); h.domain(14, self.laws.seen.len()); for (rule, tick) in &self.laws.seen { h.text(rule); h.u64(*tick); } h.domain(15, self.fall_odo.len()); for (id, distance) in &self.fall_odo { h.u64(*id); h.u64(*distance); } h.domain(16, self.lights.len()); for light in &self.lights { h.cell((light.x, light.y, light.z)); h.u64(u64::from(light.radius)); h.bool(light.warm); } h.domain(17, self.sounds.len()); for sound in &self.sounds { h.cell((sound.x, sound.y, sound.z)); h.u64(u64::from(sound.loudness)); h.u64(u64::from(sound.timbre)); } h.domain(18, self.corpses.len()); for (cell, clock) in &self.corpses { h.cell(*cell); h.u64(clock.age_ticks); h.u64(clock.bursts_at); } h.domain(19, self.signal_sources.len()); for cell in &self.signal_sources { h.cell(*cell); } h.domain(20, self.spill.gas_flows.len()); for (from, to, flow) in &self.spill.gas_flows { h.u64(*from); h.u64(*to); h.u64(u64::from(flow.to_bits())); } h.bool(self.spill.light_pending); h.bool(self.spill.signal_pending); h.bool(self.spill.scent_pending); h.u64(self.spill.desired_moves.len() as u64); for (id, sample) in &self.spill.desired_moves { h.u64(*id); h.option_u64(sample.heading.map(|v| u64::from(v.bin()))); h.u64(u64::from(sample.speed_mm_s)); h.bool(sample.stop_at.is_some()); if let Some(p)=sample.stop_at {for n in p.millimetres() {h.u64(n as u64);}} } h.u64(self.spill.crossed_cells.len() as u64); for (id,cells) in &self.spill.crossed_cells { h.u64(*id); h.u64(cells.len() as u64); for cell in cells { h.cell(*cell); } } h.domain(21, self.characters.len()); for (id, character) in &self.characters { h.u64(*id); h.u64(u64::from(character.ancestry.canonical_tag())); h.u64(u64::from(character.calling.canonical_tag())); for value in character.attrs { h.u64(u64::from(value)); } for value in character.skills { h.u64(u64::from(value)); } for value in character.uses { h.u64(u64::from(value)); } for value in character.pips { h.u64(u64::from(value)); } } h.domain(22, self.reputations.len()); for (id, reputation) in &self.reputations { h.u64(*id); for (faction, standing) in reputation.factions { h.u64(u64::from(faction.canonical_tag())); h.u64(standing.rank as u64); h.u64(standing.debt_cu as u64); } h.domain(23, reputation.town_ledger.len()); for (town, standing) in &reputation.town_ledger { h.text(town); h.u64(*standing as u64); } for (god, credit) in reputation.god_credit { h.u64(u64::from(god.canonical_tag())); h.u64(credit as u64); } } h.domain(24, self.items.len()); for (key, item) in &self.items { h.u64(*key); h.item(item); } h.domain(25, self.ground_items.len()); for (item_id, cell) in &self.ground_items { h.u64(*item_id); h.cell(*cell); } h.domain(26, self.packs.len()); for (actor_id, pack) in &self.packs { h.u64(*actor_id); h.option_u64(pack.equipped_weapon); h.option_u64(pack.equipped_armor); h.u64(u64::from(pack.max_weight_kg.to_bits())); h.u64(u64::from(pack.max_bulk)); h.domain(26, pack.items.len()); for (key, item) in &pack.items { h.u64(*key); h.item(item); } } h.domain(27, self.idf.len()); for (template, uses) in &self.idf { h.text(template); h.u64(u64::from(*uses)); } h.domain(28, self.hymn_cases.len()); for (id, case) in &self.hymn_cases { h.u64(*id); h.u64(u64::from(case.max_slots)); h.domain(29, case.cards.len()); for card in &case.cards { h.text(&card.template); h.u64(u64::from(card.conduct_base)); h.u64(u64::from(card.slots)); h.bool(card.fragile); h.domain(30, card.miscasts.len()); for miscast in &card.miscasts { h.text(miscast); } } } h.domain(31, self.cast_queue.len()); for (caster, template, target) in &self.cast_queue { h.u64(*caster); h.text(template); h.cell((target[0], target[1], target[2])); } h.domain(32, self.bargains.len()); for (id, ledger) in &self.bargains { h.u64(*id); h.domain(33, ledger.debts.len()); for debt in &ledger.debts { h.u64(u64::from(debt.god.canonical_tag())); h.text(&debt.boon); h.text(&debt.owed); h.option_u64(debt.ticks_left); h.bool(debt.altar_receipt); } } h.domain(34, 1); h.u64(self.deep_weather as u64); h.domain(35, self.npcs.len()); for (id, npc) in &self.npcs { h.u64(*id); h.text(&npc.name); h.u64(u64::from(npc.job.canonical_tag())); h.cell(npc.home); h.cell(npc.work); h.u64(npc.debt_cu as u64); } h.domain(36, self.shops.len()); for (cell, shop) in &self.shops { h.cell(*cell); h.domain(37, shop.broke.len()); for (id, count) in &shop.broke { h.u64(*id); h.u64(u64::from(*count)); } h.domain(38, shop.stole.len()); for (id, stole) in &shop.stole { h.u64(*id); h.bool(*stole); } h.domain(39, shop.killed.len()); for (id, killed) in &shop.killed { h.u64(*id); h.bool(*killed); } } h.domain(40, self.gossip.len()); for packet in &self.gossip { h.text(&packet.from); h.text(&packet.to); h.u64(packet.sent_day); h.u64(packet.ledger_delta as u64); } h.domain(41, self.chalk.marks.len()); for (cell, text) in &self.chalk.marks { h.cell(*cell); h.text(text); } h.domain(42, usize::from(self.mule.is_some())); if let Some(mule) = &self.mule { h.domain(43, mule.waypoints.len()); for cell in &mule.waypoints { h.cell(*cell); } } h.domain(44, 1); h.u64(u64::from( self.vault_choice.map_or(0, VaultChoice::canonical_tag), )); h.domain(45, 2); h.u64(self.next_id); h.bool(self.step_mode); h.domain(46, self.chapel_debts.len()); for (actor_id, debt) in &self.chapel_debts { h.u64(*actor_id); h.u64(u64::from(debt.burials_owed)); h.text(&debt.chapel); } // v4 append-only domain: one outer row per named category, then the // explicit category tag and its canonically ordered, length-framed IDs. h.domain(RUN_KNOWLEDGE_HASH_DOMAIN, 3); for (category_tag, ids) in [ (0, &self.knowledge.mapped_regions), (1, &self.knowledge.recipes), (2, &self.knowledge.translated_hymns), ] { h.u64(category_tag); h.u64(ids.len() as u64); for id in ids { h.text(id); } } // v5 append-only domain: the single pending-action slot. Presence is // framed explicitly so an empty slot and a present slot cannot collide. h.domain(PENDING_HASH_DOMAIN, usize::from(self.pending.is_some())); if let Some(pending) = &self.pending { h.u64(pending.start_tick); h.u64(pending.remaining_ticks); h.u64(u64::from(pending.action.tag() as u8)); match &pending.action { PendingAction::Move { direction } => { h.u64(u64::from(direction.canonical_tag())); } PendingAction::Wait => {} PendingAction::InspectBoard { town } => h.text(town), PendingAction::AcceptContract { id } => h.text(id), PendingAction::UseTransition { transition_id } => h.u64(*transition_id), PendingAction::ResolveAttack { target_actor } => h.u64(*target_actor), PendingAction::Pickup { item_id } | PendingAction::Drop { item_id } | PendingAction::Wield { item_id } | PendingAction::TinkerIdentify { item_id } => h.u64(*item_id), PendingAction::TurnIn { contract_id } => h.text(contract_id), PendingAction::UseService { service } => { h.u64(u64::from(service.canonical_tag())); } PendingAction::Inn(inn) => { h.u64(inn.total_ticks); h.u64(inn.remaining_ticks); } } } // v5 append-only domain: the canonical player terrain-observation // memory (docs/fluid §5). One outer row per location, then one row per // reached boundary face. Record contents AND observation ticks are both // hashed so a stale memory and a refreshed one cannot collide. h.domain(OBSERVATION_HASH_DOMAIN, self.observations.len()); for (location, map) in self.observations.iter() { h.u64(u64::from(location.kind)); h.text(&location.town); h.u64(u64::from(location.depth)); h.u64(map.len() as u64); for (key, record) in map { h.cell(key.cell); h.u64(u64::from(key.face)); h.u64(u64::from(record.material)); h.u64(u64::from(record.occupancy)); h.u64(u64::from(record.door_appearance)); h.u64(record.observed_tick); h.u64(record.branch_epoch); } } // v5 append-only domain: the durable Story branch epoch (docs/fluid §7). // One framing row so a rolled-back branch and its restored successor // cannot collide even when every other field agrees. h.domain(BRANCH_EPOCH_HASH_DOMAIN, 1); h.u64(self.branch_epoch); Ok(h.finish()) }
/// Cheap O(actors + small tables) debugging fingerprint. It is not a /// persistence/replay digest and intentionally excludes voxels and queues. #[must_use] pub fn diagnostic_hash(&self) -> u64 { let mut h = CanonicalHasher::new(); h.domain(0xff, self.actors.len()); h.u64(self.tick); h.u64(self.seed); for actor in &self.actors { h.u64(actor.id); h.u64(actor.hp as u64); h.bool(actor.alive); } h.finish() }}
#[cfg(test)]mod tests { use super::*;
#[test] fn known_template_raise_is_monotonic() { for initial in [None, Some(0), Some(1), Some(2), Some(3), Some(255)] { let mut state = State::new(42); state.idf.insert("unrelated".into(), 1); if let Some(count) = initial { state.idf.insert("hymn_card_blank".into(), count); } let before_hash = state.try_hash_state().unwrap(); let before_idf = state.idf.clone(); let before_rng = state.rng_master.canonical_state(); let before_tick = state.tick; let expected = initial.unwrap_or(0).max(2); assert_eq!( state.initialize_known_template("hymn_card_blank").unwrap(), expected ); assert_eq!( state.initialize_known_template("hymn_card_blank").unwrap(), expected ); assert_eq!(state.idf["unrelated"], 1); assert_eq!( state.known_templates().collect::<Vec<_>>(), ["hymn_card_blank"] ); assert_eq!(state.rng_master.canonical_state(), before_rng); assert_eq!(state.tick, before_tick); // Restore only IDF: the canonical hash must show no other mutation. state.idf = before_idf; assert_eq!(state.try_hash_state().unwrap(), before_hash); } }
#[test] fn same_seed_same_hash() { let mut a = State::new(1234); let mut b = State::new(1234); for _ in 0..100 { a.step(); b.step(); } assert_eq!(a.hash_state(), b.hash_state()); }
#[test] fn different_seeds_diverge() { let a = State::new(1); let b = State::new(2); assert_ne!(a.hash_state(), b.hash_state()); }
#[test] fn death_records_cause() { let mut s = State::new(7); let id = s.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([0, 0, 0]).unwrap()); s.actors.iter_mut().find(|a| a.id == id).unwrap().hp = 0; s.step(); assert!(!s.causes.is_empty()); }
fn melee_pair(attacker_hp: i32, defender_hp: i32) -> (State, u64, u64) { let mut state = State::new(77); let attacker = state.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([1, 2, 3]).unwrap(), ActorIdentity::new("delver", "Ada", crate::runtime::ActorRole::Player), ); let defender = state.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([2, 2, 3]).unwrap(), ActorIdentity::new("rat", "Cable-rat", crate::runtime::ActorRole::Monster), ); state.actors[0].hp = attacker_hp; state.actors[1].hp = defender_hp; (state, attacker, defender) }
#[test] fn melee_optional_retaliation_is_explicit_and_atomic() { for counter in [None, Some(3)] { for defender_hp in [3, 6] { let (mut state, attacker, defender) = melee_pair(10, defender_hp); let causes = state.causes.len(); let result = state .resolve_melee_exchange_with_retaliation(attacker, defender, 3, counter, None) .unwrap(); let expected = if defender_hp == 3 { None } else { counter }; assert_eq!(result.retaliation_damage, expected); assert_eq!(result.defender_killed, defender_hp == 3); assert_eq!(state.actors[0].hp, 10 - expected.unwrap_or(0)); assert_eq!(state.actors[1].hp, defender_hp - 3); assert_eq!(state.tick, 0); assert_eq!( state.causes.len() - causes, 1 + usize::from(expected.is_some()) + usize::from(result.defender_killed) ); assert_eq!( state .causes .links() .iter() .filter(|cause| cause.detail.contains("retaliates")) .count(), usize::from(expected.is_some()) ); } } for counter in [Some(0), Some(-1)] { for defender_hp in [1, 6] { let (mut state, attacker, defender) = melee_pair(10, defender_hp); let hash = state.try_hash_state().unwrap(); let causes = state.causes.clone(); assert_eq!( state.resolve_melee_exchange_with_retaliation( attacker, defender, 3, counter, None ), Err(MeleeError::NonPositiveDamage { strike: MeleeStrike::Retaliation }) ); assert_eq!(state.try_hash_state().unwrap(), hash); assert_eq!(state.causes.links(), causes.links()); } } for counter in [None, Some(3)] { let (mut state, attacker, defender) = melee_pair(10, 1); let hash = state.try_hash_state().unwrap(); let causes = state.causes.clone(); assert_eq!( state.resolve_melee_exchange_with_retaliation( attacker, defender, 3, counter, Some("missing") ), Err(MeleeError::InvalidObjective(ContractError::NotFound)) ); assert_eq!(state.try_hash_state().unwrap(), hash); assert_eq!(state.causes.links(), causes.links()); let mut image = state.to_image().unwrap(); image.next_id = u64::MAX - 1; let mut exhausted = State::from_image(image).unwrap(); let hash = exhausted.try_hash_state().unwrap(); assert_eq!( exhausted .resolve_melee_exchange_with_retaliation(attacker, defender, 3, counter, None), Err(MeleeError::IdExhausted) ); assert_eq!(exhausted.try_hash_state().unwrap(), hash); assert_eq!(exhausted.causes.links(), causes.links()); assert_eq!(exhausted.next_id, u64::MAX - 1); assert_eq!(exhausted.actors[1].hp, 1); assert!(exhausted.items.is_empty()); } }
#[test] fn melee_survivor_retaliates_once_and_killing_blow_does_not() { let (mut state, attacker, defender) = melee_pair(10, 6); let first = state .resolve_melee_exchange(attacker, defender, 3, 3, None) .unwrap(); assert_eq!(first.outgoing_damage, 3); assert_eq!(first.retaliation_damage, Some(3)); assert!(!first.defender_killed); assert!(!first.attacker_killed); assert_eq!(state.actors[0].hp, 7); assert_eq!(state.actors[1].hp, 3); let second = state .resolve_melee_exchange(attacker, defender, 3, 3, None) .unwrap(); assert_eq!(second.retaliation_damage, None); assert!(second.defender_killed); assert_eq!(state.actors[0].hp, 7); assert_eq!(state.tick, 0); assert_eq!( state .causes .links() .iter() .filter(|link| link.detail.contains("retaliates")) .count(), 1 );
let (mut lethal, attacker, defender) = melee_pair(10, 30); let outcomes: Vec<MeleeExchange> = (0..4) .map(|_| { lethal .resolve_melee_exchange(attacker, defender, 3, 3, None) .unwrap() }) .collect(); assert!(outcomes.last().unwrap().attacker_killed); assert!(!outcomes.last().unwrap().defender_killed); assert_eq!(lethal.actors[0].hp, -2); assert_eq!(lethal.actors[1].hp, 18); assert_eq!(lethal.items.len(), 1); assert_eq!(lethal.ground_items.values().next(), Some(&(1, 2, 3))); assert_eq!(lethal.tick, 0);
let (mut saturated, attacker, defender) = melee_pair(1, 1); saturated .resolve_melee_exchange(attacker, defender, i32::MAX, 1, None) .unwrap(); assert_eq!(saturated.actors[1].hp, i32::MIN + 2); }
#[test] fn melee_dead_invalid_blocked_or_exhausted_rejects_atomically() { let errors = [ (1, 1, 3, 3, None, MeleeError::SameActor), (99, 2, 3, 3, None, MeleeError::UnknownAttacker), (1, 99, 3, 3, None, MeleeError::UnknownDefender), ( 1, 2, 0, 3, None, MeleeError::NonPositiveDamage { strike: MeleeStrike::Outgoing, }, ), ( 1, 2, 3, i32::MIN, None, MeleeError::NonPositiveDamage { strike: MeleeStrike::Retaliation, }, ), ( 1, 2, 3, 3, Some("missing"), MeleeError::ObjectiveRequiresDefenderDeath, ), ]; for (attacker, defender, damage, retaliation, objective, expected) in errors { let (mut state, _, _) = melee_pair(10, 10); let before = state.to_image().unwrap(); let before_hash = state.try_hash_state().unwrap(); assert_eq!( state.resolve_melee_exchange(attacker, defender, damage, retaliation, objective,), Err(expected) ); assert_eq!(state.try_hash_state().unwrap(), before_hash); let after = state.to_image().unwrap(); assert_eq!(after.tick, before.tick); assert_eq!(after.actors.len(), before.actors.len()); assert!(after .actors .iter() .zip(before.actors.iter()) .all(|(a, b)| a.id == b.id && a.hp == b.hp && a.alive == b.alive)); assert_eq!(after.causes, before.causes); assert_eq!(after.items, before.items); assert_eq!(after.ground_items, before.ground_items); assert_eq!(after.chapel_debts, before.chapel_debts); assert_eq!(after.bargains, before.bargains); assert_eq!(after.contracts, before.contracts); assert_eq!(after.next_id, before.next_id); }
let (mut dead, attacker, defender) = melee_pair(10, 10); dead.actors[0].alive = false; assert_eq!( dead.resolve_melee_exchange(attacker, defender, 3, 3, None), Err(MeleeError::DeadAttacker) ); dead.actors[0].alive = true; dead.actors[0].hp = 0; assert_eq!( dead.resolve_melee_exchange(attacker, defender, 3, 3, None), Err(MeleeError::DeadAttacker) ); dead.actors[0].hp = 10; dead.actors[1].alive = false; assert_eq!( dead.resolve_melee_exchange(attacker, defender, 3, 3, None), Err(MeleeError::DeadDefender) ); dead.actors[1].alive = true; dead.actors[1].hp = 0; assert_eq!( dead.resolve_melee_exchange(attacker, defender, 3, 3, None), Err(MeleeError::DeadDefender) );
let (mut objective, attacker, defender) = melee_pair(10, 1); objective.initialize_town("milthollow").unwrap(); objective .accept_contract("rat_cull", attacker, 1, 1, 1, "milthollow", 0) .unwrap(); let before_hash = objective.try_hash_state().unwrap(); assert_eq!( objective.resolve_melee_exchange(attacker, defender, 3, 3, Some("missing")), Err(MeleeError::InvalidObjective(ContractError::NotFound)) ); assert_eq!(objective.try_hash_state().unwrap(), before_hash); assert_eq!(objective.actors[1].hp, 1); assert_eq!( objective.resolve_melee_exchange(attacker, defender, 3, 3, Some("rat_cull")), Err(MeleeError::InvalidObjective(ContractError::WrongLocation)) ); objective.transition_location(RunLocation::Deep(1)).unwrap(); objective.fail_contract("rat_cull").unwrap(); assert_eq!( objective.resolve_melee_exchange(attacker, defender, 3, 3, Some("rat_cull")), Err(MeleeError::InvalidObjective(ContractError::NotActive)) );
let (exhausted, attacker, defender) = melee_pair(10, 1); let mut exhausted_image = exhausted.to_image().unwrap(); exhausted_image.next_id = u64::MAX - 1; let mut exhausted = State::from_image(exhausted_image).unwrap(); let before = exhausted.to_image().unwrap(); assert_eq!( exhausted.resolve_melee_exchange(attacker, defender, i32::MAX, 3, None), Err(MeleeError::IdExhausted) ); assert_eq!(exhausted.to_image().unwrap().next_id, before.next_id); assert_eq!(exhausted.actors[1].hp, 1); assert!(exhausted.items.is_empty()); }
#[test] fn melee_combined_pending_id_boundary_is_atomic_and_not_over_rejected() { fn boundary_state(next_id: u64) -> (State, u64, u64, u64) { let (mut state, attacker, defender) = melee_pair(10, 1); let pending = state.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([8, 8, 8]).unwrap()); state.actors[2].hp = 0; let mut image = state.to_image().unwrap(); image.next_id = next_id; ( State::from_image(image).unwrap(), attacker, defender, pending, ) }
let (mut exhausted, attacker, defender, pending) = boundary_state(u64::MAX - 2); let before = exhausted.to_image().unwrap(); let before_hash = exhausted.try_hash_state().unwrap(); assert_eq!( exhausted.resolve_melee_exchange(attacker, defender, 3, 3, None), Err(MeleeError::IdExhausted) ); assert_eq!(exhausted.try_hash_state().unwrap(), before_hash); let after = exhausted.to_image().unwrap(); assert_eq!(after.tick, before.tick); assert_eq!(after.actors.len(), before.actors.len()); assert!(after .actors .iter() .zip(before.actors.iter()) .all(|(a, b)| a.id == b.id && a.hp == b.hp && a.alive == b.alive)); assert_eq!(after.causes, before.causes); assert_eq!(after.items, before.items); assert_eq!(after.ground_items, before.ground_items); assert_eq!(after.chapel_debts, before.chapel_debts); assert_eq!(after.bargains, before.bargains); assert_eq!(after.contracts, before.contracts); assert_eq!(after.next_id, before.next_id); assert!( after .actors .iter() .find(|actor| actor.id == pending) .unwrap() .alive );
let (mut valid, attacker, defender, pending) = boundary_state(u64::MAX - 3); let result = valid .resolve_melee_exchange(attacker, defender, 3, 3, None) .unwrap(); assert!(result.defender_killed); assert_eq!(valid.next_id, u64::MAX - 2); assert!( valid .actors .iter() .find(|actor| actor.id == pending) .unwrap() .alive ); assert_eq!(valid.validate_runtime(), Ok(())); assert!(valid.try_hash_state().is_ok()); }
#[test] fn empty_prepared_deaths_preserve_idf_and_keep_broad_chapel_cleanup() { let (mut state, attacker, _) = melee_pair(10, 10); state.idf.insert("mature_lore".into(), 2); state.assign_chapel_debt(attacker).unwrap(); state.actors[0].alive = false;
let prepared = state.prepare_deaths(&[]).unwrap(); assert!(prepared.deaths.is_empty()); assert_eq!(prepared.next_id, state.next_id); state.commit_deaths(prepared, true);
assert_eq!(state.idf.get("mature_lore"), Some(&2)); assert_eq!(state.chapel_debt(attacker), None); assert!(state.causes.is_empty()); }
#[test] fn melee_death_cleans_debt_contracts_and_mints_one_corpse() { use crate::bargain::{Debt, Ledger}; use crate::item::Condition; use crate::reputation::{Faction, GodId, Reputation};
let (mut state, attacker, defender) = melee_pair(10, 3); state.initialize_town("milthollow").unwrap(); state .accept_contract("active", defender, 1, 1, 2, "milthollow", 0) .unwrap(); state .accept_contract("completed", defender, 1, 1, 1, "milthollow", 0) .unwrap(); state.transition_location(RunLocation::Deep(1)).unwrap(); state .advance_contract_objective("completed", defender, 1) .unwrap(); state .transition_location(RunLocation::Town("milthollow".into())) .unwrap(); state .accept_contract("terminal", defender, 1, 1, 1, "milthollow", 0) .unwrap(); state.fail_contract("terminal").unwrap(); state.assign_chapel_debt(defender).unwrap(); state.reputations.insert(defender, Reputation::new()); state.bargains.insert( defender, Ledger { debts: vec![Debt { god: GodId::Audit, boon: "receipt".into(), owed: "pay".into(), ticks_left: None, altar_receipt: true, }], }, ); state.idf.insert("known_relic".into(), 2); let corpse_id = state.next_id; let outcome = state .resolve_melee_exchange(attacker, defender, i32::MAX, i32::MAX, None) .unwrap(); assert!(outcome.defender_killed); assert_eq!(outcome.retaliation_damage, None); assert!(!state.actors[1].alive); assert_eq!( state.contract("active").unwrap().status, ContractStatus::Failed ); assert_eq!( state.contract("completed").unwrap().status, ContractStatus::Failed ); assert_eq!( state.contract("terminal").unwrap().status, ContractStatus::Failed ); assert_eq!(state.chapel_debt(defender), None); assert!(!state.bargains.contains_key(&defender)); assert_eq!(state.reputations[&defender].ledger("milthollow"), -10); assert_eq!( state.reputations[&defender] .factions .iter() .find(|(faction, _)| *faction == Faction::Audit) .unwrap() .1 .debt_cu, 50 ); let corpse = &state.items[&corpse_id]; assert_eq!(corpse.template, "corpse"); assert_eq!(corpse.material, crate::voxel::material::FLESH); assert!(corpse.identified); assert_eq!(corpse.condition, Condition::Sound); assert_eq!(corpse.tags, vec!["corpse", "perishable"]); assert_eq!(state.ground_items[&corpse_id], (2, 2, 3)); assert_eq!(state.next_id, corpse_id + 1); assert_eq!(state.idf.get("known_relic"), Some(&2)); assert_eq!(state.validate_runtime(), Ok(())); assert!(state.try_hash_state().is_ok()); state.step(); assert_eq!(state.items.len(), 1, "finalized death is not reminted"); }
#[test] fn melee_cause_image_roundtrip() { let (mut state, attacker, defender) = melee_pair(10, 6); state .resolve_melee_exchange(attacker, defender, 3, 3, None) .unwrap(); let image = state.to_image().unwrap(); assert_eq!(image.causes[0].rule, "melee"); assert!(image.causes[0].detail.contains("actor 1 strikes actor 2")); assert!(image.causes[1].detail.contains("retaliates")); let restored = State::from_image(image).unwrap(); assert_eq!(restored.try_hash_state(), state.try_hash_state()); assert_eq!(restored.causes.links()[0].rule, "melee"); }
#[test] fn melee_finalizes_only_exchange_death_and_credits_objective_once() { let (mut state, attacker, defender) = melee_pair(10, 1); let pending = state.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([8, 8, 8]).unwrap()); state.initialize_town("milthollow").unwrap(); state .accept_contract("rat_cull", attacker, 1, 1, 1, "milthollow", 0) .unwrap(); state.transition_location(RunLocation::Deep(1)).unwrap(); state.actors[2].hp = 0; let result = state .resolve_melee_exchange(attacker, defender, i32::MAX, 3, Some("rat_cull")) .unwrap(); assert_eq!(result.objective_progress, Some((1, 1))); assert_eq!( state.contract("rat_cull").unwrap().status, ContractStatus::Completed ); assert!( state .actors .iter() .find(|actor| actor.id == pending) .unwrap() .alive ); assert_eq!(state.items.len(), 1); state.step(); assert_eq!(state.items.len(), 2); assert!( !state .actors .iter() .find(|actor| actor.id == pending) .unwrap() .alive ); }
#[test] fn step_mode_advances_single_tick() { let mut s = State::new(9); s.step_mode = true; s.step(); assert_eq!(s.tick, 1); s.step(); assert_eq!(s.tick, 2); }
#[test] fn activate_loads_27_chunks() { let mut s = State::new(11); s.activate_around(0, 0, 0); assert_eq!(s.grid.loaded_count(), 27); }
#[test] fn unattended_advance_matches_empty_world_clock_scope() { let mut stepped = State::new(7); let mut unattended = State::new(7); stepped.far.day_tick = TICKS_PER_DAY - 2; unattended.far.day_tick = TICKS_PER_DAY - 2; for _ in 0..2 { stepped.step(); } unattended.advance_unattended(2).unwrap(); assert_eq!(unattended.tick, stepped.tick); assert_eq!(unattended.far.day, stepped.far.day); assert_eq!(unattended.far.day_tick, stepped.far.day_tick); assert_eq!(unattended.validate_runtime(), Ok(())); }
#[test] fn unattended_advance_overflow_is_atomic() { let mut tick_overflow = State::new(7); tick_overflow.tick = u64::MAX; let before = tick_overflow.diagnostic_hash(); assert_eq!( tick_overflow.advance_unattended(1), Err(UnattendedAdvanceError::TickOverflow) ); assert_eq!(tick_overflow.diagnostic_hash(), before);
let mut day_overflow = State::new(8); day_overflow.far.day = u64::MAX; day_overflow.far.day_tick = TICKS_PER_DAY - 1; let before = day_overflow.diagnostic_hash(); assert_eq!( day_overflow.advance_unattended(1), Err(UnattendedAdvanceError::FarFieldOverflow) ); assert_eq!(day_overflow.diagnostic_hash(), before); }
#[test] fn paid_unattended_advance_preflights_wallet_and_clock_atomically() { use crate::runtime::{ActorIdentity, ActorRole};
let mut poor = State::new(9); let actor = poor.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([0, 0, 0]).unwrap(), ActorIdentity::new("player", "Ada", ActorRole::Player), ); let before = poor.diagnostic_hash(); assert_eq!( poor.spend_copper_and_advance_unattended(actor, 1, 1), Err(PaidUnattendedError::Wallet(WalletError::InsufficientFunds)) ); assert_eq!(poor.diagnostic_hash(), before);
let mut overflow = State::new(10); let actor = overflow.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([0, 0, 0]).unwrap(), ActorIdentity::new("player", "Ada", ActorRole::Player), ); overflow.earn_copper(actor, 8).unwrap(); overflow.tick = u64::MAX; let before = overflow.diagnostic_hash(); assert_eq!( overflow.spend_copper_and_advance_unattended(actor, 8, 1), Err(PaidUnattendedError::Clock( UnattendedAdvanceError::TickOverflow )) ); assert_eq!(overflow.diagnostic_hash(), before); assert_eq!(overflow.wallet(actor).unwrap().copper(), 8); }
#[test] fn unattended_advance_runs_npc_schedule_motion() { use crate::npc::{Job, Townsfolk}; use crate::runtime::{ActorIdentity, ActorRole}; let mut state = State::new(8); let id = state.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([9, 0, 0]).unwrap(), ActorIdentity::new("townsfolk", "Mara", ActorRole::Npc), ); state.npcs.insert( id, Townsfolk { name: "Mara".into(), job: Job::Miller, home: (0, 0, 0), work: (9, 0, 0), debt_cu: 0, }, ); state.advance_unattended(TICKS_PER_DAY).unwrap(); assert_eq!(state.actors[0].position.millimetres(), [500, 500, 0]); }
#[test] fn far_field_clock_advances() { // Rollover logic is unit-tested directly (fast); the step-based check // below only asserts the kernel drives the clock (600 steps keeps // debug test times sane now that ticks run all 12 wired phases). let mut f = FarField::default(); for _ in 0..TICKS_PER_DAY { f.advance(); } assert_eq!(f.day, 1); assert_eq!(f.day_tick, 0);
let mut s = State::new(13); for _ in 0..600 { s.step(); } assert_eq!(s.far.day, 0); assert_eq!(s.far.day_tick, 600); }
#[test] fn drafts_flow_downhill_only() { use crate::pressure::{PressureEdge, PressureNode}; let mut s = State::new(21); s.pressure_nodes.push(PressureNode { id: 1, pressure: 2.0, }); s.pressure_nodes.push(PressureNode { id: 2, pressure: 0.5, }); s.pressure_edges.push(PressureEdge { from: 1, to: 2, openness: 0.5, hidden: false, }); s.pressure_edges.push(PressureEdge { from: 1, to: 2, openness: 99.0, hidden: true, }); let d = s.drafts(); assert!((d[0] - 0.75).abs() < 1e-6); assert_eq!(d[1], 0.0); }
#[test] fn speak_law_fires_once() { let mut s = State::new(31); s.speak_law("rooms breathe"); s.speak_law("rooms breathe"); assert_eq!(s.laws.len(), 1); // First sight speaks the NetHack-voice line; replay keeps both. assert_eq!(s.causes.len(), 1); assert!(s.causes.links()[0].detail.contains("SUCKED")); }
#[test] fn full_tick_mutates_and_hashes() { let mut s = State::new(41); s.activate_around(0, 0, 0); s.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([4, 4, 8]).unwrap()); s.lights.push(LightSource { x: 4, y: 4, z: 8, radius: 6, warm: true, }); let h0 = s.hash_state(); s.step(); assert_eq!(s.tick, 1); // Gravity settled the actor; light wrote cells: hash must move. assert_ne!(s.hash_state(), h0); // Determinism: replay the same tick from scratch. let mut t = State::new(41); t.activate_around(0, 0, 0); t.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([4, 4, 8]).unwrap()); t.lights.push(LightSource { x: 4, y: 4, z: 8, radius: 6, warm: true, }); t.step(); assert_eq!(s.hash_state(), t.hash_state()); }
#[test] fn light_phase_bakes_initial_sources_and_moves_without_a_trail() { let mut s = State::new(43); s.activate_around(0, 0, 0); s.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([4, 4, 8]).unwrap()); s.lights.push(LightSource { x: 4, y: 4, z: 8, radius: 6, warm: true, }); // A registered source on an unstepped world ships a dark field: the // dirty-only pass has an empty dirty set until the phase bakes. assert_eq!(s.grid.get_world(5, 4, 8).light, 0); s.step(); assert!( s.grid.get_world(5, 4, 8).light > 0, "the Light phase must bake the registered source on the first tick" ); // The carried lamp moves: the departed box is cleared, so the lamp // leaves no stale illumination behind (docs/03 §3.3.1). s.lights[0] = LightSource { x: 24, y: 4, z: 8, radius: 6, warm: true, }; s.step(); assert_eq!( s.grid.get_world(5, 4, 8).light, 0, "a moved lamp must leave no light trail" ); assert!(s.grid.get_world(25, 4, 8).light > 0);
// Dousing the lamp (removing the source) darkens the box. s.lights.clear(); s.step(); assert_eq!(s.grid.get_world(25, 4, 8).light, 0); }}