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.
155 kB · 4017 lines
Rust
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018use std::collections::BTreeMap;use std::ffi::OsString;#[cfg(test)]use std::fmt::Write as _;use std::io::{self, Write};use std::path::{Path, PathBuf};use std::time::Instant;
use hexwork_content::RuntimePack;use hexwork_core::modes::Mode;#[cfg(test)]use hexwork_render::camera::View;use hexwork_render::env as render_env;#[cfg(test)]use hexwork_render::scene::render;use hexwork_render::scene::RenderScratch;#[cfg(test)]use hexwork_render::scene::{render_tactical_map, SemanticRole, TacticalMapContext};use hexwork_render::FrameBuffer;use hexwork_save::artifact::{durable_replace, ArtifactStore, RecoveryLimits};use hexwork_save::{ decode_replay, read_file_bounded, BoundedReadError, RunId, MAX_COMPRESSED_BYTES,};use hexwork_term::backend::{refusal_message, Backend};use hexwork_term::keymap::KEYMAP_FILE_NAME;use hexwork_term::remap::{load_keymap, Action, Keymap, LoadError, LoadSource};use hexwork_term::size_ok;use hexwork_term::Input;#[cfg(test)]use hexwork_ui::inventory::render_browser;#[cfg(test)]use hexwork_ui::logfmt::{recent_log_window, RecentLogWindow};#[cfg(test)]use hexwork_ui::transfer::{render_transfer_prompt, TransferPromptKind as UITransferPromptKind};#[cfg(test)]use hexwork_ui::Screen;
use crate::app::{ default_new_setup, recover_checked, recovery_limits, validate_identity_constraints, AppError, Application, InputBarrier, InputResult, LifecycleBoundary, LoadConstraints, WorkBudget,};use crate::cli::{Cli, ModeConstraint, Operation, HELP};use crate::crash::{install_panic_hook, update_panic_state};use crate::frontend_support::{ parse_script, select_content_root, select_data_root, HeadlessCommand, Platform, RootChoice, RootOrigin, MAX_SCRIPT_BYTES,};#[cfg(test)]use crate::loop_::Loop;use crate::output::{JsonOutput, Record};use crate::presentation_settings::{ self, CommitOutcome, LoadedPreferences, PaletteChoice, PreferenceSource, PresentationLoadError, PresentationPreferences,};#[cfg(test)]use crate::presentation_settings::RenderTuning;use crate::session::{GameSession, ReplayLimits};use crate::signals::{InstalledSignals, SignalController};
#[cfg(test)]#[path = "../tests/routes.rs"]pub mod test_routes;
const MELEE_WARNING: &str = "Melee warning: surviving hostiles counterattack only within their own reach.";const TERMINAL_CHROME_ROWS: u16 = 8;#[cfg(test)]const RECENT_LOG_ROWS: usize = hexwork_game::chrome::RECENT_LOG_ROWS;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum ColorMode { Truecolor, Ansi256, NoColor,}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum DrainedBatchResult { Drained, PresentationBarrier, Quit, Lifecycle(LifecycleBoundary),}
fn dispatch_drained_batch<W: Write>( app: &mut Application, inputs: impl IntoIterator<Item = hexwork_term::Input>, limits: RecoveryLimits, output: &mut JsonOutput<W>,) -> Result<DrainedBatchResult, AppError> { for input in inputs { let result = match app.physical_input_with_boundary(input, None, output, None, limits) { Ok(result) => result, Err(error) if error.is_input_rejection() => { if error.input_barrier() == InputBarrier::DiscardDrainedTerminalBatch { return Ok(DrainedBatchResult::PresentationBarrier); } continue; } Err(error) => return Err(error), }; if result.input == InputResult::Quit { return Ok(DrainedBatchResult::Quit); } if result.lifecycle != LifecycleBoundary::None { return Ok(DrainedBatchResult::Lifecycle(result.lifecycle)); } if result.barrier == InputBarrier::DiscardDrainedTerminalBatch { return Ok(DrainedBatchResult::PresentationBarrier); } } Ok(DrainedBatchResult::Drained)}
fn finish_headless_command<W: Write>( app: &mut Application, command_index: Option<usize>, output: &mut JsonOutput<W>, limits: RecoveryLimits,) -> Result<(), AppError> { app.finish_signal_if_pending(command_index, output, limits) .map(|_| ())}
fn operation_requires_durable_writes(cli: &Cli) -> bool { match cli.operation { Operation::Help | Operation::Arena => false, Operation::Replay(_) => cli.export_ansi.is_some(), Operation::Recover | Operation::Play => true, }}
fn bounded_read_error(code: &'static str, error: BoundedReadError) -> AppError { match error { BoundedReadError::Io(error) => AppError::io(code, error.to_string()), BoundedReadError::TooLarge { actual, limit } => AppError::limit( code, format!("file is at least {actual} bytes; limit is {limit}"), ), }}
fn safe_diagnostic(value: &str) -> String { value .chars() .take(512) .map(|character| { if character.is_control() { ' ' } else { character } }) .collect()}
fn presentation_load_error(error: PresentationLoadError) -> ProcessError { match error { PresentationLoadError::Invalid(detail) => { ProcessError::Usage(safe_diagnostic(&format!("presentation.json: {detail}"))) } PresentationLoadError::Io(error) => AppError::io( "presentation-read", safe_diagnostic(&format!("cannot read presentation.json: {error}")), ) .into(), PresentationLoadError::NotRegular => AppError::io( "presentation-read", "presentation.json is not a regular file", ) .into(), PresentationLoadError::TooLarge { limit } => AppError::limit( "presentation-limit", format!("presentation.json exceeds {limit} bytes"), ) .into(), }}
struct BootPresentation { loaded: LoadedPreferences, effective: PresentationPreferences,}
fn load_boot_presentation( data_path: &Path, cli: &Cli, signals: &SignalController,) -> Result<BootPresentation, ProcessError> { signals.refuse_if_pending("before presentation preference read")?; let loaded = presentation_settings::load(data_path).map_err(presentation_load_error)?; signals.refuse_if_pending("after presentation preference read")?; let effective = loaded .preferences() .with_overrides(&cli.presentation_overrides) .map_err(presentation_load_error)?; Ok(BootPresentation { loaded, effective })}
fn palette_name(choice: PaletteChoice) -> &'static str { match choice { PaletteChoice::Default => "default", PaletteChoice::Protan => "protan", PaletteChoice::Deutan => "deutan", PaletteChoice::Tritan => "tritan", PaletteChoice::HighContrast => "high-contrast", }}
fn keymap_load_error(error: LoadError) -> ProcessError { match error { LoadError::Parse(error) => ProcessError::Usage(safe_diagnostic(&error.to_string())), LoadError::InvalidUtf8 => ProcessError::Usage("keymap is not valid UTF-8".into()), LoadError::Read(error) => AppError::io( "keymap-read", safe_diagnostic(&format!("cannot read keymap: {error}")), ) .into(), LoadError::NotRegular => AppError::io("keymap-read", "keymap is not a regular file").into(), LoadError::TooLarge { limit } => { AppError::limit("keymap-limit", format!("keymap exceeds {limit} bytes")).into() } }}
fn keymap_path(data_path: &Path) -> PathBuf { data_path.join(KEYMAP_FILE_NAME)}
fn load_interactive_keymap( data_path: &Path, signals: &SignalController,) -> Result<Keymap, ProcessError> { signals.refuse_if_pending("before keymap read")?; let loaded = load_keymap(keymap_path(data_path)).map_err(keymap_load_error)?; signals.refuse_if_pending("after keymap read")?; if loaded.source == LoadSource::MissingFileFallback { eprintln!("keymap: {KEYMAP_FILE_NAME} missing; using embedded defaults"); } Ok(loaded.keymap)}
use hexwork_game::presentation::binding_label;#[cfg(test)]use hexwork_game::presentation::{inventory_browser_bindings, transfer_prompt_bindings};#[cfg(test)]use hexwork_game::ribbons::{tinker_browser_ribbon, wield_detail_ribbon, tactical_ribbon, keymap_context_ribbon};
#[cfg(test)]fn wall_clock_due_ticks(scheduler: &mut Loop, elapsed_ms: u64, enabled: bool) -> u64 { if enabled { scheduler.due_ticks(elapsed_ms) } else { *scheduler = Loop::new(); 0 }}
pub fn run() -> i32 { match run_inner(std::env::args_os()) { Ok(()) => 0, Err(ProcessError::Usage(detail)) => { eprintln!("usage: {detail}\n\n{HELP}"); 2 } Err(ProcessError::App(error)) => { eprintln!("{error}"); error.class as i32 } }}
enum ProcessError { Usage(String), App(AppError),}
impl From<AppError> for ProcessError { fn from(error: AppError) -> Self { Self::App(error) }}
fn run_inner(args: impl IntoIterator<Item = OsString>) -> Result<(), ProcessError> { let cli = Cli::parse(args).map_err(ProcessError::Usage)?; match &cli.operation { Operation::Help => { println!("{HELP}"); return Ok(()); } Operation::Arena => { let mut arena = crate::arena::Arena::demo_room_with_seed(cli.seed); arena.tick_n(20); print!("{}", crate::arena::describe(&arena)); return Ok(()); } _ => {} }
let environment = known_environment()?; let content_choice = resolve_content_root(&cli, &environment)?; if content_choice.origin == RootOrigin::VerifiedDevelopment { eprintln!( "development content fallback: {}", content_choice.path.display() ); } let pack = RuntimePack::load(&content_choice.path) .map_err(|error| AppError::content("content-load", error.to_string()))?;
if cfg!(target_os = "windows") && operation_requires_durable_writes(&cli) { return Err(AppError::contention( "unsupported-durable-writes", "managed mutation supports Linux/macOS only", ) .into()); }
if let Operation::Replay(path) = &cli.operation { return run_replay(&cli, &pack, path).map_err(Into::into); }
let data = select_data_root(platform(), cli.data_dir.clone(), &environment) .map_err(|error| ProcessError::Usage(error.to_string()))?; let data_path = absolutize(data.path)?; let recovery_limits = recovery_limits(cli.trusted_work_limit)?; let installed_signals = InstalledSignals::install()?; let signals = installed_signals.controller(); signals.refuse_if_pending("before managed startup")?;
if matches!(cli.operation, Operation::Recover) { signals.refuse_if_pending("before recovery root mutation")?; let stdout = io::stdout(); let mut output = JsonOutput::new(stdout.lock()); let (mut store, opened) = ArtifactStore::open(&data_path)?; for milestone in opened { let (code, run_id) = crate::output::milestone_fields(&milestone); output .emit(&Record::basic( "artifact_milestone", None, run_id, None, code, )) .map_err(|error| AppError::io("output", error))?; } recover_checked(&mut store, &pack, recovery_limits, &mut output, &signals)?; return Ok(()); }
let script = match cli.headless_script.as_ref() { Some(path) => { let bytes = read_file_bounded(path, MAX_SCRIPT_BYTES) .map_err(|error| bounded_read_error("script-read", error))?; Some(parse_script(&bytes).map_err(|error| ProcessError::Usage(error.to_string()))?) } None => None, };
if let Some(commands) = script { run_headless( cli, pack, data_path, recovery_limits, commands, installed_signals, ) } else { run_terminal(cli, pack, data_path, recovery_limits, installed_signals) }}
fn run_headless( cli: Cli, pack: RuntimePack, data_path: PathBuf, limits: RecoveryLimits, commands: Vec<HeadlessCommand>, installed_signals: InstalledSignals,) -> Result<(), ProcessError> { let signals = installed_signals.controller(); let stdout = io::stdout(); let mut output = JsonOutput::new(stdout.lock()); let mut app = open_application(&cli, pack, &data_path, limits, &mut output, signals)?; let panic_state = install_panic_hook(&data_path); update_panic_state(&panic_state, &app, None); let mut work = WorkBudget::new(cli.trusted_work_limit); let mut explicitly_quit = false; for (index, command) in commands.into_iter().enumerate() { if app.game_over() { break; } match command { HeadlessCommand::Input(input) => { if app.input(input, Some(index), &mut output, Some(&mut work), limits)? == InputResult::Quit { explicitly_quit = true; app.command_ack(index, "quit", &mut output)?; update_panic_state(&panic_state, &app, Some(index)); finish_headless_command(&mut app, Some(index), &mut output, limits)?; break; } app.command_ack(index, "input", &mut output)?; } HeadlessCommand::Advance(count) => { let _boundary = app.advance_with_boundary( u64::from(count), Some(index), &mut output, Some(&mut work), limits, )?; app.command_ack(index, "advance", &mut output)?; } HeadlessCommand::Save => { work.charge_action()?; app.save(Some(index), &mut output)?; app.command_ack(index, "save", &mut output)?; } HeadlessCommand::Observe(kind) => { work.charge_action()?; app.observe(kind, index, &mut output)?; } HeadlessCommand::Quit => { work.charge_action()?; app.save(Some(index), &mut output)?; app.command_ack(index, "quit", &mut output)?; explicitly_quit = true; update_panic_state(&panic_state, &app, Some(index)); finish_headless_command(&mut app, Some(index), &mut output, limits)?; break; } } update_panic_state(&panic_state, &app, Some(index)); finish_headless_command(&mut app, Some(index), &mut output, limits)?; } if !explicitly_quit && !app.game_over() { app.save(None, &mut output)?; let mut record = Record::basic( "command_ack", None, Some(app.run_id().to_string()), Some(app.tick()), "eof-controlled-quit", ); record.diagnostic_hash = Some(app.diagnostic_hash()); output .emit(&record) .map_err(|error| AppError::io("output", error))?; } Ok(())}
fn open_application<W: Write>( cli: &Cli, pack: RuntimePack, data_path: &Path, limits: RecoveryLimits, output: &mut JsonOutput<W>, signals: SignalController,) -> Result<Application, ProcessError> { let expected_mode = cli.mode_constraint.map(|constraint| match constraint { ModeConstraint::Story => Mode::Story, ModeConstraint::Ironcartographer => Mode::Ironcartographer, }); if let Some(value) = cli.load.as_ref() { let run_id = value .parse::<RunId>() .map_err(|error| ProcessError::Usage(format!("invalid --load RUN_ID: {error}")))?; Application::load_with_output_checked( pack, data_path, run_id, LoadConstraints { expected_mode, expected_seed: cli.seed_explicit.then_some(cli.seed), }, limits, output, signals, ) .map_err(Into::into) } else { let setup = default_new_setup( cli.seed, cli.mode, cli.name.as_deref(), cli.ancestry, cli.calling, ); Application::new_with_output_checked(pack, data_path, setup, limits, output, signals) .map_err(Into::into) }}
fn run_replay(cli: &Cli, pack: &RuntimePack, path: &Path) -> Result<(), AppError> { let bytes = read_file_bounded(path, MAX_COMPRESSED_BYTES) .map_err(|error| bounded_read_error("replay-read", error))?; let replay = decode_replay(&bytes, pack.hash()) .map_err(|error| AppError::content("replay-decode", error.to_string()))?; let expected_mode = cli.mode_constraint.map(|constraint| match constraint { ModeConstraint::Story => Mode::Story, ModeConstraint::Ironcartographer => Mode::Ironcartographer, }); validate_identity_constraints( &replay.identity, expected_mode, cli.seed_explicit.then_some(cli.seed), )?; let work = cli.trusted_work_limit; let session = GameSession::replay_recorded( pack.clone(), &replay, expected_mode, ReplayLimits { max_full_steps: work.unwrap_or(10_000), max_unattended_iterations: work.unwrap_or(50_000), max_total_elapsed_ticks: work.unwrap_or(100_000), max_evidence_rows: work .map(|value| usize::try_from(value).unwrap_or(usize::MAX)) .unwrap_or(16_384), }, )?; let mut text = format!( "HEXWORK verified event-text replay\nrun {} tick {} hash {:#x}\nevents {} evidence {}\n", replay.identity.run_id, session.tick(), session.canonical_hash()?, replay.events.len(), session.replay_evidence().len() ); for row in session.replay_evidence() { text.push_str(&format!( "tick {} [{}:{}] {}\n", row.tick, row.rule, row.detail, row.subtitle )); if text.len() > crate::output::MAX_OUTPUT_BYTES { return Err(AppError::limit( "replay-output-limit", "event text exceeds 8MiB", )); } } if let Some(export) = cli.export_ansi.as_ref() { let owned = format!("\x1b[2J\x1b[H{text}\x1b[0m"); durable_replace(export, owned.as_bytes()) .map_err(|error| AppError::io("replay-export", error.to_string()))?; } else { print!("{text}"); io::stdout() .flush() .map_err(|error| AppError::io("replay-output", error.to_string()))?; } Ok(())}
#[cfg(test)]#[test]fn host_resize_admission_rejects_before_frame_allocation() { assert_eq!(admitted_viewport(80,24), Some(16)); for (w,h) in [(0,0),(0,24),(80,0),(79,23),(u16::MAX,u16::MAX),(4097,24),(1024,1024)] { assert_eq!(admitted_viewport(w,h), None); } assert_eq!(admitted_viewport(80,24), Some(16));}fn admitted_viewport(width: u16, height: u16) -> Option<usize> { if !size_ok(width, height) { return None; } hexwork_game::composed_frame::validate_dimensions(usize::from(width), usize::from(height)).ok()?; Some(usize::from(height.checked_sub(TERMINAL_CHROME_ROWS)?))}
fn run_terminal( mut cli: Cli, pack: RuntimePack, data_path: PathBuf, limits: RecoveryLimits, installed_signals: InstalledSignals,) -> Result<(), ProcessError> { let signals = installed_signals.controller(); let boot_presentation = load_boot_presentation(&data_path, &cli, &signals)?; let keymap = load_interactive_keymap(&data_path, &signals)?; let caps = Backend::probe_caps(); if admitted_viewport(caps.size.0, caps.size.1).is_none() { return Err(ProcessError::Usage(refusal_message( caps.size.0, caps.size.1, ))); } let overrides = render_env::read_env(); let mut render_caps = render_env::CapabilitiesView { truecolor: caps.truecolor, unicode: caps.unicode, }; render_env::apply(&mut render_caps, &overrides); if !render_caps.truecolor { let reason = if overrides.no_color { "HEXWORK_NO_COLOR" } else if overrides.force_256 { "HEXWORK_FORCE_256" } else { "terminal probe (no truecolor)" }; eprintln!("Downshifted from truecolor ({reason}) — set HEXWORK_FORCE_TC=1 to override."); } // Explicit opt-in: keyboard remains sufficient on terminals without capture. let mouse_requested = std::env::var_os("HEXWORK_MOUSE").is_some_and(|value| value == "1"); let mut backend = Backend::with_keymap_and_mouse(keymap, mouse_requested) .map_err(|error| AppError::io("terminal-open", error.to_string()))?; if needs_interactive_setup(&cli) && !choose_missing_setup(&mut backend, &mut cli, caps.size.0 as usize)? { return Ok(()); } signals.refuse_if_pending("before terminal managed startup")?; let mut startup_sink = JsonOutput::new(io::sink()); let mut app = open_application(&cli, pack, &data_path, limits, &mut startup_sink, signals)?; let commit = app.commit_startup_presentation( &boot_presentation.loaded, &boot_presentation.effective, cli.presentation_overrides.is_requested(), )?; let tuning = boot_presentation.effective.render_tuning(); eprintln!( "presentation: source={} gamma={} fov={} palette={} commit={}", match boot_presentation.loaded.source() { PreferenceSource::Defaults => "defaults", PreferenceSource::File => "file", }, boot_presentation.effective.gamma(), boot_presentation.effective.fov_deg(), palette_name(boot_presentation.effective.palette_choice()), match commit { CommitOutcome::Unchanged => "unchanged", CommitOutcome::Durable => "durable", }, ); let panic_state = install_panic_hook(&data_path); update_panic_state(&panic_state, &app, None); let mut terminal_cache = crate::composed_terminal::TerminalCache::default(); backend .flush_str(&format!("{}{MELEE_WARNING}\r\n", terminal_cache.physical_clear())) .map_err(|error| AppError::io("terminal-write", error.to_string()))?; let (mut width, mut height) = backend .size() .map_err(|error| AppError::io("terminal-size", error.to_string()))?; let mut viewport_height = admitted_viewport(width, height).ok_or_else(|| AppError::limit("frame-dimensions", "terminal size outside supported bounds"))?; let mut frame = FrameBuffer::new(width as usize, viewport_height); let mut render_scratch = RenderScratch::default(); let ascii_only = overrides.no_color || overrides.force_ascii || !render_caps.unicode; let color_mode = if overrides.no_color { ColorMode::NoColor } else if render_caps.truecolor { ColorMode::Truecolor } else { ColorMode::Ansi256 }; // Diagnostics are opt-in, separate from all save/replay state. Create-new // avoids clobbering a user file (and refuses pre-existing symlink targets). let mut interface_metadata = std::env::var_os("HEXWORK_INTERFACE_METADATA") .map(|path| { std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path) }) .transpose() .map_err(|error| AppError::io("interface-metadata-open", error.to_string()))?; let build_identifier = interface_metadata .as_ref() .and_then(|_| std::env::current_exe().ok()) .and_then(|path| { std::fs::read(path).ok().map(|bytes| { let hash = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) }); format!("fnv1a64:{hash:016x}") }) }); let mut frame_number = 0u64; let mut pace_base = Instant::now(); app.pace_rebase(0); 'play: loop { { let mut operation_sink = JsonOutput::new(io::sink()); app.finish_signal_if_pending(None, &mut operation_sink, limits)?; } let inputs = backend .poll_input(10) .map_err(|error| AppError::io("terminal-input", error.to_string()))?; // Resize is an atomic presentation boundary. Discard the whole drained // batch rather than letting keys queued at an obsolete size move the player. let resized = inputs.iter().rev().find_map(|input| match input { Input::Resize(columns, rows) => Some((*columns, *rows)), _ => None, }); if let Some((columns, rows)) = resized { let mut operation_sink = JsonOutput::new(io::sink()); app.physical_input_with_boundary( Input::FocusLost, None, &mut operation_sink, None, limits, )?; width = columns; height = rows; if let Some(admitted) = admitted_viewport(width, height) { viewport_height = admitted; frame = FrameBuffer::new(width as usize, viewport_height); } pace_base = Instant::now(); app.pace_rebase(0); backend .flush_str(terminal_cache.physical_clear()) .map_err(|error| AppError::io("terminal-resize", error.to_string()))?; } if admitted_viewport(width, height).is_none() { // Signals are still serviced at the top of the loop. Neither input // nor elapsed wall-clock time is committed while the display is unsafe. pace_base = Instant::now(); app.pace_rebase(0); backend .flush_str(&format!( "\x1b[0m\x1b[H{}\x1b[K", terminal_line("Resize to 80x24; play suspended.", width as usize) )) .map_err(|error| AppError::io("terminal-resize", error.to_string()))?; continue; } let inputs = if resized.is_some() { Vec::new() } else { inputs }; let drained_any = !inputs.is_empty(); let mut operation_sink = JsonOutput::new(io::sink()); match dispatch_drained_batch(&mut app, inputs, limits, &mut operation_sink)? { DrainedBatchResult::Drained | DrainedBatchResult::PresentationBarrier => {} DrainedBatchResult::Quit => { app.finish_signal_if_pending(None, &mut operation_sink, limits)?; break 'play; } DrainedBatchResult::Lifecycle(_) if app.game_over() => { app.finish_signal_if_pending(None, &mut operation_sink, limits)?; break 'play; } DrainedBatchResult::Lifecycle(_) => { pace_base = Instant::now(); app.pace_rebase(0); } } // Action-paced locomotion: admit elapsed monotonic time and run exactly // the ticks the scheduler authorizes. Idle with no intent authorizes // none; a held key or toggle-walk authorizes one Move per 100 ms tick. // An overload/system pause is visible and needs an explicit resume. let now_ns = pace_base .elapsed() .as_nanos() .min(u128::from(u64::MAX)) as u64; let held = backend.held_move(); if app.pace_requires_resume() { if drained_any || held.is_some() { app.pace_resume(now_ns)?; } } else { match held { Some(held) => app.pace_set_hold_input(held.as_input())?, None => app.pace_set_hold(None)?, } } { let mut operation_sink = JsonOutput::new(io::sink()); let boundary = app.advance_paced(now_ns, None, &mut operation_sink, None, limits)?; if boundary != LifecycleBoundary::None { pace_base = Instant::now(); app.pace_rebase(0); } } { let mut operation_sink = JsonOutput::new(io::sink()); app.finish_signal_if_pending(None, &mut operation_sink, limits)?; } let (composed, presentation) = hexwork_game::composer::compose_application( &mut app, backend.keymap(), &tuning, ascii_only, &mut frame, &mut render_scratch, )?; let ansi = terminal_cache.encode(composed, renderer_color_mode(color_mode)); backend .flush_str(&ansi) .map_err(|error| AppError::io("terminal-write", error.to_string()))?; frame_number += 1; if let Some(metadata) = interface_metadata.as_mut() { let record = serde_json::json!({ "frame": frame_number, "seed": app.seed(), "run_id": app.run_id().to_string(), "build_identifier": build_identifier, "width": width, "height": height, "color_mode": format!("{color_mode:?}"), "ascii_only": ascii_only, "gamma": boot_presentation.effective.gamma(), "fov": boot_presentation.effective.fov_deg(), "palette": palette_name(boot_presentation.effective.palette_choice()), "mode": format!("{:?}", presentation.view.mode), "look_active": presentation.look_active, "eye": presentation.view.eye, "yaw": presentation.camera_yaw, "pitch": presentation.view.pitch, "elevation": presentation.camera_tilt, "selected_z": presentation.tactical_z, "player_z": presentation.tactical_map.player_cell[2], "player_location": presentation.tactical_map.player_cell, "tick": app.tick(), "diagnostic_hash": app.diagnostic_hash(), "history_len": app.history_len(), "mouse_capture": backend.mouse_enabled(), "screen": format!("{:?}", presentation.screen) }); writeln!(metadata, "{record}") .and_then(|_| metadata.flush()) .map_err(|error| AppError::io("interface-metadata-write", error.to_string()))?; } update_panic_state(&panic_state, &app, None); if app.game_over() { let mut operation_sink = JsonOutput::new(io::sink()); app.finish_signal_if_pending(None, &mut operation_sink, limits)?; break; } } drop(backend); let canonical = app.last_canonical_hash(); println!( "HEXWORK session over — run {} tick {} last canonical hash {} at tick {}.", app.run_id(), app.tick(), canonical .map(|snapshot| format!("{:#x}", snapshot.hash)) .unwrap_or_else(|| "unknown".into()), canonical .map(|snapshot| snapshot.tick.to_string()) .unwrap_or_else(|| "unknown".into()) ); Ok(())}
fn needs_interactive_setup(cli: &Cli) -> bool { cli.load.is_none() && (cli.ancestry.is_none() || cli.calling.is_none())}
fn choose_missing_setup( backend: &mut Backend, cli: &mut Cli, width: usize,) -> Result<bool, ProcessError> { use hexwork_game::chargen::{Chargen, Stage}; let mut model = Chargen::new(cli.ancestry, cli.calling); while !matches!(model.stage(), Stage::Complete | Stage::Cancelled) { backend.flush_str(&format!("\r\n{}\r\n", model.title())) .map_err(|error| AppError::io("terminal-write", error.to_string()))?; let choices = model.choices(); for (index, choice) in choices.iter().enumerate() { let binding = binding_label(backend.keymap(), Action::ChargenPick(index), "unavailable"); let row = terminal_line(&format!("{choice} [{binding}]"), width); backend.flush_str(&format!("{row}\r\n")) .map_err(|error| AppError::io("terminal-write", error.to_string()))?; } let quit = binding_label(backend.keymap(), Action::Quit, "quit action"); let row = terminal_line(&format!(" Cancel: [{quit}]"), width); backend.flush_str(&format!("{row}\r\n")) .map_err(|error| AppError::io("terminal-write", error.to_string()))?; model.decide(choose_index(backend, choices.len())?); } cli.ancestry = model.ancestry; cli.calling = model.calling; Ok(model.stage() == Stage::Complete)}
use hexwork_game::chargen::{adapt_choice, ChoiceState};
fn choose_index(backend: &mut Backend, count: usize) -> Result<Option<usize>, ProcessError> { let mut state = ChoiceState::Waiting; while state == ChoiceState::Waiting { let inputs = backend .poll_input(60_000) .map_err(|error| AppError::io("terminal-input", error.to_string()))?; for input in inputs { state = adapt_choice(state, input, count); } } match state { ChoiceState::Selected(index) => Ok(Some(index)), ChoiceState::Cancelled => Ok(None), ChoiceState::Waiting => unreachable!("choice loop exits only after a decision"), }}
#[cfg(test)]pub(crate) use hexwork_game::composer::render_production_scene;
// Historical renderer fixtures remain regression coverage, not production proof.#[cfg(test)]pub(crate) fn render_with_preferences( state: &hexwork_core::state::State, view: View, tactical_map: &TacticalMapContext, tuning: &RenderTuning, ascii_only: bool, frame: &mut FrameBuffer,) { let view = tuning.view(view); if view.is_orthographic() { render_tactical_map(&state.grid, &view, tactical_map, frame); tuning.apply_gamma(frame); return; // Tactical glyphs are already semantic ASCII, not a brightness ramp. } let lights = hexwork_core::light::sample_light_field(&state.grid, &state.lights) .into_iter() .map(|((x, y, z), level)| (x, y, z, level)) .collect::<Vec<_>>(); let actors = state .actors .iter() .filter_map(|actor| { state.identity(actor.id).map(|identity| { ( identity.template_id.as_str(), actor.position.presentation_metres(), actor.stance, actor.alive, (state.tick % 4) as u8, ) }) }) .collect::<Vec<_>>(); render( &state.grid, &view, tuning.palette(), frame, &lights, &actors, ); tuning.apply_gamma(frame); if ascii_only { ascii_floor(frame); }}
#[cfg(test)]fn encode_frame_diff( previous: &FrameBuffer, next: &FrameBuffer, mode: ColorMode, output: &mut String,) { hexwork_render::ansi::encode_diff_mode(previous, next, output, renderer_color_mode(mode));}
fn renderer_color_mode(mode: ColorMode) -> hexwork_render::ansi::ColorMode { match mode { ColorMode::Truecolor => hexwork_render::ansi::ColorMode::TrueColor, ColorMode::Ansi256 => hexwork_render::ansi::ColorMode::Ansi256, ColorMode::NoColor => hexwork_render::ansi::ColorMode::NoColor, }}
#[cfg(test)]fn encode_frame_full(next: &FrameBuffer, mode: ColorMode, output: &mut String) { hexwork_render::ansi::encode_full_mode(next, output, renderer_color_mode(mode));}
#[cfg(test)]fn append_opaque_modal( output: &mut String, rows: &[String], viewport_height: usize, clear_viewport: bool,) { output.push_str("\x1b[0m"); let viewport_height = viewport_height.min(usize::from(u16::MAX)); if clear_viewport { for row in 0..viewport_height { let _ = write!(output, "\x1b[{};1H\x1b[2K", row + 1); } } for (row, line) in rows.iter().take(viewport_height).enumerate() { let _ = write!(output, "\x1b[{};1H\x1b[2K{line}", row + 1); }}
#[cfg(test)]fn append_terminal_chrome( output: &mut String, viewport_height: usize, width: usize, presentation: &crate::app::PresentationSnapshot, context_ribbon: &str, recent_logs: &RecentLogWindow,) { // Chrome must never inherit terrain backgrounds or semantic bold. output.push_str("\x1b[0m"); let lines = hexwork_game::chrome::chrome_rows(width, presentation, context_ribbon, recent_logs); for (row, line) in lines.into_iter().enumerate() { let _ = write!(output, "\x1b[{};1H{line}", viewport_height + row + 1); }}
fn terminal_line(value: &str, width: usize) -> String { hexwork_ui::text::fit_terminal_cells(value, width)}
#[cfg(test)]fn ascii_floor(frame: &mut FrameBuffer) { for cell in &mut frame.cells { let luminance = u16::from(cell.fg[0]) + u16::from(cell.fg[1]) + u16::from(cell.fg[2]); cell.glyph = match luminance { 0..=40 => ' ', 41..=120 => '.', 121..=220 => ':', 221..=350 => '=', 351..=500 => '#', _ => '@', }; cell.fg = [200, 200, 200]; cell.bg = [0, 0, 0]; }}
fn resolve_content_root( cli: &Cli, environment: &BTreeMap<String, String>,) -> Result<RootChoice, ProcessError> { let explicit = cli.content_root.clone().map(absolutize).transpose()?; if let Some(path) = explicit.as_ref() { if !content_candidate(path) { return Err( AppError::content("content-root-missing", path.display().to_string()).into(), ); } } let packaged = std::env::current_exe() .ok() .and_then(|path| path.parent().map(|parent| parent.join("assets"))) .filter(|path| content_candidate(path)); let development = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .ok() .filter(|path| content_candidate(path)); let choice = select_content_root(explicit, environment, packaged, development) .map_err(|error| ProcessError::Usage(error.to_string()))? .ok_or_else(|| AppError::content("content-root-missing", "no usable content root"))?; let path = absolutize(choice.path)?; if !content_candidate(&path) { return Err(AppError::content("content-root-missing", path.display().to_string()).into()); } Ok(RootChoice { path, ..choice })}
fn content_candidate(path: &Path) -> bool { path.join("assets/data/items.toml").is_file() && path.join("assets/data/monsters.toml").is_file() && path.join("assets/data/hymns.toml").is_file() && path.join("assets/data/gods.toml").is_file() && path.join("assets/data/contracts.toml").is_file() && path.join("assets/data/floors.toml").is_file() && path.join("assets/data/towns.toml").is_file()}
fn known_environment() -> Result<BTreeMap<String, String>, ProcessError> { let mut values = BTreeMap::new(); for name in [ "XDG_DATA_HOME", "HOME", "LOCALAPPDATA", "HEXWORK_CONTENT_ROOT", ] { if let Some(value) = std::env::var_os(name) { let value = value.into_string().map_err(|_| { ProcessError::Usage(format!("environment variable {name} is not valid UTF-8")) })?; values.insert(name.to_owned(), value); } } Ok(values)}
fn absolutize(path: PathBuf) -> Result<PathBuf, ProcessError> { if path.is_absolute() { Ok(path) } else { std::env::current_dir() .map(|cwd| cwd.join(path)) .map_err(|error| AppError::io("current-directory", error.to_string()).into()) }}
const fn platform() -> Platform { #[cfg(target_os = "windows")] { Platform::Windows } #[cfg(target_os = "macos")] { Platform::Macos } #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] { Platform::Linux }}
impl From<hexwork_save::artifact::ArtifactError> for ProcessError { fn from(error: hexwork_save::artifact::ArtifactError) -> Self { ProcessError::App(error.into()) }}
impl From<hexwork_content::RuntimePackError> for ProcessError { fn from(error: hexwork_content::RuntimePackError) -> Self { ProcessError::App(AppError::content("content-load", error.to_string())) }}
impl From<crate::session::SessionError> for ProcessError { fn from(error: crate::session::SessionError) -> Self { ProcessError::App(error.into()) }}
#[cfg(test)]mod tests { use super::*; use hexwork_term::remap::{parse_keymap, DEFAULT_KEYMAP_TOML}; use hexwork_term::Input; use hexwork_ui::inventory::{layout_browser, BrowserContent, BrowserMode}; use hexwork_ui::look::Tooltip; use hexwork_ui::transfer::{layout_transfer_prompt, TransferPromptContent};
fn presentation_tuning( gamma: Option<f32>, fov_deg: Option<f32>, palette: Option<PaletteChoice>, ) -> RenderTuning { PresentationPreferences::default() .with_overrides(&crate::presentation_settings::PresentationOverrides { gamma, fov_deg, palette, }) .unwrap() .render_tuning() }
fn frame_for( app: &Application, view: View, tuning: &RenderTuning, ascii_only: bool, ) -> FrameBuffer { let mut frame = FrameBuffer::new(40, 12); render_with_preferences( app.state().unwrap(), view, &app.presentation().unwrap().tactical_map, tuning, ascii_only, &mut frame, ); frame }
#[derive(Clone, Debug, PartialEq, Eq)] struct TerminalGrid { width: usize, height: usize, cells: Vec<char>, row: usize, column: usize, }
impl TerminalGrid { fn filled(width: usize, height: usize, glyph: char) -> Self { Self { width, height, cells: vec![glyph; width.saturating_mul(height)], row: 0, column: 0, } }
fn apply(&mut self, ansi: &str) { let mut chars = ansi.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\x1b' && chars.next_if_eq(&'[').is_some() { let mut parameters = String::new(); let command = loop { let Some(value) = chars.next() else { return; }; if ('@'..='~').contains(&value) { break value; } parameters.push(value); }; if command == 'H' || command == 'f' { let mut values = parameters .split(';') .map(|value| value.parse::<usize>().unwrap_or(1)); self.row = values.next().unwrap_or(1).saturating_sub(1); self.column = values.next().unwrap_or(1).saturating_sub(1); } else if command == 'K' && parameters == "2" && self.row < self.height { let start = self.row * self.width; self.cells[start..start + self.width].fill(' '); } continue; } match ch { '\r' => self.column = 0, '\n' => { self.row = self.row.saturating_add(1); self.column = 0; } _ if self.row < self.height && self.column < self.width => { self.cells[self.row * self.width + self.column] = ch; self.column += 1; } _ => self.column = self.column.saturating_add(1), } } }
fn row(&self, row: usize) -> &[char] { let start = row * self.width; &self.cells[start..start + self.width] } }
fn remapped_keymap() -> Keymap { let text = DEFAULT_KEYMAP_TOML .replace("look = [\"x\"]", "look = [\"r\"]") .replace("inventory = [\"i\"]", "inventory = [\"g\"]") .replace("pickup = [\"g\"]", "pickup = [\"i\"]") .replace("contracts = [\"c\"]", "contracts = [\"t\"]") .replace("toggle_walk = [\"t\"]", "toggle_walk = [\";\"]") .replace("map_tactical = [\"m\"]", "map_tactical = [\"o\"]") .replace("chapel = [\"p\"]", "chapel = [\"v\"]") .replace("camera_toggle = [\"Tab\"]", "camera_toggle = [\"f\"]") .replace("look_reset = [\"0\"]\n", "") .replace("quit = [\"Esc\", \"q\"]", "quit = [\"0\"]") .replace("confirm = [\"Enter\"]", "confirm = [\"9\"]") .replace("chargen_pick_1 = [\"1\"]", "chargen_pick_1 = [\"7\"]") .replace("chargen_pick_2 = [\"2\"]", "chargen_pick_2 = [\"8\"]"); parse_keymap(&text).unwrap() }
struct SignalOnFlush<'a> { bytes: &'a mut Vec<u8>, signals: SignalController, needle: &'static [u8], triggered: bool, }
impl Write for SignalOnFlush<'_> { fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { self.bytes.extend_from_slice(bytes); Ok(bytes.len()) }
fn flush(&mut self) -> io::Result<()> { if !self.triggered && self .bytes .windows(self.needle.len()) .any(|window| window == self.needle) { self.triggered = true; self.signals.trigger(); } Ok(()) } }
#[derive(Debug)] struct StateObservation { canonical_hash: u64, event_count: u64, detail: String, }
fn observe_state(app: &mut Application) -> StateObservation { let mut bytes = Vec::new(); app.observe( crate::frontend_support::Observation::State, 0, &mut JsonOutput::new(&mut bytes), ) .unwrap(); let record: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); StateObservation { canonical_hash: record["canonical_hash"].as_u64().unwrap(), event_count: record["count"].as_u64().unwrap(), detail: record["detail"].as_str().unwrap().to_owned(), } }
fn managed_application( root: &Path, pack: RuntimePack, mode: Mode, signals: SignalController, ) -> Application { Application::new_with_output_checked( pack, root, default_new_setup(223, mode, None, None, None), RecoveryLimits::default(), &mut JsonOutput::new(Vec::new()), signals, ) .unwrap() }
fn managed_story(root: &Path, pack: RuntimePack) -> Application { managed_application(root, pack, Mode::Story, SignalController::default()) }
#[test] fn shared_route_fixture_scripts_and_arrivals_remain_consistent() { let seed = 223; let inputs = test_routes::grate_mother_death_inputs(seed); let script = test_routes::grate_mother_death_script(seed); assert_eq!(script.lines().count(), inputs.len() + 2); let arrivals = test_routes::first_two_deep_arrivals(seed); assert_ne!(arrivals[0], arrivals[1]); assert!(!test_routes::story_grate_mother_death_script_after_rollback(seed).is_empty()); }
struct FailingWriter;
impl Write for FailingWriter { fn write(&mut self, _bytes: &[u8]) -> io::Result<usize> { Err(io::Error::other("intentional terminal output failure")) }
fn flush(&mut self) -> io::Result<()> { Err(io::Error::other("intentional terminal output failure")) } }
fn expected_eye(cell: hexwork_worldgen::Cell) -> [f32; 3] { [ cell[0] as f32 + 0.5, cell[1] as f32 + 0.5, cell[2] as f32 + 1.6, ] }
/// The world cell a rendered camera eye stands in. A real 300 mm sample can /// leave the player anywhere inside the cell, so the truthful "cell unchanged" /// comparison is the floor of the exact position, not its centre. fn eye_cell(eye: [f32; 3]) -> hexwork_worldgen::Cell { [ eye[0].floor() as i32, eye[1].floor() as i32, (eye[2] - 1.6).round() as i32, ] }
// These historical batch fixtures express world-space routes, unlike the // physical terminal boundary. Translate at that boundary, not in production. // // Canonical North is -Y, matching the fixture compass // (`hexwork_game::test_routes`), and the play camera's canonical yaw of // -pi/2 is exactly the orientation where `physical_input_with_boundary`'s // camera-relative transform is the identity: screen-up is already that same // -Y. The translation at this boundary is therefore the identity, and the // yaw guard below is what keeps it honest — a camera that is no longer in // its canonical play orientation must fail loudly here rather than silently // walk a world-space route in the wrong direction. fn dispatch_drained_batch<W: Write>( app: &mut Application, inputs: impl IntoIterator<Item = Input>, limits: RecoveryLimits, output: &mut JsonOutput<W>, ) -> Result<DrainedBatchResult, AppError> { let world_route = app.presentation_screen() == Screen::Play && app.transfer_prompt_kind().is_none(); if world_route { assert!((app.presentation()?.camera_yaw + std::f32::consts::FRAC_PI_2).abs() < 0.0001); } super::dispatch_drained_batch(app, inputs, limits, output) }
#[test] fn terminal_batch_continues_after_blocked_move_recorded_as_noop_tick() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let route = test_routes::blocked_move_route(223); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-blocked-batch-{}", std::process::id() )); let legal_root = PathBuf::from(format!( "/var/tmp/hexwork-entry-legal-batch-{}", std::process::id() )); let fatal_root = PathBuf::from(format!( "/var/tmp/hexwork-entry-fatal-batch-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&legal_root); let _ = std::fs::remove_dir_all(&fatal_root);
let mut app = managed_application( &root, pack.clone(), Mode::Classic, SignalController::default(), ); assert_eq!( dispatch_drained_batch( &mut app, route.prefix_inputs.iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); assert_eq!( app.presentation().unwrap().view.eye, expected_eye(route.expected_cells[0]) ); let before = observe_state(&mut app); let before_tick = app.tick();
let headless_root = PathBuf::from(format!( "/var/tmp/hexwork-entry-blocked-headless-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&headless_root); let mut headless = managed_application( &headless_root, pack.clone(), Mode::Classic, SignalController::default(), ); dispatch_drained_batch( &mut headless, route.prefix_inputs.iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(); let headless_before = observe_state(&mut headless); let headless_before_tick = headless.tick(); // Direct single-input API: a blocked sample is a recorded no-op action, // not a fatal error, so the call succeeds and the process continues. headless .input( route.rejected_input, Some(0), &mut JsonOutput::new(io::sink()), None, RecoveryLimits::default(), ) .expect("a blocked sample is a recorded action, not a fatal error"); let headless_after = observe_state(&mut headless); assert_eq!(headless.tick(), headless_before_tick + 1); assert_eq!(headless_after.event_count, headless_before.event_count + 1); assert_eq!( eye_cell(headless.presentation().unwrap().view.eye), route.expected_cells[0], "a blocked sample must not change the player cell" );
// A blocked sample is admitted: it is journaled as a no-op action that // spends exactly one tick and leaves the player cell unchanged. The // drained batch keeps going instead of aborting. assert_eq!( dispatch_drained_batch( &mut app, [route.rejected_input], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); let blocked = observe_state(&mut app); assert_eq!( eye_cell(app.presentation().unwrap().view.eye), route.expected_cells[0], "a blocked sample must not change the player cell" ); assert_eq!( app.tick(), before_tick + 1, "a blocked sample spends exactly one tick" ); assert_eq!( blocked.event_count, before.event_count + 1, "the blocked sample is journaled as a no-op action" );
// From the same centred start the fixture's legal follow-up still walks // the delver to the adjacent legal cell. let mut legal_app = managed_application( &legal_root, pack.clone(), Mode::Classic, SignalController::default(), ); dispatch_drained_batch( &mut legal_app, route.prefix_inputs.iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(); let legal_before = observe_state(&mut legal_app); assert!(!route.legal_followup.is_empty()); assert_eq!( dispatch_drained_batch( &mut legal_app, route.legal_followup.iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); assert_eq!( eye_cell(legal_app.presentation().unwrap().view.eye), route.expected_cells[2] ); assert_eq!( observe_state(&mut legal_app).event_count, legal_before.event_count + route.legal_followup.len() as u64 );
let mut fatal_app = managed_application( &fatal_root, pack, Mode::Classic, SignalController::default(), ); dispatch_drained_batch( &mut fatal_app, route.prefix_inputs.iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(); let fatal_before = observe_state(&mut fatal_app); // A genuinely fatal error still aborts: the injected terminal output // failure surfaces as Io, while the blocked sample itself is recorded, // never fatal, and never changes the player cell. let error = dispatch_drained_batch( &mut fatal_app, [route.rejected_input, Input::Quit], RecoveryLimits::default(), &mut JsonOutput::new(FailingWriter), ) .unwrap_err(); assert_eq!(error.class, crate::app::ExitClass::Io); assert!(!error.is_input_rejection()); assert_eq!( eye_cell(fatal_app.presentation().unwrap().view.eye), route.expected_cells[0] ); assert_eq!( observe_state(&mut fatal_app).event_count, fatal_before.event_count + 1 );
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(headless_root); let _ = std::fs::remove_dir_all(legal_root); let _ = std::fs::remove_dir_all(fatal_root); }
fn observed_inventory(app: &mut Application) -> Vec<String> { let mut bytes = Vec::new(); app.observe( crate::frontend_support::Observation::Inventory, 0, &mut JsonOutput::new(&mut bytes), ) .unwrap(); let record: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); record["entries"] .as_array() .unwrap() .iter() .map(|entry| entry.as_str().unwrap().to_owned()) .collect() }
#[test] fn transfer_terminal_batches_discard_queued_inputs_for_open_cancel_success_and_rejection() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-transfer-batch-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); let mut app = managed_application(&root, pack, Mode::Classic, SignalController::default()); let limits = RecoveryLimits::default(); let mut output = JsonOutput::new(io::sink()); let initial_inventory = observed_inventory(&mut app); assert!( initial_inventory.len() >= 2, "Lampbearer kit must have alternatives" ); let initial_eye = app.presentation().unwrap().view.eye;
assert_eq!( dispatch_drained_batch(&mut app, [Input::Inventory], limits, &mut output).unwrap(), DrainedBatchResult::Drained ); assert!(app.inventory_browser_view(80, 16).unwrap().is_some()); // SYNTHETIC rejection seam only: ordinary gameplay/process evidence must not // claim that the initial kit acquired this curse through live controls. let cursed_id = app.curse_selected_inventory_item_for_test().unwrap();
assert_eq!( dispatch_drained_batch( &mut app, [Input::Drop, Input::Confirm, Input::Left], limits, &mut output, ) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(app.transfer_prompt_kind(), Some(UITransferPromptKind::Drop)); assert_eq!(observed_inventory(&mut app), initial_inventory); assert_eq!(app.presentation().unwrap().view.eye, initial_eye);
assert_eq!( dispatch_drained_batch(&mut app, [Input::Drop, Input::Left], limits, &mut output,) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(app.transfer_prompt_kind(), None); assert_eq!(app.presentation_screen(), Screen::Inventory); assert_eq!(app.presentation().unwrap().view.eye, initial_eye);
assert_eq!( dispatch_drained_batch(&mut app, [Input::Drop], limits, &mut output).unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!( dispatch_drained_batch( &mut app, [Input::Confirm, Input::Inventory, Input::Left], limits, &mut output, ) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(app.transfer_prompt_kind(), None); assert_eq!(app.presentation_screen(), Screen::Inventory); assert_eq!(observed_inventory(&mut app), initial_inventory); assert!(app .logs() .iter() .any(|line| line.starts_with("drop-item-stuck:")));
assert_eq!( dispatch_drained_batch(&mut app, [Input::Down], limits, &mut output).unwrap(), DrainedBatchResult::Drained ); let selected = app.inventory_browser_view(80, 16).unwrap().unwrap(); let selected_slot = selected.content.selected.unwrap(); let selected_inventory = observed_inventory(&mut app); let selected_id = selected_inventory[selected_slot] .split(':') .next() .unwrap() .parse::<u64>() .unwrap(); assert_ne!(selected_id, cursed_id);
assert_eq!( dispatch_drained_batch( &mut app, [Input::Drop, Input::Confirm, Input::Left], limits, &mut output, ) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(app.transfer_prompt_kind(), Some(UITransferPromptKind::Drop)); assert_eq!(observed_inventory(&mut app), initial_inventory); assert_eq!(app.presentation().unwrap().view.eye, initial_eye); assert_eq!( dispatch_drained_batch( &mut app, [Input::Confirm, Input::Inventory, Input::Left], limits, &mut output, ) .unwrap(), DrainedBatchResult::PresentationBarrier ); let after_drop = observed_inventory(&mut app); assert_eq!(after_drop.len() + 1, initial_inventory.len()); assert!(!after_drop .iter() .any(|entry| entry.starts_with(&format!("{selected_id}:")))); assert_eq!(app.presentation_screen(), Screen::Inventory); assert_eq!(app.presentation().unwrap().view.eye, initial_eye);
assert_eq!( dispatch_drained_batch(&mut app, [Input::Inventory], limits, &mut output).unwrap(), DrainedBatchResult::Drained ); assert_eq!(app.presentation_screen(), Screen::Play); assert_eq!( dispatch_drained_batch( &mut app, [Input::Pickup, Input::Confirm, Input::Left], limits, &mut output, ) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!( app.transfer_prompt_kind(), Some(UITransferPromptKind::Pickup) ); assert_eq!(observed_inventory(&mut app), after_drop); assert_eq!(app.presentation().unwrap().view.eye, initial_eye);
assert_eq!( dispatch_drained_batch(&mut app, [Input::Pickup, Input::Left], limits, &mut output,) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(app.transfer_prompt_kind(), None); assert_eq!(app.presentation().unwrap().view.eye, initial_eye); assert_eq!( dispatch_drained_batch(&mut app, [Input::Pickup], limits, &mut output).unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!( app.transfer_prompt_kind(), Some(UITransferPromptKind::Pickup) ); assert_eq!( dispatch_drained_batch(&mut app, [Input::Confirm, Input::Left], limits, &mut output,) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(observed_inventory(&mut app), initial_inventory); assert_eq!(app.presentation().unwrap().view.eye, initial_eye);
assert_eq!( dispatch_drained_batch(&mut app, [Input::Pickup, Input::Left], limits, &mut output,) .unwrap(), DrainedBatchResult::PresentationBarrier ); assert_eq!(app.transfer_prompt_kind(), None); assert_eq!(observed_inventory(&mut app), initial_inventory); assert_eq!(app.presentation().unwrap().view.eye, initial_eye);
let _ = std::fs::remove_dir_all(root); }
#[test] fn classic_terminal_boundary_signal_is_finalized_once_by_batch_and_headless_paths() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let route = test_routes::grate_mother_death_inputs(223); let probe_root = PathBuf::from(format!( "/var/tmp/hexwork-entry-classic-signal-probe-{}", std::process::id() )); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-classic-signal-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&probe_root); let _ = std::fs::remove_dir_all(&root);
let mut probe = managed_application( &probe_root, pack.clone(), Mode::Classic, SignalController::default(), ); let lethal_index = route .iter() .position(|input| { dispatch_drained_batch( &mut probe, [*input], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap() == DrainedBatchResult::Lifecycle(LifecycleBoundary::Terminal) }) .expect("ordinary generated route must reach a real Classic death"); drop(probe);
let signals = SignalController::default(); let mut app = managed_application(&root, pack, Mode::Classic, signals.clone()); dispatch_drained_batch( &mut app, route[..lethal_index].iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(); app.input( Input::Inventory, None, &mut JsonOutput::new(io::sink()), None, RecoveryLimits::default(), ) .unwrap(); assert!(app.inventory_browser_view(80, 16).unwrap().is_some()); app.dispatch_contextual_action_while_browser_open_for_test() .unwrap(); let run_id = app.run_id(); let mut milestone_bytes = Vec::new(); let error = dispatch_drained_batch( &mut app, [Input::Unknown], RecoveryLimits::default(), &mut JsonOutput::new(SignalOnFlush { bytes: &mut milestone_bytes, signals, needle: b"\"code\":\"recovery-complete\"", triggered: false, }), ) .unwrap_err(); assert_eq!(error.class, crate::app::ExitClass::Io); assert_eq!(error.code, "signal-received"); assert!(error.detail.contains("terminal recovery boundary")); assert!(app.game_over());
let records = milestone_bytes .split(|byte| *byte == b'\n') .filter(|line| !line.is_empty()) .map(|line| serde_json::from_slice::<serde_json::Value>(line).unwrap()) .collect::<Vec<_>>(); for code in [ "terminal-snapshot-durable", "pending-death-durable", "replay-durable", "epitaph-durable", "vault-death-durable", "committed-death-durable", "run-retired-durable", "pending-retired-durable", "recovery-complete", ] { assert_eq!( records .iter() .filter(|record| record["code"].as_str() == Some(code)) .count(), 1, "milestone {code} must occur exactly once" ); }
let run_dir = root.join("runs").join(run_id.to_string()); assert!(!run_dir.join("run.save").exists()); assert!(!run_dir.join("death.pending").exists()); let committed_path = run_dir.join("death.committed"); let replay_path = run_dir.join("replay.hexdemo"); let vault_path = root.join("lineage-vault.json"); let committed = std::fs::read(&committed_path).unwrap(); let replay = std::fs::read(&replay_path).unwrap(); let vault = std::fs::read(&vault_path).unwrap();
let mut repeated = Vec::new(); let repeated_error = finish_headless_command( &mut app, Some(lethal_index), &mut JsonOutput::new(&mut repeated), RecoveryLimits::default(), ) .unwrap_err(); assert_eq!(repeated_error.class, crate::app::ExitClass::Io); assert_eq!(repeated_error.code, "signal-received"); assert!(repeated.is_empty()); assert_eq!(std::fs::read(committed_path).unwrap(), committed); assert_eq!(std::fs::read(replay_path).unwrap(), replay); assert_eq!(std::fs::read(vault_path).unwrap(), vault);
let _ = std::fs::remove_dir_all(probe_root); let _ = std::fs::remove_dir_all(root); }
#[test] fn story_boundary_signal_is_reported_after_durable_rollback_save() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let route = test_routes::grate_mother_death_inputs(223); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-story-signal-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root);
let signals = SignalController::default(); let mut app = managed_application(&root, pack, Mode::Story, signals.clone()); dispatch_drained_batch( &mut app, route[..route.len() - 1].iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(); let mut boundary_bytes = Vec::new(); let error = dispatch_drained_batch( &mut app, [route[route.len() - 1]], RecoveryLimits::default(), &mut JsonOutput::new(SignalOnFlush { bytes: &mut boundary_bytes, signals, needle: b"\"code\":\"run-snapshot-durable\"", triggered: false, }), ) .unwrap_err(); assert_eq!(error.class, crate::app::ExitClass::Io); assert_eq!(error.code, "signal-received"); assert!(error .detail .contains("Story recovery and durable living save")); assert_eq!( boundary_bytes .split(|byte| *byte == b'\n') .filter(|line| !line.is_empty()) .map(|line| serde_json::from_slice::<serde_json::Value>(line).unwrap()) .filter(|record| record["code"].as_str() == Some("run-snapshot-durable")) .count(), 1 ); let restored = observe_state(&mut app); assert!( restored .detail .contains("location=Town(\"milthollow\");chapel_debt=1"), "{}", restored.detail ); assert!(!app.game_over());
let _ = std::fs::remove_dir_all(root); }
#[test] fn transfer_prompt_batch_stops_after_classic_lifecycle_and_discards_later_inputs() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let route = test_routes::grate_mother_death_inputs(223); let probe_root = PathBuf::from(format!( "/var/tmp/hexwork-entry-transfer-classic-probe-{}", std::process::id() )); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-transfer-classic-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&probe_root); let _ = std::fs::remove_dir_all(&root);
let mut probe = managed_application( &probe_root, pack.clone(), Mode::Classic, SignalController::default(), ); let lethal_index = route .iter() .position(|input| { dispatch_drained_batch( &mut probe, [*input], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap() == DrainedBatchResult::Lifecycle(LifecycleBoundary::Terminal) }) .expect("ordinary generated route must reach a real Classic death"); drop(probe);
let mut app = managed_application(&root, pack, Mode::Classic, SignalController::default()); assert_eq!( dispatch_drained_batch( &mut app, route[..lethal_index].iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); app.input( Input::Inventory, None, &mut JsonOutput::new(io::sink()), None, RecoveryLimits::default(), ) .unwrap(); app.input( Input::Drop, None, &mut JsonOutput::new(io::sink()), None, RecoveryLimits::default(), ) .unwrap(); assert_eq!(app.transfer_prompt_kind(), Some(UITransferPromptKind::Drop)); app.dispatch_contextual_action_while_browser_open_for_test() .unwrap(); assert_eq!(app.transfer_prompt_kind(), Some(UITransferPromptKind::Drop)); let dead = observe_state(&mut app);
assert_eq!( dispatch_drained_batch( &mut app, [Input::Confirm, Input::Left], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Lifecycle(LifecycleBoundary::Terminal) ); assert!(app.game_over()); assert_eq!(app.transfer_prompt_kind(), None); assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); let finalized = observe_state(&mut app); assert_eq!(finalized.canonical_hash, dead.canonical_hash); assert_eq!(finalized.event_count, dead.event_count);
let _ = std::fs::remove_dir_all(probe_root); let _ = std::fs::remove_dir_all(root); }
#[test] fn transfer_prompt_batch_stops_after_story_lifecycle_and_discards_later_inputs() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let route = test_routes::grate_mother_death_inputs(223); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-inventory-story-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); let mut app = managed_story(&root, pack); assert_eq!( dispatch_drained_batch( &mut app, route[..route.len() - 1].iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); app.input( Input::Inventory, None, &mut JsonOutput::new(io::sink()), None, RecoveryLimits::default(), ) .unwrap(); assert!(app.inventory_browser_view(80, 16).unwrap().is_some()); app.input( Input::Drop, None, &mut JsonOutput::new(io::sink()), None, RecoveryLimits::default(), ) .unwrap(); assert_eq!(app.transfer_prompt_kind(), Some(UITransferPromptKind::Drop)); app.dispatch_contextual_action_while_browser_open_for_test() .unwrap(); assert_eq!(app.transfer_prompt_kind(), Some(UITransferPromptKind::Drop)); let sentinel = test_routes::story_grate_mother_death_inputs_after_rollback(223) .into_iter() .next() .expect("post-rollback route begins with movement"); assert_ne!(sentinel, Input::Confirm); assert_eq!( dispatch_drained_batch( &mut app, [Input::Confirm, sentinel], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Lifecycle(LifecycleBoundary::StoryResolved) ); assert_eq!(app.transfer_prompt_kind(), None); assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); assert_eq!(app.presentation_screen(), Screen::Play); let restored = observe_state(&mut app); assert!( restored .detail .contains("location=Town(\"milthollow\");chapel_debt=1"), "{}", restored.detail ); let events_after_boundary = restored.event_count; assert_eq!( dispatch_drained_batch( &mut app, [sentinel], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); assert_eq!( observe_state(&mut app).event_count, events_after_boundary + 1 ); let _ = std::fs::remove_dir_all(root); }
#[test] fn terminal_batch_stops_after_actual_story_death_and_fresh_movement_remains_effective() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let route = test_routes::grate_mother_death_inputs(223); let probe_root = PathBuf::from(format!( "/var/tmp/hexwork-entry-batch-probe-{}", std::process::id() )); let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-batch-story-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&probe_root); let _ = std::fs::remove_dir_all(&root);
let mut probe = managed_story(&probe_root, pack.clone()); let lethal_index = route .iter() .position(|input| { dispatch_drained_batch( &mut probe, [*input], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap() == DrainedBatchResult::Lifecycle(LifecycleBoundary::StoryResolved) }) .expect("ordinary generated route must reach a real Story death"); assert!( route[..lethal_index] .iter() .all(|input| *input != Input::Quit), "ordinary route prefix must contain only gameplay input" );
let mut app = managed_story(&root, pack.clone()); assert_eq!( dispatch_drained_batch( &mut app, route[..lethal_index].iter().copied(), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); let before = observe_state(&mut app); assert!( before.detail.contains("location=Deep(2)"), "{}", before.detail );
let sentinel = test_routes::story_grate_mother_death_inputs_after_rollback(223) .into_iter() .next() .expect("chapel-to-Grate-mother route starts with movement"); assert_ne!(sentinel, Input::Confirm); assert_eq!( dispatch_drained_batch( &mut app, [route[lethal_index], sentinel], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Lifecycle(LifecycleBoundary::StoryResolved) ); let restored_position = app.presentation().unwrap().view.eye; let chapel_access = hexwork_worldgen::milthollow(223) .interactions .into_iter() .find(|interaction| { matches!( &interaction.kind, hexwork_worldgen::InteractionKind::Service { id } if id == "chapel" ) }) .expect("canonical chapel interaction") .access; assert_eq!(restored_position, expected_eye(chapel_access)); let restored = observe_state(&mut app); assert!( restored .detail .contains("location=Town(\"milthollow\");chapel_debt=1"), "{}", restored.detail ); assert_eq!(restored.event_count, before.event_count + 2); assert!(app .logs() .last() .is_some_and(|log| log == "The chapel returned the delver to town."));
assert_eq!( dispatch_drained_batch( &mut app, [sentinel], RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), ) .unwrap(), DrainedBatchResult::Drained ); let moved_position = app.presentation().unwrap().view.eye; let moved = observe_state(&mut app); assert_ne!(moved_position, restored_position); assert_eq!(moved.event_count, restored.event_count + 1); assert_ne!(moved.canonical_hash, restored.canonical_hash);
let run_id = app.run_id(); drop(app); let mut reloaded = Application::load_with_output_checked( pack, &root, run_id, LoadConstraints { expected_mode: Some(Mode::Story), expected_seed: Some(223), }, RecoveryLimits::default(), &mut JsonOutput::new(Vec::new()), SignalController::default(), ) .unwrap(); let durable = observe_state(&mut reloaded); assert_eq!(durable.canonical_hash, restored.canonical_hash); assert_eq!(durable.event_count, restored.event_count); assert_eq!(durable.detail, restored.detail); assert_eq!(reloaded.presentation().unwrap().view.eye, restored_position);
let _ = std::fs::remove_dir_all(probe_root); let _ = std::fs::remove_dir_all(root); }
#[test] fn paused_wall_clock_discards_partial_and_paused_elapsed_without_catchup() { let mut scheduler = Loop::new(); assert_eq!(wall_clock_due_ticks(&mut scheduler, 99, true), 0); assert_eq!(wall_clock_due_ticks(&mut scheduler, 500, false), 0); assert_eq!(wall_clock_due_ticks(&mut scheduler, 1, true), 0); assert_eq!(wall_clock_due_ticks(&mut scheduler, 99, true), 1); }
#[test] fn operation_write_predicate_keeps_stdout_replay_read_only() { let replay = Cli::parse([ OsString::from("hexwork"), OsString::from("--replay"), OsString::from("run.bin"), ]) .unwrap(); assert!(!operation_requires_durable_writes(&replay)); let export = Cli::parse([ OsString::from("hexwork"), OsString::from("--replay"), OsString::from("run.bin"), OsString::from("--export-ansi"), OsString::from("run.ansi"), ]) .unwrap(); assert!(operation_requires_durable_writes(&export)); let recover = Cli::parse([OsString::from("hexwork"), OsString::from("--recover")]).unwrap(); assert!(operation_requires_durable_writes(&recover)); }
#[test] fn bounded_reader_variants_have_exact_exit_classes() { let io = bounded_read_error( "script-read", BoundedReadError::Io(io::Error::new(io::ErrorKind::NotFound, "missing")), ); assert_eq!(io.class, crate::app::ExitClass::Io); let large = bounded_read_error( "replay-read", BoundedReadError::TooLarge { actual: 11, limit: 10, }, ); assert_eq!(large.class, crate::app::ExitClass::Limit); assert_eq!(large.code, "replay-read"); }
#[test] fn keymap_path_joins_selected_absolute_data_root() { let explicit = match absolutize(PathBuf::from("relative-data")) { Ok(path) => path, Err(_) => panic!("relative explicit root did not absolutize"), }; assert!(explicit.is_absolute()); assert_eq!(keymap_path(&explicit), explicit.join(KEYMAP_FILE_NAME));
let selected = select_data_root( Platform::Linux, None, &BTreeMap::from([("XDG_DATA_HOME".to_owned(), "/named-root".to_owned())]), ) .unwrap(); let platform_root = match absolutize(selected.path) { Ok(path) => path, Err(_) => panic!("selected platform root did not absolutize"), }; assert_eq!(platform_root, PathBuf::from("/named-root/hexwork")); assert_eq!( keymap_path(&platform_root), platform_root.join(KEYMAP_FILE_NAME) ); }
#[test] fn keymap_error_adapter_has_exact_exit_classes_and_safe_bounded_text() { let usage = keymap_load_error(LoadError::InvalidUtf8); assert!(matches!(usage, ProcessError::Usage(_)));
let read = keymap_load_error(LoadError::Read(io::Error::new( io::ErrorKind::PermissionDenied, "denied\u{1b}[31m", ))); let ProcessError::App(read) = read else { panic!("read error was not an application error") }; assert_eq!(read.class, crate::app::ExitClass::Io); assert_eq!(read.code, "keymap-read"); assert!(!read.detail.contains('\u{1b}')); assert!(read.detail.chars().count() <= 512);
let not_regular = keymap_load_error(LoadError::NotRegular); let ProcessError::App(not_regular) = not_regular else { panic!("type error was not an application error") }; assert_eq!(not_regular.class, crate::app::ExitClass::Io);
let too_large = keymap_load_error(LoadError::TooLarge { limit: 65_536 }); let ProcessError::App(too_large) = too_large else { panic!("limit error was not an application error") }; assert_eq!(too_large.class, crate::app::ExitClass::Limit); assert_eq!(too_large.code, "keymap-limit"); }
#[test] fn keymap_read_failure_is_deterministic_without_permission_assumptions() { let root = PathBuf::from(format!( "/var/tmp/hexwork-entry-keymap-read-{}", std::process::id() )); let _ = std::fs::remove_file(&root); let _ = std::fs::remove_dir_all(&root); std::fs::write(&root, b"not a directory").unwrap(); let error = match load_interactive_keymap(&root, &SignalController::default()) { Ok(_) => panic!("non-directory parent unexpectedly loaded a keymap"), Err(error) => error, }; let ProcessError::App(error) = error else { panic!("read failure was not an application error") }; assert_eq!(error.class, crate::app::ExitClass::Io); assert_eq!(error.code, "keymap-read"); std::fs::remove_file(root).unwrap(); }
#[test] fn chargen_labels_follow_loaded_bindings() { let keymap = remapped_keymap(); assert_eq!(binding_label(&keymap, Action::ChargenPick(0), "x"), "7"); assert_eq!(binding_label(&keymap, Action::ChargenPick(1), "x"), "8"); assert_eq!(binding_label(&keymap, Action::Quit, "x"), "0"); }
#[test] fn tinker_entry_80_column_remapped_chrome() { let remapped = DEFAULT_KEYMAP_TOML .replace("confirm = [\"Enter\"]", "confirm = [\"BackTab\"]") .replace("quit = [\"Esc\", \"q\"]", "quit = [\"Q\"]") .replace("up_left = [\"Q\", \"y\"]", "up_left = [\"y\"]"); let keymap = parse_keymap(&remapped).unwrap(); let confirm = keymap.primary_binding_label(Action::Confirm).unwrap(); let quit = keymap.primary_binding_label(Action::Quit).unwrap(); let kind = UITransferPromptKind::TinkerIdentify { item_id: 43, fee_cu: 15, wallet_cu: 25, }; let content = hexwork_ui::transfer::TransferPromptContent { kind, item: hexwork_ui::look::Tooltip { title: "Unknown hymn card".into(), lines: vec![], }, original_cell: [8, 19, 8], has_before: false, has_after: false, clipped: false, }; let layout = hexwork_ui::transfer::layout_transfer_prompt(&content, 80, 16); let rows = render_transfer_prompt(&content, &transfer_prompt_bindings(&keymap, kind), &layout); let mut ansi = String::new(); append_opaque_modal(&mut ansi, &rows, 16, true); let mut grid = TerminalGrid::filled(80, 16, ' '); grid.apply(&ansi); let visible: String = grid.cells.iter().collect(); assert!(visible.contains(&confirm)); assert!(visible.contains(&quit)); assert!(visible.contains("15cu")); assert!(visible.contains("25cu")); assert!(visible.contains("43")); let ribbon = keymap_context_ribbon( Screen::Play, &keymap, Some(crate::app::ContextualHint::Tinker { fee_cu: 15 }), None, ); assert!(ribbon.chars().count() <= 80); assert!(ribbon.contains(&confirm)); assert!(ribbon.contains(&quit)); assert!(ribbon.contains("Tinker: identify 15cu")); let mut tinker = hexwork_ui::inventory::TinkerBrowserContent { fee_cu: 15, wallet_cu: 25, eligible_total: 1, selected_item_id: Some(43), selected_known: false, selected_eligible: true, }; for mode in [ hexwork_ui::inventory::BrowserMode::List, hexwork_ui::inventory::BrowserMode::Detail, ] { let ribbon = tinker_browser_ribbon(&keymap, mode, &tinker); let visible = terminal_line(&ribbon, 80); assert!(ribbon.chars().count() <= 80); assert!(visible.contains(&confirm) && visible.contains(&quit)); assert!(visible.starts_with("Tinker:")); for false_hint in ["drop", "Wield", "look: move"] { assert!(!visible.contains(false_hint), "{visible}"); } } tinker.selected_eligible = false; let ribbon = tinker_browser_ribbon(&keymap, hexwork_ui::inventory::BrowserMode::Detail, &tinker); assert!(ribbon.contains("not eligible") && !ribbon.contains(&confirm)); assert!( keymap_context_ribbon(Screen::Inventory, &keymap, None, None).contains("drop selected") ); }
#[test] fn all_context_ribbons_are_keymap_aware_control_safe_and_cell_bounded() { let keymap = remapped_keymap(); let cases = [ (Screen::Contracts, &["9 accept", "t close"][..]), ( Screen::Map, &["0 close", "PageUp/PageDown Z", "Home center"], ), (Screen::Inventory, &["r look", "g close"]), (Screen::Look, &["r close"]), (Screen::Chapel, &["9 interact", "v close"]), ( Screen::Play, &[ "r look", "g inventory", "t contracts", "o map", "v chapel", "f camera", "0 quit", ], ), ]; for (screen, expected) in cases { let ribbon = keymap_context_ribbon(screen, &keymap, None, None); for needle in expected { assert!(ribbon.contains(needle), "{screen:?}: {ribbon}"); } for stale in ["arrows", "Enter", "Tab", "x look", "i close", "c close"] { assert!( !ribbon.contains(stale), "{screen:?} advertised {stale}: {ribbon}" ); } let fitted = terminal_line(&ribbon, 40); assert_eq!(terminal_line(&fitted, 40), fitted); assert!(fitted.chars().count() <= 40); assert!(!fitted.chars().any(char::is_control)); }
let contextual = "Melee: confirm an adjacent hostile".to_owned(); assert_eq!( keymap_context_ribbon(Screen::Play, &keymap, Some(contextual.clone().into()), None), format!("0 quit · {contextual}") ); }
#[test] fn transfer_hints_follow_optional_loaded_bindings_without_fake_fallbacks() { let default = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); let play = keymap_context_ribbon(Screen::Play, &default, None, None); let map = keymap_context_ribbon(Screen::Map, &default, None, None); let inventory = keymap_context_ribbon(Screen::Inventory, &default, None, None); let look = keymap_context_ribbon(Screen::Look, &default, None, None); assert!(terminal_line(&play, 80).contains("g pickup"), "{play}"); assert!(terminal_line(&map, 80).contains("g pickup"), "{map}"); assert!( terminal_line(&inventory, 80).contains("- drop selected"), "{inventory}" ); assert!( terminal_line(&look, 80).contains("- drop selected"), "{look}" ); assert!(!play.contains("drop") && !inventory.contains("pickup"));
let remapped = DEFAULT_KEYMAP_TOML .replace("pickup = [\"g\"]", "pickup = [\"r\"]") .replace("drop = [\"-\"]", "drop = [\"v\"]") .replace("look = [\"x\"]", "look = [\"g\"]") .replace("chapel = [\"p\"]", "chapel = [\"x\"]"); let remapped = parse_keymap(&remapped).unwrap(); assert!(keymap_context_ribbon(Screen::Play, &remapped, None, None).contains("r pickup")); assert!( keymap_context_ribbon(Screen::Inventory, &remapped, None, None) .contains("v drop selected") );
let legacy = DEFAULT_KEYMAP_TOML .lines() .filter(|line| !line.starts_with("pickup = ") && !line.starts_with("drop = ")) .collect::<Vec<_>>() .join("\n"); let legacy = parse_keymap(&legacy).unwrap(); for screen in [Screen::Play, Screen::Map, Screen::Inventory, Screen::Look] { let ribbon = keymap_context_ribbon(screen, &legacy, None, None); assert!( !ribbon.contains("pickup") && !ribbon.contains("drop"), "{ribbon}" ); } }
#[test] fn transfer_prompt_hints_own_confirm_cancel_and_suppress_contextual_attack() { let keymap = remapped_keymap(); let pickup = keymap_context_ribbon( Screen::Play, &keymap, Some("Melee: confirm an adjacent hostile".to_owned().into()), Some(UITransferPromptKind::Pickup), ); assert!(pickup.contains("9 confirm") && pickup.contains("0 cancel")); assert!(pickup.contains("or i")); assert!(!pickup.contains("Melee") && !pickup.contains("Attack"));
let drop = keymap_context_ribbon( Screen::Inventory, &keymap, Some("Attack".to_owned().into()), Some(UITransferPromptKind::Drop), ); assert!(drop.contains("9 confirm") && drop.contains("0 cancel")); assert!(drop.contains("or -")); assert!(!drop.contains("Attack"));
let legacy_text = DEFAULT_KEYMAP_TOML .lines() .filter(|line| !line.starts_with("pickup = ") && !line.starts_with("drop = ")) .collect::<Vec<_>>() .join("\n"); let legacy = parse_keymap(&legacy_text).unwrap(); let prompt = keymap_context_ribbon( Screen::Play, &legacy, None, Some(UITransferPromptKind::Pickup), ); assert!(prompt.contains("Enter confirm") && prompt.contains("Esc cancel")); assert!(!prompt.contains(" or ") && !prompt.contains("unavailable")); }
#[test] fn chargen_choice_adapter_selects_cancels_and_ignores_invalid_input() { assert_eq!( adapt_choice(ChoiceState::Waiting, Input::ChargenPick(2), 4), ChoiceState::Selected(2) ); assert_eq!( adapt_choice(ChoiceState::Waiting, Input::ChargenPick(4), 4), ChoiceState::Waiting ); assert_eq!( adapt_choice(ChoiceState::Waiting, Input::Up, 4), ChoiceState::Waiting ); assert_eq!( adapt_choice(ChoiceState::Waiting, Input::Quit, 4), ChoiceState::Cancelled ); assert_eq!( adapt_choice(ChoiceState::Selected(1), Input::Quit, 4), ChoiceState::Selected(1) ); }
#[test] fn initial_perspective_scene_is_visible() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); for (seed, calling) in [ (0, None), (140, None), (140, Some(hexwork_core::character::Calling::Ratcatcher)), ] { for mode in [Mode::Classic, Mode::Story] { let data = std::env::temp_dir().join(format!( "hexwork-initial-scene-{}-{seed}-{mode:?}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let app = Application::new_with_output_checked( RuntimePack::load(&root).unwrap(), &data, default_new_setup(seed, mode, None, None, calling), RecoveryLimits::default(), &mut JsonOutput::new(Vec::new()), SignalController::default(), ) .unwrap(); let view = app.presentation().unwrap().view; for (width, height) in [(40, 12), (80, 16), (120, 28)] { let mut frame = FrameBuffer::new(width, height); let mut scratch = RenderScratch::default(); assert!( (app.presentation().unwrap().camera_yaw + std::f32::consts::FRAC_PI_2) .abs() < 0.0001 ); render_production_scene( app.state().unwrap(), view, &app.presentation().unwrap().tactical_map, &app.presentation_landmarks().unwrap(), &presentation_tuning(None, None, None), false, &mut frame, &mut scratch, ); for color in [ColorMode::Truecolor, ColorMode::Ansi256] { let displayed = |rgb: [u8; 3]| { if color == ColorMode::Truecolor { return rgb; } let index = hexwork_render::ansi::quantize_256(rgb) - 16; let levels = [0, 95, 135, 175, 215, 255]; [ levels[(index / 36) as usize], levels[((index / 6) % 6) as usize], levels[(index % 6) as usize], ] }; let luminance = |rgb: [u8; 3]| { let c = rgb.map(|v| { let s = f64::from(v) / 255.0; if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } }); c[0] * 0.2126 + c[1] * 0.7152 + c[2] * 0.0722 }; // Half-block foreground/background are independent surfaces, // not text contrast. AC7 does not promise broad lit coverage. for half in 0..2 { assert!(scratch.half_samples().iter().enumerate().any(|(i, sample)| { (i / width) % 2 == half && sample.hit.is_some() && luminance(displayed(sample.color)) > 0.0 }), "no lit terrain half={half}: seed={seed} {mode:?} {color:?} {width}x{height}"); } // Deliberately fully lit semantic ink on its black backing: // unlike terrain halves, this is a meaningful 3:1 comparison. for role in [ SemanticRole::Player, SemanticRole::Threat, SemanticRole::Npc, SemanticRole::Item, SemanticRole::Connector, SemanticRole::Interaction, ] { let ink = hexwork_render::scene::semantic_color( role, 255, &hexwork_render::palette::DEFAULT, ); let fg = luminance(displayed(ink)); assert!( fg >= 0.10 && (fg + 0.05) / 0.05 >= 3.0, "lit semantic contrast: {role:?} {color:?} {fg}" ); } } assert!(frame .cells .iter() .any(|cell| cell.glyph != ' ' && (cell.fg != [0; 3] || cell.bg != [0; 3]))); for color in [ColorMode::Truecolor, ColorMode::Ansi256, ColorMode::NoColor] { if color == ColorMode::NoColor { render_production_scene( app.state().unwrap(), view, &app.presentation().unwrap().tactical_map, &app.presentation_landmarks().unwrap(), &presentation_tuning(None, None, None), true, &mut frame, &mut scratch, ); assert!(frame.cells.iter().all(|cell| cell.glyph.is_ascii())); } let mut ansi = String::new(); encode_frame_full(&frame, color, &mut ansi); if color == ColorMode::NoColor { assert!(!ansi.contains("38;") && !ansi.contains("48;")); } let mut terminal = TerminalGrid::filled(frame.w, frame.h, ' '); terminal.apply(&ansi); assert!(terminal.cells.iter().any(|glyph| *glyph != ' ')); } } let _ = std::fs::remove_dir_all(&data); } } }
#[test] fn presentation_defaults_match_existing_frame() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-presentation-default-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let app = managed_application(&data, pack, Mode::Story, SignalController::default()); let view = app.presentation().unwrap().view; let state = app.state().unwrap(); let lights = hexwork_core::light::sample_light_field(&state.grid, &state.lights) .into_iter() .map(|((x, y, z), level)| (x, y, z, level)) .collect::<Vec<_>>(); let actors = state .actors .iter() .filter_map(|actor| { state.identity(actor.id).map(|identity| { ( identity.template_id.as_str(), actor.position.presentation_metres(), actor.stance, actor.alive, (state.tick % 4) as u8, ) }) }) .collect::<Vec<_>>(); let mut legacy = FrameBuffer::new(40, 12); render( &state.grid, &view, &hexwork_render::palette::DEFAULT, &mut legacy, &lights, &actors, ); let actual = frame_for( &app, view, &PresentationPreferences::default().render_tuning(), false, ); assert_eq!(actual.cells, legacy.cells); let _ = std::fs::remove_dir_all(data); }
#[test] fn gamma_lut_matches_component_curve_and_changes_encoded_colors() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-presentation-gamma-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let app = managed_application(&data, pack, Mode::Story, SignalController::default()); let view = app.presentation().unwrap().view; let default = frame_for(&app, view, &presentation_tuning(None, None, None), false); let gamma = frame_for( &app, view, &presentation_tuning(Some(1.5), None, None), false, ); assert_ne!(default.cells, gamma.cells); let changed = default .cells .iter() .zip(&gamma.cells) .find(|(before, after)| before.fg != after.fg || before.bg != after.bg) .expect("gamma must alter a shaded cell"); let mut settings = hexwork_ui::settings::Settings::default(); settings.set_gamma(1.5); assert_eq!(changed.1.fg, settings.apply_gamma(changed.0.fg)); assert_eq!(changed.1.bg, settings.apply_gamma(changed.0.bg)); let blank = FrameBuffer::new(gamma.w, gamma.h); let mut default_ansi = String::new(); let mut gamma_ansi = String::new(); encode_frame_diff(&blank, &default, ColorMode::Truecolor, &mut default_ansi); encode_frame_diff(&blank, &gamma, ColorMode::Truecolor, &mut gamma_ansi); assert_ne!(default_ansi, gamma_ansi); assert!(gamma_ansi.contains("38;2;") || gamma_ansi.contains("48;2;")); let _ = std::fs::remove_dir_all(data); }
#[test] fn perspective_fov_changes_rays_not_authoritative_state() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-presentation-fov-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let mut app = managed_application(&data, pack, Mode::Story, SignalController::default()); let before = observe_state(&mut app); let view = app.presentation().unwrap().view; let wide = frame_for( &app, view, &presentation_tuning(None, Some(75.0), None), false, ); let narrow = frame_for( &app, view, &presentation_tuning(None, Some(30.0), None), false, ); assert_ne!( wide.cells, narrow.cells, "perspective FOV must change sampled rays" ); let after = observe_state(&mut app); assert_eq!(after.canonical_hash, before.canonical_hash); assert_eq!(after.event_count, before.event_count); assert_eq!(app.tick(), 0); let _ = std::fs::remove_dir_all(data); }
#[test] fn palette_selection_changes_real_fog_pixels() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-presentation-palette-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let app = managed_application(&data, pack, Mode::Story, SignalController::default()); let view = app.presentation().unwrap().view; let default = frame_for(&app, view, &presentation_tuning(None, None, None), false); for choice in [ PaletteChoice::Protan, PaletteChoice::Deutan, PaletteChoice::Tritan, PaletteChoice::HighContrast, ] { let selected = frame_for( &app, view, &presentation_tuning(None, None, Some(choice)), false, ); assert_ne!( default.cells, selected.cells, "{choice:?} must alter real fog pixels" ); } let _ = std::fs::remove_dir_all(data); }
#[test] fn presentation_tactical_and_no_color_semantics_are_honest() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-presentation-tactical-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let mut app = managed_application(&data, pack, Mode::Story, SignalController::default()); app.input( Input::Map, None, &mut JsonOutput::new(Vec::new()), None, RecoveryLimits::default(), ) .unwrap(); let view = app.presentation().unwrap().view; let default = frame_for(&app, view, &presentation_tuning(None, None, None), false); let tuned = frame_for( &app, view, &presentation_tuning(None, Some(20.0), Some(PaletteChoice::Protan)), false, ); assert_eq!( default.cells, tuned.cells, "tactical ignores FOV and fog palette" ); let ascii = frame_for( &app, view, &presentation_tuning(Some(1.5), None, Some(PaletteChoice::Protan)), true, ); assert_eq!( ascii .cells .iter() .map(|cell| cell.glyph) .collect::<Vec<_>>(), default .cells .iter() .map(|cell| cell.glyph) .collect::<Vec<_>>(), "tactical glyph semantics survive gamma and forced ASCII" ); assert!(ascii.cells.iter().all(|cell| cell.glyph.is_ascii())); let blank = FrameBuffer::new(ascii.w, ascii.h); let mut no_color = String::new(); encode_frame_diff(&blank, &ascii, ColorMode::NoColor, &mut no_color); assert!(!no_color.contains("38;") && !no_color.contains("48;")); assert!(view.tactical_slice_z.is_some()); let _ = std::fs::remove_dir_all(data); }
#[test] fn render_capability_modes_change_emitted_ansi() { let previous = FrameBuffer::new(1, 1); let mut next = FrameBuffer::new(1, 1); next.cells[0] = hexwork_render::Cell { glyph: 'X', fg: [255, 0, 0], bg: [0, 0, 0], }; let mut truecolor = String::new(); encode_frame_diff(&previous, &next, ColorMode::Truecolor, &mut truecolor); assert!(truecolor.contains("38;2;255;0;0")); let mut ansi256 = String::new(); encode_frame_diff(&previous, &next, ColorMode::Ansi256, &mut ansi256); assert!(ansi256.contains("38;5;196")); assert!(!ansi256.contains("38;2;")); let mut no_color = String::new(); encode_frame_diff(&previous, &next, ColorMode::NoColor, &mut no_color); assert!(no_color.contains('X')); assert!(!no_color.contains("38;")); }
#[test] fn ascii_floor_changes_glyphs_and_removes_hue() { let mut frame = FrameBuffer::new(1, 1); frame.cells[0].glyph = '█'; frame.cells[0].fg = [255, 0, 0]; ascii_floor(&mut frame); assert_eq!(frame.cells[0].glyph, '='); assert_eq!(frame.cells[0].fg, [200, 200, 200]); assert_eq!(frame.cells[0].bg, [0, 0, 0]); }
#[test] fn terminal_lines_are_control_safe_utf8_safe_and_exact_cell_width() { assert_eq!(terminal_line("lamp\u{1b}[31m", 8), "lamp [31"); assert_eq!(terminal_line("Gräte", 7), "Gräte "); assert_eq!(terminal_line("深淵灯", 2), "深"); assert_eq!(terminal_line("深淵灯", 5), "深淵 "); assert_eq!(terminal_line("e\u{301}lan", 3), "e\u{301}la"); assert_eq!(terminal_line("\u{301}a", 1), "a"); assert_eq!(terminal_line("ignored", 0), ""); }
fn emitted_row(output: &str, row: usize) -> &str { let marker = format!("\x1b[{row};1H"); let start = output.find(&marker).expect("emitted terminal row") + marker.len(); let rest = &output[start..]; let end = rest.find("\x1b[").unwrap_or(rest.len()); &rest[..end] }
#[test] fn tactical_entry_preserves_player_and_terrain_in_all_fallback_modes() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let data = PathBuf::from(format!( "/var/tmp/hexwork-tactical-semantic-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let mut app = managed_application( &data, RuntimePack::load(root).unwrap(), Mode::Story, SignalController::default(), ); let mut output = JsonOutput::new(Vec::new()); app.input( Input::Map, None, &mut output, None, RecoveryLimits::default(), ) .unwrap(); let snapshot = app.presentation().unwrap(); let mut expected = None; for gamma in [0.5, 1.0, 2.0] { for (ascii, color) in [ (false, ColorMode::Truecolor), (true, ColorMode::Truecolor), (true, ColorMode::NoColor), (true, ColorMode::Ansi256), ] { let mut frame = FrameBuffer::new(80, 16); render_with_preferences( app.state().unwrap(), snapshot.view, &snapshot.tactical_map, &presentation_tuning(Some(gamma), None, None), ascii, &mut frame, ); let glyphs: Vec<_> = frame.cells.iter().map(|c| c.glyph).collect(); for glyph in ['@', '.', '#', ' '] { assert!(glyphs.contains(&glyph), "missing {glyph}"); } assert_eq!(glyphs.iter().filter(|&&c| c == '@').count(), 1); assert_eq!(glyphs[8 * 80 + 40], '@'); if let Some(expected) = &expected { assert_eq!(&glyphs, expected); } else { expected = Some(glyphs.clone()); } let mut ansi = String::new(); encode_frame_full(&frame, color, &mut ansi); let mut grid = TerminalGrid::filled(80, 16, '?'); grid.apply(&ansi); assert_eq!(grid.cells, glyphs); } } // Labeled synthetic hazard fixture using the same live view/context; // the untouched generated Town above remains the real geometry proof. // No actor tables are needed on the tactical adapter branch. let mut hazards = hexwork_core::state::State::new(app.seed()); let [x, y, z] = snapshot.tactical_map.player_cell; for (dx, voxel) in [ ( 1, hexwork_core::voxel::Voxel { fluid_kind: 1, fluid_amt: 1, ..Default::default() }, ), ( 2, hexwork_core::voxel::Voxel { gas_kind: 1, gas_amt: 1, ..Default::default() }, ), ] { assert!(x + dx <= snapshot.tactical_map.max[0]); hazards.grid.set_world(x + dx, y, z, voxel); hazards.grid.set_world(x + dx, y, z + 1, Default::default()); } for gamma in [0.5, 1.0, 2.0] { for (ascii, color) in [ (false, ColorMode::Truecolor), (true, ColorMode::Truecolor), (true, ColorMode::NoColor), (true, ColorMode::Ansi256), ] { let mut frame = FrameBuffer::new(80, 16); render_with_preferences( &hazards, snapshot.view, &snapshot.tactical_map, &presentation_tuning(Some(gamma), None, None), ascii, &mut frame, ); assert_eq!(frame.cells[8 * 80 + 41].glyph, '~'); assert_eq!(frame.cells[8 * 80 + 42].glyph, ':'); let mut ansi = String::new(); encode_frame_full(&frame, color, &mut ansi); let mut grid = TerminalGrid::filled(80, 16, '?'); grid.apply(&ansi); assert_eq!(grid.row(8)[41..43], ['~', ':']); } } app.input( Input::SliceUp, None, &mut output, None, RecoveryLimits::default(), ) .unwrap(); let off = app.presentation().unwrap(); let mut frame = FrameBuffer::new(80, 16); render_with_preferences( app.state().unwrap(), off.view, &off.tactical_map, &presentation_tuning(None, None, None), true, &mut frame, ); assert!(!frame.cells.iter().any(|c| c.glyph == '@')); app.input( Input::SliceDown, None, &mut output, None, RecoveryLimits::default(), ) .unwrap(); let restored = app.presentation().unwrap(); render_with_preferences( app.state().unwrap(), restored.view, &restored.tactical_map, &presentation_tuning(None, None, None), true, &mut frame, ); assert_eq!( frame.cells.iter().map(|c| c.glyph).collect::<Vec<_>>(), expected.unwrap() ); drop(app); let _ = std::fs::remove_dir_all(data); }
#[test] fn tactical_chrome_legend_and_off_slice_tail_fit_loaded_keys_at_80() { let default = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); let exact = "Esc close · move N-up · PageUp/PageDown Z · Home center · g pickup · …"; assert!(exact.chars().count() <= 80); assert_eq!(tactical_ribbon(&default, 80), exact); let remapped = remapped_keymap(); let long = parse_keymap( &DEFAULT_KEYMAP_TOML .replace("quit = [\"Esc\", \"q\"]", "quit = [\"O\"]") .replace("camera_toggle = [\"Tab\"]", "camera_toggle = [\"BackTab\"]") .replace("pickup = [\"g\"]", "pickup = [\"U\"]"), ) .unwrap(); let wide = parse_keymap( &DEFAULT_KEYMAP_TOML .replace("quit = [\"Esc\", \"q\"]", "quit = [\"界\"]") .replace("slice_up = [\"PageUp\"]", "slice_up = [\"淵\"]") .replace("slice_down = [\"PageDown\"]", "slice_down = [\"森\"]") .replace("camera_toggle = [\"Tab\"]", "camera_toggle = [\"BackTab\"]") .replace("pickup = [\"g\"]", "pickup = [\"水\"]"), ) .unwrap(); let wide_full = tactical_ribbon(&wide, 200); assert!(wide_full.contains("@you .floor #blocked ~water :gas")); let wide_line = tactical_ribbon(&wide, 80); assert!(wide_line.ends_with('…')); assert_eq!(terminal_line(&wide_line, 80).trim_end(), wide_line); assert!(wide_full.contains("水 pickup")); assert!(!wide_line.contains("pickup")); for group in [ "界 close · move N-up", "淵/森 Z", "Home center", "@you .floor #blocked", ] { assert!(wide_line.contains(group), "{wide_line}"); } let long_line = tactical_ribbon(&long, 80); assert!(long_line.ends_with('…')); for group in [ "Shift+o close · move N-up", "PageUp/PageDown Z", "Home center", ] { assert!(long_line.contains(group), "{long_line}"); } assert_eq!(terminal_line(&long_line, 80).trim_end(), long_line); let narrow = tactical_ribbon(&long, 30); assert_eq!(narrow, "Shift+o close · move N-up · …"); let legacy = parse_keymap( &DEFAULT_KEYMAP_TOML .lines() .filter(|l| !l.starts_with("pickup =") && !l.starts_with("drop =")) .collect::<Vec<_>>() .join("\n"), ) .unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-tactical-chrome-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let mut app = managed_application( &data, RuntimePack::load(root).unwrap(), Mode::Story, SignalController::default(), ); let mut output = JsonOutput::new(Vec::new()); app.input( Input::Map, None, &mut output, None, RecoveryLimits::default(), ) .unwrap(); let mut grid = TerminalGrid::filled(80, 24, '?'); let logs = RecentLogWindow { rows: vec!["feedback".into()], omitted_entries: 0, clipped: false, }; for input in [Input::SliceUp, Input::SliceDown] { app.input(input, None, &mut output, None, RecoveryLimits::default()) .unwrap(); let snapshot = app.presentation().unwrap(); for map in [&default, &remapped, &legacy, &long] { let ribbon = tactical_ribbon(map, 80); let mut world = FrameBuffer::new(80, 16); render_with_preferences( app.state().unwrap(), snapshot.view, &snapshot.tactical_map, &presentation_tuning(None, None, None), true, &mut world, ); let mut ansi = String::new(); encode_frame_full(&world, ColorMode::NoColor, &mut ansi); append_terminal_chrome(&mut ansi, 16, 80, &snapshot, &ribbon, &logs); grid.apply(&ansi); assert_eq!( grid.row(17).iter().collect::<String>(), format!("{ribbon:<80}") ); assert_eq!( grid.row(19).iter().collect::<String>().contains("you Z 8"), input == Input::SliceUp ); assert_eq!( grid.cells[..80 * 16].iter().filter(|&&c| c == '@').count(), usize::from(input == Input::SliceDown) ); assert!(grid .row(18) .iter() .collect::<String>() .starts_with(MELEE_WARNING)); assert_eq!(grid.row(23), vec![' '; 80]); } } // Actual chrome composition must retain the fitted wide-key groups, not clip // a terminal-cell overflow that a scalar count incorrectly admitted. let mut wide_ansi = String::new(); append_terminal_chrome( &mut wide_ansi, 16, 80, &app.presentation().unwrap(), &wide_line, &logs, ); let wide_row = emitted_row(&wide_ansi, 18); assert_eq!(wide_row.trim_end(), wide_line); assert_eq!(terminal_line(wide_row, 80), wide_row); assert!( wide_row.contains("界 close · move N-up") && wide_row.contains("淵/森 Z") && wide_row.contains("Home center") ); // Synthetic max-tick snapshots across the supported current catalog, not elapsed play. for depth in 0..=12 { let mut snapshot = app.presentation().unwrap(); snapshot.tick = u64::MAX; snapshot.location = if depth == 0 { hexwork_core::runtime::RunLocation::Town("milthollow".into()) } else { hexwork_core::runtime::RunLocation::Deep(depth) }; snapshot.tactical_map.player_cell[2] = if depth == 0 { 8 } else { 1 - 10 * i32::from(depth) }; snapshot.tactical_z = snapshot.tactical_map.player_cell[2] - 1; for paused in [false, true] { snapshot.wall_clock_paused = paused; let mut ansi = String::new(); append_terminal_chrome(&mut ansi, 16, 80, &snapshot, exact, &logs); grid.apply(&ansi); let row: String = grid.row(19).iter().collect(); assert!(row.starts_with(if depth == 0 { "Town " } else { "Deep " })); assert!(row.contains("t18446744073709551615"), "{row}"); assert!( row.contains(&format!("TACTICAL Z {}", snapshot.tactical_z)), "{row}" ); assert!( row.contains(&format!("you Z {}", snapshot.tactical_map.player_cell[2])), "{row}" ); assert!(row.contains(if paused { "PAUSE-STEP]" } else { "LIVE]" })); } } drop(app); let _ = std::fs::remove_dir_all(data); }
#[test] fn essential_chrome_grid_preserves_vitals_location_tail_and_loaded_quit() { use hexwork_core::runtime::RunLocation; let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let legacy = DEFAULT_KEYMAP_TOML .lines() .filter(|line| !line.starts_with("pickup =") && !line.starts_with("drop =")) .collect::<Vec<_>>() .join("\n"); let keymaps = [ parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(), remapped_keymap(), parse_keymap(&legacy).unwrap(), ]; for mode in [Mode::Story, Mode::Classic] { let data = PathBuf::from(format!( "/var/tmp/hexwork-essentials-{}-{mode:?}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let mut app = managed_application( &data, RuntimePack::load(&root).unwrap(), mode, SignalController::default(), ); let mut grid = TerminalGrid::filled(80, 24, '#'); let logs = RecentLogWindow { rows: vec!["feedback retained".into()], omitted_entries: 0, clipped: false, }; let mut output = JsonOutput::new(Vec::new()); for screen in [Screen::Play, Screen::Map] { if screen == Screen::Map { app.input( Input::Map, None, &mut output, None, RecoveryLimits::default(), ) .unwrap(); } let snapshot = app.presentation().unwrap(); for (index, keymap) in keymaps.iter().enumerate() { for melee in [ None, Some("Melee: confirm adjacent hostile".to_owned().into()), ] { let ribbon = keymap_context_ribbon(screen, keymap, melee, None); let mut frame = String::new(); append_terminal_chrome(&mut frame, 16, 80, &snapshot, &ribbon, &logs); grid.apply(&frame); let row = |r| grid.row(r).iter().collect::<String>(); for value in [&snapshot.hp.to_string(), &snapshot.light, &snapshot.burden] { assert!(row(16).contains(value), "{} missing {value}", row(16)); } let quit = if index == 1 { "0" } else { "Esc" }; let verb = if screen == Screen::Map { "close" } else { "quit" }; assert!(row(17).starts_with(&format!("{quit} {verb}"))); if index == 1 { assert!(!row(17).contains("Esc")); } if index == 2 { assert!(!row(17).contains("pickup")); } assert!(row(18).starts_with(MELEE_WARNING)); assert!(row(19).starts_with("Town ")); assert!(row(19).contains(&format!(" · t{}", snapshot.tick))); if screen == Screen::Map { assert!(row(19).contains(if mode == Mode::Story { "PAUSE-STEP]" } else { "LIVE]" })); } assert!(row(20).starts_with("Recent feedback")); assert!(row(21).starts_with("feedback retained")); assert_eq!(row(23), " ".repeat(80)); for r in 17..=24 { // This generated ASCII fixture and chrome use one-cell glyphs. assert_eq!(emitted_row(&frame, r).chars().count(), 80); } assert!(grid.row(15).iter().all(|c| *c == '#')); } } } let mut snapshot = app.presentation().unwrap(); snapshot.tick = u64::MAX; for z in [-12, 8] { snapshot.tactical_z = z; for location in [ RunLocation::Town("Long town identifier ".repeat(30)), RunLocation::Deep(255), ] { snapshot.location = location; let mut frame = String::new(); append_terminal_chrome(&mut frame, 16, 80, &snapshot, "Esc quit", &logs); grid.apply(&frame); let row: String = grid.row(19).iter().collect(); assert!(row.contains("t18446744073709551615"), "{row}"); assert!(row.contains(&format!("[TACTICAL Z {z} —")), "{row}"); assert!(row.contains(if mode == Mode::Story { "PAUSE-STEP]" } else { "LIVE]" })); match snapshot.location { RunLocation::Town(_) => { assert!(row.starts_with("Town ") && row.contains('…')) } _ => assert!(row.starts_with("Deep 255")), } } } app.input( Input::CameraToggle, None, &mut output, None, RecoveryLimits::default(), ) .unwrap(); let mut snapshot = app.presentation().unwrap(); snapshot.location = RunLocation::Town("A".into()); let mut frame = String::new(); append_terminal_chrome(&mut frame, 16, 80, &snapshot, "Esc quit", &logs); grid.apply(&frame); let row: String = grid.row(19).iter().collect(); assert!( row.starts_with("Town A ") && !row.contains("TACTICAL") && !row.contains("Long") ); assert_eq!(row, emitted_row(&frame, 20)); snapshot.location = RunLocation::Uninitialized; frame.clear(); append_terminal_chrome(&mut frame, 16, 80, &snapshot, "Esc quit", &logs); assert!(emitted_row(&frame, 20).starts_with("Location unavailable")); drop(app); let _ = std::fs::remove_dir_all(data); } }
#[test] fn terminal_recent_log_rows_fit_80x24_and_clear_stale_text() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-feedback-layout-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let app = managed_application(&data, pack, Mode::Story, SignalController::default()); let snapshot = app.presentation().unwrap(); let logs = RecentLogWindow { rows: vec!["wide 深淵 and control\u{1b}[31m".to_owned()], omitted_entries: 7, clipped: true, }; let mut frame = String::new(); let viewport_height = 24_usize - usize::from(TERMINAL_CHROME_ROWS); append_terminal_chrome( &mut frame, viewport_height, 80, &snapshot, "context \u{7} ribbon", &logs, );
assert_eq!(viewport_height, 16); assert!(emitted_row(&frame, 17).contains(&snapshot.light)); assert!(emitted_row(&frame, 18).starts_with("context ribbon")); assert!(emitted_row(&frame, 19).starts_with(MELEE_WARNING)); assert!(emitted_row(&frame, 20).starts_with("Town ")); assert!(emitted_row(&frame, 20).contains(&format!(" · t{}", snapshot.tick))); assert!(emitted_row(&frame, 21).starts_with("Recent feedback [7 older omitted; clipped]")); assert!(emitted_row(&frame, 22).starts_with("wide 深淵 and control [31m")); assert_eq!(emitted_row(&frame, 23), " ".repeat(80)); assert_eq!(emitted_row(&frame, 24), " ".repeat(80)); for row in 17..=24 { assert!(!emitted_row(&frame, row).chars().any(char::is_control)); } assert!(!frame.contains("search") && !frame.contains("scroll")); let _ = std::fs::remove_dir_all(data); }
#[test] fn action_inventory_and_lifecycle_feedback_reaches_emitted_chrome() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&root).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-feedback-app-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let mut app = managed_application(&data, pack, Mode::Story, SignalController::default()); let startup_logs = recent_log_window(app.logs(), 80, RECENT_LOG_ROWS); let startup_snapshot = app.presentation().unwrap(); let mut startup_frame = String::new(); append_terminal_chrome( &mut startup_frame, 16, 80, &startup_snapshot, "startup context", &startup_logs, ); assert!(startup_frame.contains("The managed lantern is lit."));
let mut output = JsonOutput::new(Vec::new()); for input in [Input::Wait, Input::Map, Input::Inventory] { app.input(input, None, &mut output, None, RecoveryLimits::default()) .unwrap(); } let before_hash = app.state().unwrap().try_hash_state().unwrap(); let before_tick = app.tick(); let before_history = observe_state(&mut app).event_count; let logs = recent_log_window(app.logs(), 80, RECENT_LOG_ROWS); let snapshot = app.presentation().unwrap(); let keymap = hexwork_term::remap::parse_keymap(hexwork_term::remap::DEFAULT_KEYMAP_TOML).unwrap(); let ribbon = keymap_context_ribbon(app.presentation_screen(), &keymap, None, None); let mut frame = String::new(); append_terminal_chrome(&mut frame, 16, 80, &snapshot, &ribbon, &logs);
assert!(frame.contains("you wait;"), "rendered chrome: {frame:?}"); assert!( frame.contains("inventory browser opened"), "rendered chrome: {frame:?}" ); assert!( frame.contains("[TACTICAL Z 8 — PAUSE-STEP]"), "rendered chrome: {frame:?}" ); assert!(!frame.contains(":true") && !frame.contains(":false")); assert_eq!(app.state().unwrap().try_hash_state().unwrap(), before_hash); assert_eq!(app.tick(), before_tick); assert_eq!(observe_state(&mut app).event_count, before_history); let _ = std::fs::remove_dir_all(data); }
#[test] fn wielded_inventory_marker_is_bounded_and_tracks_current_slot() { let root = PathBuf::from(format!( "/var/tmp/hexwork-wield-grid-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&root); let mut app = Application::new_with_output_checked( RuntimePack::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap(), &root, default_new_setup( 223, Mode::Classic, None, None, Some(hexwork_core::character::Calling::Ratcatcher), ), RecoveryLimits::default(), &mut JsonOutput::new(io::sink()), SignalController::default(), ) .unwrap(); let slot = app .state() .unwrap() .packs .values() .find_map(|pack| pack.items.values().position(|item| item.has_tag("polearm"))) .unwrap(); let limits = RecoveryLimits::default(); let mut sink = JsonOutput::new(io::sink()); dispatch_drained_batch( &mut app, [Input::Inventory, Input::ChargenPick(slot), Input::Confirm], limits, &mut sink, ) .unwrap(); let initial = observe_state(&mut app); let browser = app.inventory_browser_view(80, 16).unwrap().unwrap(); assert!(browser.content.can_wield_selected); assert!(!browser.content.selected_wielded); let long = DEFAULT_KEYMAP_TOML .replace("confirm = [\"Enter\"]", "confirm = [\"BackTab\"]") .replace("look = [\"x\"]", "look = [\"X\"]") .replace("inventory = [\"i\"]", "inventory = [\"I\"]") .replace("quit = [\"Esc\", \"q\"]", "quit = [\"Q\"]") .replace("up_left = [\"Q\", \"y\"]", "up_left = [\"y\"]"); let keymap = parse_keymap(&long).unwrap(); let mut bindings = inventory_browser_bindings(&keymap); bindings.wield = keymap.primary_binding_label(Action::Confirm); let label = bindings.wield.clone().unwrap(); let rows = render_browser(&browser.content, &bindings, &browser.layout); let ribbon = wield_detail_ribbon(&keymap, browser.content.can_wield_selected).unwrap(); let mut ansi = String::new(); append_opaque_modal(&mut ansi, &rows, 16, true); let logs = recent_log_window(app.logs(), 80, RECENT_LOG_ROWS); append_terminal_chrome( &mut ansi, 16, 80, &app.presentation().unwrap(), &ribbon, &logs, ); let mut grid = TerminalGrid::filled(80, 24, ' '); grid.apply(&ansi); let visible: String = grid.cells.iter().collect(); assert!(visible.contains(&format!("{label} Wield (opens confirmation)"))); assert!(!visible.contains("[wielded]")); assert!(MELEE_WARNING.chars().count() <= 80); assert!(MELEE_WARNING.contains("only within their own reach")); assert_eq!( transfer_prompt_bindings(&keymap, UITransferPromptKind::Wield).originating_action, None ); // One drained burst can request but cannot confirm or perform a second action. dispatch_drained_batch( &mut app, [Input::Confirm, Input::Confirm, Input::Wait], limits, &mut sink, ) .unwrap(); assert_eq!( app.transfer_prompt_kind(), Some(UITransferPromptKind::Wield) ); assert_eq!(observe_state(&mut app).event_count, initial.event_count); dispatch_drained_batch(&mut app, [Input::Confirm, Input::Wait], limits, &mut sink).unwrap(); assert_eq!(app.transfer_prompt_kind(), None); assert_eq!(observe_state(&mut app).event_count, initial.event_count + 1); let equipped = app.inventory_browser_view(80, 16).unwrap().unwrap(); assert!(equipped.content.selected_wielded); assert!(!equipped.content.can_wield_selected); let mut ansi = String::new(); append_opaque_modal( &mut ansi, &render_browser( &equipped.content, &inventory_browser_bindings(&keymap), &equipped.layout, ), 16, false, ); append_terminal_chrome( &mut ansi, 16, 80, &app.presentation().unwrap(), &keymap_context_ribbon(Screen::Look, &keymap, None, None), &logs, ); grid.apply(&ansi); let visible: String = grid.cells.iter().collect(); assert!(visible.contains("; wielded")); assert!(!visible.contains("Wield (opens confirmation)")); dispatch_drained_batch(&mut app, [Input::Quit], limits, &mut sink).unwrap(); let list = app.inventory_browser_view(80, 16).unwrap().unwrap(); assert_eq!(list.content.wielded_slot, Some(slot)); assert!(render_browser( &list.content, &inventory_browser_bindings(&keymap), &list.layout ) .iter() .any(|row| row.contains("[wielded]"))); dispatch_drained_batch(&mut app, [Input::Drop], limits, &mut sink).unwrap(); dispatch_drained_batch(&mut app, [Input::Confirm], limits, &mut sink).unwrap(); assert_eq!( app.inventory_browser_view(80, 16) .unwrap() .unwrap() .content .wielded_slot, None ); drop(app); std::fs::remove_dir_all(root).unwrap(); }
#[test] fn inventory_hints_follow_loaded_keymap_through_entry_helper() { let keymap = remapped_keymap(); let bindings = inventory_browser_bindings(&keymap); assert_eq!(bindings.inspect, "9"); assert_eq!(bindings.back, "r"); assert_eq!(bindings.close, "g"); assert_eq!(bindings.quit, "0"); assert_eq!(bindings.picks[0], "7"); assert_eq!(bindings.picks[1], "8"); assert_ne!(bindings.inspect, "Enter"); assert_ne!(bindings.picks[0], "1"); }
#[test] fn inventory_close_full_redraw_restores_every_unchanged_world_cell() { let mut world = FrameBuffer::new(8, 4); world.cells[0].glyph = '@'; world.cells[world.w * 3 + 7].glyph = '#'; let expected = world .cells .iter() .map(|cell| cell.glyph) .collect::<Vec<_>>(); let rows = vec!["Inventory".to_owned(), "MENU TEXT".to_owned()];
for mode in [ColorMode::Truecolor, ColorMode::Ansi256, ColorMode::NoColor] { let mut terminal = TerminalGrid::filled(world.w, world.h, '?'); let mut initial = String::new(); encode_frame_full(&world, mode, &mut initial); terminal.apply(&initial);
let mut modal = String::new(); append_opaque_modal(&mut modal, &rows, world.h, true); terminal.apply(&modal); assert!(terminal.cells.contains(&'M'));
let mut close = String::new(); encode_frame_full(&world, mode, &mut close); terminal.apply(&close); assert_eq!(terminal.cells, expected, "failed full redraw for {mode:?}"); assert!( terminal.cells[1..world.w * 3 + 7] .iter() .all(|cell| *cell == ' '), "default blank cells retained modal text for {mode:?}" ); } }
#[test] fn transfer_prompt_close_clears_composed_terminal_cells() { let prompt_content = TransferPromptContent { kind: UITransferPromptKind::Pickup, item: Tooltip { title: "Generated lamp oil".to_owned(), lines: vec!["ordinary loose item".to_owned()], }, original_cell: [17, 21, 8], has_before: false, has_after: true, clipped: false, }; let keymap = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); let prompt_layout = layout_transfer_prompt(&prompt_content, 20, 8); let prompt_rows = render_transfer_prompt( &prompt_content, &transfer_prompt_bindings(&keymap, UITransferPromptKind::Pickup), &prompt_layout, ); let browser_content = BrowserContent { tinker: None, mode: BrowserMode::List, detail_returns_to_list: true, total: 1, selected: Some(0), wielded_slot: None, selected_wielded: false, can_wield_selected: false, has_before: false, has_after: false, titles: vec!["Lampwright lantern".to_owned()], detail: None, detail_offset: 0, clipped: false, }; let browser_layout = layout_browser(&browser_content, 20, 8); let browser_rows = render_browser( &browser_content, &inventory_browser_bindings(&keymap), &browser_layout, ); let mut world = FrameBuffer::new(20, 8); world.cells[0].glyph = '@'; world.cells[world.w * world.h - 1].glyph = '#'; let expected = world .cells .iter() .map(|cell| cell.glyph) .collect::<Vec<_>>();
for mode in [ColorMode::Truecolor, ColorMode::Ansi256, ColorMode::NoColor] { let mut terminal = TerminalGrid::filled(world.w, world.h, '?'); let mut initial = String::new(); encode_frame_full(&world, mode, &mut initial); terminal.apply(&initial);
let mut prompt = String::new(); append_opaque_modal(&mut prompt, &prompt_rows, world.h, true); terminal.apply(&prompt); assert!(terminal.cells.contains(&'P'), "prompt absent for {mode:?}");
let mut browser = String::new(); append_opaque_modal(&mut browser, &browser_rows, world.h, false); terminal.apply(&browser); let composed = terminal.cells.iter().collect::<String>(); assert!( composed.contains("Inventory"), "browser absent for {mode:?}" ); assert!(!composed.contains("Pickup"), "prompt leaked for {mode:?}");
let mut close = String::new(); encode_frame_full(&world, mode, &mut close); terminal.apply(&close); assert_eq!( terminal.cells, expected, "failed prompt/browser close for {mode:?}" ); assert!(!terminal .cells .iter() .collect::<String>() .contains("Inventory")); } }
#[test] fn transfer_prompt_entry_clears_full_tall_physical_viewport() { const WIDTH: usize = 20; const VIEWPORT_HEIGHT: usize = 300; let content = TransferPromptContent { kind: UITransferPromptKind::Pickup, item: Tooltip { title: "Generated lamp oil".to_owned(), lines: Vec::new(), }, original_cell: [17, 21, 8], has_before: false, has_after: false, clipped: false, }; let layout = layout_transfer_prompt(&content, WIDTH, VIEWPORT_HEIGHT); let keymap = parse_keymap(DEFAULT_KEYMAP_TOML).unwrap(); let rows = render_transfer_prompt( &content, &transfer_prompt_bindings(&keymap, UITransferPromptKind::Pickup), &layout, ); assert_eq!(rows.len(), 256); let mut terminal = TerminalGrid::filled(WIDTH, VIEWPORT_HEIGHT, 'X'); let mut ansi = String::new(); append_opaque_modal(&mut ansi, &rows, VIEWPORT_HEIGHT, true); terminal.apply(&ansi); assert!(terminal.row(255).iter().any(|cell| *cell != ' ')); assert!(terminal.row(256).iter().all(|cell| *cell == ' ')); assert!(terminal .row(VIEWPORT_HEIGHT - 1) .iter() .all(|cell| *cell == ' ')); assert_eq!( ansi.matches("\x1b[2K").count(), VIEWPORT_HEIGHT + rows.len() ); }
#[test] fn inventory_entry_clears_full_capped_physical_viewport_and_preserves_chrome() { const WIDTH: usize = 20; const VIEWPORT_HEIGHT: usize = 300; let height = VIEWPORT_HEIGHT + usize::from(TERMINAL_CHROME_ROWS); let rows = vec!["Inventory [capped]".to_owned(); 256]; let mut terminal = TerminalGrid::filled(WIDTH, height, 'X'); let mut ansi = String::new(); append_opaque_modal(&mut ansi, &rows, VIEWPORT_HEIGHT, true); let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let pack = RuntimePack::load(&content).unwrap(); let data = PathBuf::from(format!( "/var/tmp/hexwork-entry-capped-viewport-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&data); let app = managed_application(&data, pack, Mode::Story, SignalController::default()); let presentation = app.presentation().unwrap(); let logs = RecentLogWindow { rows: Vec::new(), omitted_entries: 0, clipped: false, }; append_terminal_chrome( &mut ansi, VIEWPORT_HEIGHT, WIDTH, &presentation, "chrome ribbon", &logs, ); terminal.apply(&ansi);
assert!(terminal.row(255).iter().any(|cell| *cell != ' ')); assert!(terminal.row(256).iter().all(|cell| *cell == ' ')); assert!(terminal .row(VIEWPORT_HEIGHT - 1) .iter() .all(|cell| *cell == ' ')); assert_eq!(terminal.row(VIEWPORT_HEIGHT)[0], 'H'); assert!(terminal .row(VIEWPORT_HEIGHT + 1) .starts_with(&['c', 'h', 'r', 'o', 'm', 'e'])); assert_eq!( ansi.matches("\x1b[2K").count(), VIEWPORT_HEIGHT + rows.len() ); let _ = std::fs::remove_dir_all(data); }
#[test] fn interactive_chargen_only_requests_missing_new_run_choices() { let missing = Cli::parse([OsString::from("hexwork")]).unwrap(); assert!(needs_interactive_setup(&missing)); let explicit = Cli::parse([ OsString::from("hexwork"), OsString::from("--ancestry"), OsString::from("2"), OsString::from("--calling"), OsString::from("5"), ]) .unwrap(); assert!(!needs_interactive_setup(&explicit)); let load = Cli::parse([ OsString::from("hexwork"), OsString::from("--load"), OsString::from("00000000000000000000000000000001"), ]) .unwrap(); assert!(!needs_interactive_setup(&load)); }}