use 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( app: &mut Application, inputs: impl IntoIterator, limits: RecoveryLimits, output: &mut JsonOutput, ) -> Result { 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( app: &mut Application, command_index: Option, output: &mut JsonOutput, 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 { 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 { 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 for ProcessError { fn from(error: AppError) -> Self { Self::App(error) } } fn run_inner(args: impl IntoIterator) -> 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, 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( cli: &Cli, pack: RuntimePack, data_path: &Path, limits: RecoveryLimits, output: &mut JsonOutput, signals: SignalController, ) -> Result { 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::() .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 { 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 { 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, 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::>(); 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::>(); 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, ) -> Result { 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, 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 { 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 for ProcessError { fn from(error: hexwork_save::artifact::ArtifactError) -> Self { ProcessError::App(error.into()) } } impl From for ProcessError { fn from(error: hexwork_content::RuntimePackError) -> Self { ProcessError::App(AppError::content("content-load", error.to_string())) } } impl From 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, fov_deg: Option, palette: Option, ) -> 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, 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::().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, signals: SignalController, needle: &'static [u8], triggered: bool, } impl Write for SignalOnFlush<'_> { fn write(&mut self, bytes: &[u8]) -> io::Result { 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 { 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( app: &mut Application, inputs: impl IntoIterator, limits: RecoveryLimits, output: &mut JsonOutput, ) -> Result { 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 { 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::() .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::(line).unwrap()) .collect::>(); 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::(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::>() .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::>() .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::>(); 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::>(); 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::>(), default .cells .iter() .map(|cell| cell.glyph) .collect::>(), "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::>(), 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::>() .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::(), format!("{ribbon:<80}") ); assert_eq!( grid.row(19).iter().collect::().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::() .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::>() .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::(); 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::>(); 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::>(); 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::(); 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::() .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)); } }