From 5e6e99eada5b041a3dd71743cac14fc0fd207ef2 Mon Sep 17 00:00:00 2001 From: "david.yapfest.club" Date: Mon, 7 Sep 2026 16:41:35 -0400 Subject: [PATCH] feat: integrate confirmed live item transfers --- crates/hexwork-cli/src/app.rs | 797 +++++++++++++++++- crates/hexwork-cli/src/cli.rs | 12 +- crates/hexwork-cli/src/entry.rs | 670 ++++++++++++++- crates/hexwork-cli/src/frontend_support.rs | 4 + crates/hexwork-cli/src/session.rs | 26 +- .../hexwork-cli/tests/pty/transfers_live.py | 276 ++++++ .../hexwork-cli/tests/transfers_lifecycle.rs | 730 ++++++++++++++++ crates/hexwork-cli/tests/transfers_live.rs | 629 ++++++++++++++ docs/06-implementation-checklist.md | 5 +- docs/live-integration-plan.md | 2 +- docs/phase12-wiring-plan.md | 30 +- 11 files changed, 3105 insertions(+), 76 deletions(-) create mode 100644 crates/hexwork-cli/tests/pty/transfers_live.py create mode 100644 crates/hexwork-cli/tests/transfers_lifecycle.rs create mode 100644 crates/hexwork-cli/tests/transfers_live.rs diff --git a/crates/hexwork-cli/src/app.rs b/crates/hexwork-cli/src/app.rs index 2e370f5..d146c02 100644 --- a/crates/hexwork-cli/src/app.rs +++ b/crates/hexwork-cli/src/app.rs @@ -3,6 +3,7 @@ use std::io::Write; use std::path::Path; use hexwork_content::RuntimePack; +use hexwork_core::action::CommittedAction; use hexwork_core::character::{Ancestry, Calling}; use hexwork_core::modes::Mode; use hexwork_core::services::Service; @@ -15,6 +16,10 @@ use hexwork_save::artifact::{ use hexwork_save::{RunId, RunIdentity}; use hexwork_term::Input; use hexwork_ui::inventory::{layout_browser, BrowserContent, BrowserLayout, BrowserMode}; +use hexwork_ui::look::Tooltip; +use hexwork_ui::transfer::{ + layout_transfer_prompt, TransferPromptContent, TransferPromptKind, TransferPromptLayout, +}; use hexwork_ui::Screen; use crate::loop_::AutosaveCadence; @@ -26,14 +31,21 @@ use crate::presentation_settings::{ use crate::replay::GameSessionReplayVerifier; use crate::session::{ reconcile_dispatch, BurdenPresentation, CarriedLampPresentation, ControllerEvent, GameSession, - InventoryPage, InventoryPageQuery, PlayController, PlayerLiveness, SessionError, - SessionErrorClass, TickOutcome, + GroundTargetQuery, InventoryPage, InventoryPageQuery, PlayController, PlayerLiveness, + SessionError, SessionErrorClass, TickOutcome, }; use crate::signals::{interrupted, SignalController}; pub(crate) const MAX_PRESENTED_ENTRIES: usize = 64; pub(crate) const MAX_ACTIONS: u64 = 65_536; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum InputBarrier { + #[default] + None, + DiscardDrainedTerminalBatch, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ExitClass { Content = 3, @@ -54,6 +66,7 @@ pub(crate) struct AppError { pub code: String, pub detail: String, disposition: AppErrorDisposition, + input_barrier: InputBarrier, } impl AppError { @@ -77,22 +90,28 @@ impl AppError { self.disposition == AppErrorDisposition::RejectedInput } + pub(crate) fn input_barrier(&self) -> InputBarrier { + self.input_barrier + } + fn fatal(class: ExitClass, code: impl Into, detail: impl Into) -> Self { Self { class, code: code.into(), detail: detail.into(), disposition: AppErrorDisposition::Fatal, + input_barrier: InputBarrier::None, } } - fn rejected_input(error: SessionError) -> Self { + fn rejected_input(error: SessionError, input_barrier: InputBarrier) -> Self { debug_assert_eq!(error.class, SessionErrorClass::InputRejected); Self { class: ExitClass::Content, code: error.code.to_owned(), detail: error.detail, disposition: AppErrorDisposition::RejectedInput, + input_barrier, } } } @@ -207,6 +226,21 @@ pub(crate) struct InventoryBrowserSnapshot { pub layout: BrowserLayout, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TransferPromptSnapshot { + pub content: TransferPromptContent, + pub layout: TransferPromptLayout, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TransferPromptState { + kind: TransferPromptKind, + selected_id: u64, + original_cell: [i32; 3], + has_before: bool, + has_after: bool, +} + #[derive(Clone, Copy, Debug, PartialEq)] struct PresentationState { camera: Camera, @@ -214,6 +248,7 @@ struct PresentationState { tactical_z: Option, screen: Screen, inventory_browser: Option, + transfer_prompt: Option, } impl Default for PresentationState { @@ -224,6 +259,7 @@ impl Default for PresentationState { tactical_z: None, screen: Screen::Play, inventory_browser: None, + transfer_prompt: None, } } } @@ -280,6 +316,13 @@ enum TerminalLifecycle { pub(crate) struct DispatchResult { pub input: InputResult, pub lifecycle: LifecycleBoundary, + pub barrier: InputBarrier, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActiveModalResult { + NotActive, + Handled(InputBarrier), } pub(crate) struct Application { @@ -456,7 +499,18 @@ impl Application { } pub(crate) fn contextual_melee_hint(&self) -> Option { - self.session.contextual_melee_hint() + if self.presentation.transfer_prompt.is_some() + || self.presentation.inventory_browser.is_some() + || self.presentation.screen == Screen::Contracts + { + None + } else { + self.session.contextual_melee_hint() + } + } + + pub(crate) fn transfer_prompt_kind(&self) -> Option { + self.presentation.transfer_prompt.map(|prompt| prompt.kind) } pub(crate) fn presentation_screen(&self) -> Screen { @@ -533,6 +587,56 @@ impl Application { }) } + pub(crate) fn transfer_prompt_view( + &self, + viewport_width: usize, + viewport_height: usize, + ) -> Result, AppError> { + let Some(prompt) = self.presentation.transfer_prompt else { + return Ok(None); + }; + let current_cell = self.session.transfer_player_cell()?; + let position_changed = current_cell != prompt.original_cell; + let selected = if position_changed { + None + } else { + match prompt.kind { + TransferPromptKind::Pickup => self + .session + .ground_item(prompt.selected_id)? + .map(|(entry, _)| entry), + TransferPromptKind::Drop => self.session.inventory_item(prompt.selected_id)?, + } + }; + let (item, clipped) = selected.map_or_else( + || { + let reason = if position_changed { + "Player position changed; this transfer is no longer available." + } else { + "The selected item is no longer available." + }; + ( + Tooltip { + title: "Unavailable selection".to_owned(), + lines: vec![reason.to_owned(), "Cancel and choose again.".to_owned()], + }, + false, + ) + }, + |entry| (entry.tooltip, entry.tooltip_clipped), + ); + let content = TransferPromptContent { + kind: prompt.kind, + item, + original_cell: prompt.original_cell, + has_before: prompt.has_before, + has_after: prompt.has_after, + clipped, + }; + let layout = layout_transfer_prompt(&content, viewport_width, viewport_height); + Ok(Some(TransferPromptSnapshot { content, layout })) + } + pub(crate) fn inventory_browser_view( &mut self, viewport_width: usize, @@ -622,11 +726,28 @@ impl Application { Ok(()) } - fn handle_inventory_browser_input(&mut self, input: Input) -> Result<(), AppError> { + fn handle_inventory_browser_input(&mut self, input: Input) -> Result { let Some(mut browser) = self.presentation.inventory_browser else { - return Ok(()); + return Ok(InputBarrier::None); }; let page = self.refresh_inventory_page(&mut browser)?; + if input == Input::Drop { + let Some(selected_id) = browser.selected_id else { + self.push_log("No carried item is selected to drop.".into()); + return Ok(InputBarrier::DiscardDrainedTerminalBatch); + }; + let original_cell = self.session.transfer_player_cell()?; + self.presentation.inventory_browser = Some(browser); + self.presentation.transfer_prompt = Some(TransferPromptState { + kind: TransferPromptKind::Drop, + selected_id, + original_cell, + has_before: false, + has_after: false, + }); + self.push_log("drop confirmation opened".into()); + return Ok(InputBarrier::DiscardDrainedTerminalBatch); + } match browser.mode { BrowserMode::List => match input { Input::Up => { @@ -697,7 +818,7 @@ impl Application { } Input::Quit | Input::Inventory => { self.presentation.inventory_browser = None; - return Ok(()); + return Ok(InputBarrier::None); } _ => {} }, @@ -720,12 +841,12 @@ impl Application { browser.detail_offset = 0; } else { self.presentation.inventory_browser = None; - return Ok(()); + return Ok(InputBarrier::None); } } Input::Inventory => { self.presentation.inventory_browser = None; - return Ok(()); + return Ok(InputBarrier::None); } _ => {} }, @@ -733,7 +854,147 @@ impl Application { self.presentation.inventory_browser = Some(browser); let _ = self.inventory_browser_view(browser.last_viewport_width, browser.last_viewport_height)?; - Ok(()) + Ok(InputBarrier::None) + } + + fn open_pickup_prompt(&mut self) -> Result { + let Some(target) = self.session.ground_target(GroundTargetQuery::First)? else { + self.push_log("There is nothing here to pick up.".into()); + return Ok(InputBarrier::DiscardDrainedTerminalBatch); + }; + self.presentation.transfer_prompt = Some(TransferPromptState { + kind: TransferPromptKind::Pickup, + selected_id: target.entry.instance_id, + original_cell: target.cell, + has_before: target.has_before, + has_after: target.has_after, + }); + self.push_log("pickup confirmation opened".into()); + Ok(InputBarrier::DiscardDrainedTerminalBatch) + } + + fn cancel_transfer_prompt(&mut self, feedback: Option<&str>) -> InputBarrier { + self.presentation.transfer_prompt = None; + if let Some(feedback) = feedback { + self.push_log(feedback.to_owned()); + } + InputBarrier::DiscardDrainedTerminalBatch + } + + fn handle_transfer_prompt_input(&mut self, input: Input) -> Result { + let Some(mut prompt) = self.presentation.transfer_prompt else { + return Ok(InputBarrier::None); + }; + if matches!(input, Input::Quit) + || matches!( + (prompt.kind, input), + (TransferPromptKind::Pickup, Input::Pickup) + | (TransferPromptKind::Drop, Input::Drop) + ) + { + return Ok(self.cancel_transfer_prompt(Some("transfer cancelled"))); + } + let current_cell = self.session.transfer_player_cell()?; + if current_cell != prompt.original_cell { + return Ok( + self.cancel_transfer_prompt(Some("transfer cancelled: player position changed")) + ); + } + match input { + Input::Up | Input::Down if prompt.kind == TransferPromptKind::Pickup => { + let query = if input == Input::Up { + GroundTargetQuery::Before(prompt.selected_id) + } else { + GroundTargetQuery::After(prompt.selected_id) + }; + if let Some(target) = self.session.ground_target(query)? { + if target.cell != prompt.original_cell { + return Ok(self.cancel_transfer_prompt(Some( + "transfer cancelled: player position changed", + ))); + } + prompt.selected_id = target.entry.instance_id; + prompt.has_before = target.has_before; + prompt.has_after = target.has_after; + self.presentation.transfer_prompt = Some(prompt); + } + Ok(InputBarrier::None) + } + Input::Confirm => { + let selected_exists = match prompt.kind { + TransferPromptKind::Pickup => { + self.session.ground_item(prompt.selected_id)?.is_some() + } + TransferPromptKind::Drop => { + self.session.inventory_item(prompt.selected_id)?.is_some() + } + }; + if !selected_exists { + return Ok(self.cancel_transfer_prompt(Some( + "transfer cancelled: selected item is no longer available", + ))); + } + self.presentation.transfer_prompt = None; + let action = match prompt.kind { + TransferPromptKind::Pickup => CommittedAction::Pickup { + item_id: prompt.selected_id, + }, + TransferPromptKind::Drop => CommittedAction::Drop { + item_id: prompt.selected_id, + }, + }; + self.dispatch_precharged_action(action, None) + .map(|_| InputBarrier::DiscardDrainedTerminalBatch) + .map_err(|error| { + if error.is_input_rejection() { + AppError { + input_barrier: InputBarrier::DiscardDrainedTerminalBatch, + ..error + } + } else { + error + } + }) + } + _ => Ok(InputBarrier::None), + } + } + + fn route_active_modal(&mut self, input: Input) -> Result { + if self.presentation.transfer_prompt.is_some() { + return self + .handle_transfer_prompt_input(input) + .map(ActiveModalResult::Handled); + } + if self.presentation.inventory_browser.is_some() { + return self + .handle_inventory_browser_input(input) + .map(ActiveModalResult::Handled); + } + Ok(ActiveModalResult::NotActive) + } + + fn dispatch_precharged_action( + &mut self, + action: CommittedAction, + source: Option<&crate::session::DispatchSource>, + ) -> Result { + let dispatch = self.session.dispatch(action); + let presentation = source + .map(|source| reconcile_dispatch(&mut self.controller, source, &dispatch)) + .unwrap_or_default(); + let outcome = match dispatch { + Ok(outcome) => outcome, + Err(error) if error.class == SessionErrorClass::InputRejected => { + self.push_log(format!("{}: {}", error.code, error.detail)); + return Err(AppError::rejected_input(error, InputBarrier::None)); + } + Err(error) => return Err(error.into()), + }; + self.dirty = true; + self.cadence.advance(outcome.elapsed_ticks()); + self.push_log(outcome.log_text()); + Ok(presentation) } pub(crate) fn input( @@ -763,16 +1024,36 @@ impl Application { ) -> Result { self.check_signal_boundary(command_index, output, recovery_limits)?; self.ensure_playable()?; - if self.presentation.inventory_browser.is_some() { + if self.session.liveness()? == PlayerLiveness::Dead { + let lifecycle = self.finish_boundary(command_index, output, recovery_limits)?; + self.finish_signal_after_boundary(lifecycle)?; + return Ok(DispatchResult { + input: InputResult::Continue, + lifecycle, + barrier: InputBarrier::None, + }); + } + if self.presentation.transfer_prompt.is_some() + || self.presentation.inventory_browser.is_some() + { if let Some(work) = work { work.charge_action()?; } - self.handle_inventory_browser_input(input)?; + let (barrier, rejected) = match self.route_active_modal(input) { + Ok(ActiveModalResult::Handled(barrier)) => (barrier, None), + Ok(ActiveModalResult::NotActive) => unreachable!("modal presence checked above"), + Err(error) if error.is_input_rejection() => (error.input_barrier(), Some(error)), + Err(error) => return Err(error), + }; let lifecycle = self.finish_boundary(command_index, output, recovery_limits)?; self.finish_signal_after_boundary(lifecycle)?; + if let Some(error) = rejected { + return Err(error); + } return Ok(DispatchResult { input: InputResult::Continue, lifecycle, + barrier, }); } if self @@ -791,10 +1072,12 @@ impl Application { return Ok(DispatchResult { input: InputResult::Continue, lifecycle, + barrier: InputBarrier::None, }); } let event = self.controller.adapt(input, &self.session); let mut result = InputResult::Continue; + let mut barrier = InputBarrier::None; match event { ControllerEvent::Dispatch { action, source } => { let elapsed = self.session.action_elapsed_ticks(&action); @@ -807,19 +1090,7 @@ impl Application { _ => work.charge_action()?, } } - let dispatch = self.session.dispatch(action); - let presentation = reconcile_dispatch(&mut self.controller, &source, &dispatch); - let outcome = match dispatch { - Ok(outcome) => outcome, - Err(error) if error.class == SessionErrorClass::InputRejected => { - self.push_log(format!("{}: {}", error.code, error.detail)); - return Err(AppError::rejected_input(error)); - } - Err(error) => return Err(error.into()), - }; - self.dirty = true; - self.cadence.advance(outcome.elapsed_ticks()); - self.push_log(outcome.log_text()); + let presentation = self.dispatch_precharged_action(action, Some(&source))?; if presentation.close_board { self.presentation.screen = Screen::Play; self.push_log("contracts closed".into()); @@ -850,6 +1121,13 @@ impl Application { ControllerEvent::Control(Input::Look) => { self.open_inventory_browser(InventoryBrowserOrigin::DirectLook)?; } + ControllerEvent::Control(Input::Pickup) => { + if let Some(work) = work { + work.charge_action()?; + } + barrier = self.open_pickup_prompt()?; + } + ControllerEvent::Control(Input::Drop) => {} ControllerEvent::Control(Input::Chapel) => { self.presentation.screen = Screen::Chapel; self.push_log("chapel services are contextual at the town anchor".into()); @@ -874,6 +1152,7 @@ impl Application { Ok(DispatchResult { input: result, lifecycle, + barrier, }) } @@ -965,6 +1244,8 @@ impl Application { } self.last_canonical = Some(canonical); recover_unchecked(&mut self.store, &self.pack, recovery_limits, output)?; + self.presentation.transfer_prompt = None; + self.presentation.inventory_browser = None; self.terminal_lifecycle = TerminalLifecycle::Completed; self.push_log("The lineage remembers the fallen delver.".into()); LifecycleBoundary::Terminal @@ -1143,6 +1424,19 @@ impl Application { .map_err(|detail| AppError::io("output", detail)) } + #[cfg(test)] + pub(crate) fn curse_selected_inventory_item_for_test(&mut self) -> Result { + let selected_id = self + .presentation + .inventory_browser + .and_then(|browser| browser.selected_id) + .ok_or_else(|| { + AppError::content("test-selection-missing", "no carried item selected") + })?; + self.session.curse_carried_item_for_test(selected_id)?; + Ok(selected_id) + } + #[cfg(test)] pub(crate) fn dispatch_contextual_action_while_browser_open_for_test( &mut self, @@ -1177,6 +1471,7 @@ impl Application { if !self.signals.is_pending() { return Ok(false); } + self.presentation.transfer_prompt = None; if self.terminal_lifecycle == TerminalLifecycle::Completed { return Err(interrupted("after terminal recovery boundary")); } @@ -1581,6 +1876,46 @@ mod tests { } } + fn transfer_input( + app: &mut Application, + input: Input, + work: Option<&mut WorkBudget>, + ) -> Result { + app.input_with_boundary( + input, + None, + &mut JsonOutput::new(Vec::new()), + work, + RecoveryLimits::default(), + ) + } + + fn selected_inventory_id(app: &mut Application) -> u64 { + let snapshot = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert!(snapshot.content.selected.is_some()); + app.presentation + .inventory_browser + .and_then(|browser| browser.selected_id) + .expect("selected carried item") + } + + /// Creates a same-cell two-item ground pile only through ordinary Application + /// browser and confirmed Drop inputs. + fn create_two_item_ground_pile(app: &mut Application) -> [u64; 2] { + transfer_input(app, Input::Inventory, None).unwrap(); + let first = selected_inventory_id(app); + transfer_input(app, Input::Drop, None).unwrap(); + transfer_input(app, Input::Confirm, None).unwrap(); + let second = selected_inventory_id(app); + assert_ne!(first, second); + transfer_input(app, Input::Drop, None).unwrap(); + transfer_input(app, Input::Confirm, None).unwrap(); + transfer_input(app, Input::Inventory, None).unwrap(); + let mut ids = [first, second]; + ids.sort_unstable(); + ids + } + #[test] fn startup_pending_refuses_before_root_allocation_or_load() { let root = Scratch::new("startup-signal"); @@ -1651,6 +1986,9 @@ mod tests { assert!(!content.is_input_rejection()); assert!(!runtime.is_input_rejection()); assert!(!rejected.is_input_rejection()); + assert_eq!(content.input_barrier(), InputBarrier::None); + assert_eq!(runtime.input_barrier(), InputBarrier::None); + assert_eq!(rejected.input_barrier(), InputBarrier::None); let nested = SessionError::runtime("inner-runtime", "fault") .with_context("outer-context", "wrapped fault"); @@ -1937,11 +2275,12 @@ mod tests { assert_eq!(app.presentation_screen(), Screen::Contracts); assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); - for input in [Input::Inventory, Input::Look] { + for input in [Input::Inventory, Input::Look, Input::Pickup, Input::Drop] { app.input_with_boundary(input, None, &mut output, None, RecoveryLimits::default()) .unwrap(); assert_eq!(app.presentation_screen(), Screen::Contracts); assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); + assert_eq!(app.transfer_prompt_kind(), None); } } @@ -2213,6 +2552,412 @@ mod tests { assert!(!classic.presentation().unwrap().wall_clock_paused); } + #[test] + fn transfer_prompt_inputs_are_observational_and_single_charged() { + let (_root, mut app) = managed_app("transfer-prompt-purity", SignalController::default()); + transfer_input(&mut app, Input::Inventory, None).unwrap(); + let selected_id = selected_inventory_id(&mut app); + let before_hash = app.session.canonical_hash().unwrap(); + let before_tick = app.tick(); + let before_history = app.session.events().len(); + let before_screen = app.presentation_screen(); + let mut work = WorkBudget::new(None); + let remaining = work.actions_remaining; + + let opened = transfer_input(&mut app, Input::Drop, Some(&mut work)).unwrap(); + assert_eq!(opened.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(app.transfer_prompt_kind(), Some(TransferPromptKind::Drop)); + let view = app.transfer_prompt_view(80, 16).unwrap().unwrap(); + assert_eq!(view.content.kind, TransferPromptKind::Drop); + assert_ne!(view.content.item.title, "Unavailable selection"); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + selected_id + ); + assert_eq!(app.presentation_screen(), before_screen); + assert_eq!(app.contextual_melee_hint(), None); + + for input in [Input::Up, Input::PauseStep, Input::Map, Input::Pickup] { + let result = transfer_input(&mut app, input, Some(&mut work)).unwrap(); + assert_eq!(result.barrier, InputBarrier::None); + assert_eq!(app.transfer_prompt_kind(), Some(TransferPromptKind::Drop)); + } + assert_eq!(work.actions_remaining, remaining - 5); + assert_eq!(app.session.canonical_hash().unwrap(), before_hash); + assert_eq!(app.tick(), before_tick); + assert_eq!(app.session.events().len(), before_history); + + let cancelled = transfer_input(&mut app, Input::Drop, Some(&mut work)).unwrap(); + assert_eq!(cancelled.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(app.transfer_prompt_kind(), None); + assert_eq!(app.presentation_screen(), before_screen); + assert_eq!(work.actions_remaining, remaining - 6); + } + + #[test] + fn transfer_empty_open_and_ordinary_controls_are_observational() { + let (_root, mut app) = managed_app("transfer-empty", SignalController::default()); + let before_hash = app.session.canonical_hash().unwrap(); + let before_tick = app.tick(); + let before_history = app.session.events().len(); + let mut work = WorkBudget::new(None); + let remaining = work.actions_remaining; + + let empty_pickup = transfer_input(&mut app, Input::Pickup, Some(&mut work)).unwrap(); + assert_eq!( + empty_pickup.barrier, + InputBarrier::DiscardDrainedTerminalBatch + ); + assert_eq!(work.actions_remaining, remaining - 1); + assert_eq!(app.transfer_prompt_kind(), None); + assert!(app.logs().last().unwrap().contains("nothing here")); + + let ordinary_drop = transfer_input(&mut app, Input::Drop, Some(&mut work)).unwrap(); + assert_eq!(ordinary_drop.barrier, InputBarrier::None); + assert_eq!(work.actions_remaining, remaining - 1); + assert_eq!(app.transfer_prompt_kind(), None); + assert_eq!(app.session.canonical_hash().unwrap(), before_hash); + assert_eq!(app.tick(), before_tick); + assert_eq!(app.session.events().len(), before_history); + + app.session.retain_player_inventory_for_test(&[]).unwrap(); + transfer_input(&mut app, Input::Inventory, None).unwrap(); + let empty_drop = transfer_input(&mut app, Input::Drop, Some(&mut work)).unwrap(); + assert_eq!( + empty_drop.barrier, + InputBarrier::DiscardDrainedTerminalBatch + ); + assert_eq!(work.actions_remaining, remaining - 2); + assert_eq!(app.transfer_prompt_kind(), None); + assert!(app.logs().last().unwrap().contains("No carried item")); + } + + #[test] + fn transfer_confirmation_dispatches_exactly_one_event_and_refreshes_browser() { + let (_root, mut app) = managed_app("transfer-dispatch", SignalController::default()); + transfer_input(&mut app, Input::Inventory, None).unwrap(); + let item_id = selected_inventory_id(&mut app); + let before_history = app.session.events().len(); + let before_tick = app.tick(); + let mut work = WorkBudget::new(None); + let remaining = work.actions_remaining; + + transfer_input(&mut app, Input::Drop, Some(&mut work)).unwrap(); + let dropped = transfer_input(&mut app, Input::Confirm, Some(&mut work)).unwrap(); + assert_eq!(dropped.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(app.transfer_prompt_kind(), None); + assert_eq!(app.presentation_screen(), Screen::Inventory); + assert_eq!(app.session.events().len(), before_history + 1); + assert!(matches!( + app.session.events().last().unwrap().kind, + hexwork_save::SessionEventKind::Action(CommittedAction::Drop { item_id: actual }) + if actual == item_id + )); + assert_eq!(app.tick(), before_tick); + assert_eq!(work.actions_remaining, remaining - 2); + let refreshed = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert!(app.session.inventory_item(item_id).unwrap().is_none()); + let surviving_ids = app + .session + .inventory_page(InventoryPageQuery::First) + .unwrap() + .entries + .into_iter() + .map(|entry| entry.instance_id) + .collect::>(); + if let Some(first_survivor) = surviving_ids.first() { + assert_eq!(browser_cursor(&app), (Some(*first_survivor), Some(0), 0)); + assert_eq!(refreshed.content.selected, Some(0)); + } else { + assert_eq!(browser_cursor(&app), (None, None, 0)); + assert_eq!(refreshed.content.selected, None); + } + + transfer_input(&mut app, Input::Inventory, None).unwrap(); + let opened = transfer_input(&mut app, Input::Pickup, Some(&mut work)).unwrap(); + assert_eq!(opened.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(app.transfer_prompt_kind(), Some(TransferPromptKind::Pickup)); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + item_id + ); + let picked = transfer_input(&mut app, Input::Confirm, Some(&mut work)).unwrap(); + assert_eq!(picked.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(app.presentation_screen(), Screen::Play); + assert_eq!(app.session.events().len(), before_history + 2); + assert!(matches!( + app.session.events().last().unwrap().kind, + hexwork_save::SessionEventKind::Action(CommittedAction::Pickup { item_id: actual }) + if actual == item_id + )); + assert_eq!(app.tick(), before_tick); + assert_eq!(work.actions_remaining, remaining - 4); + } + + #[test] + fn transfer_pickup_navigation_is_stable_nonwrapping_and_confirms_chosen_id_only() { + let (_root, mut app) = managed_app("transfer-navigation", SignalController::default()); + let ids = create_two_item_ground_pile(&mut app); + let before_pickup_history = app.session.events().len(); + transfer_input(&mut app, Input::Pickup, None).unwrap(); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + ids[0] + ); + assert!(!app.presentation.transfer_prompt.unwrap().has_before); + assert!(app.presentation.transfer_prompt.unwrap().has_after); + + transfer_input(&mut app, Input::Up, None).unwrap(); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + ids[0] + ); + transfer_input(&mut app, Input::Down, None).unwrap(); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + ids[1] + ); + assert!(app.presentation.transfer_prompt.unwrap().has_before); + assert!(!app.presentation.transfer_prompt.unwrap().has_after); + transfer_input(&mut app, Input::Down, None).unwrap(); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + ids[1] + ); + transfer_input(&mut app, Input::Up, None).unwrap(); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + ids[0] + ); + transfer_input(&mut app, Input::Down, None).unwrap(); + + transfer_input(&mut app, Input::Confirm, None).unwrap(); + assert_eq!(app.session.events().len(), before_pickup_history + 1); + assert!(matches!( + app.session.events().last().unwrap().kind, + hexwork_save::SessionEventKind::Action(CommittedAction::Pickup { item_id }) + if item_id == ids[1] + )); + assert!(app.session.inventory_item(ids[1]).unwrap().is_some()); + assert!(app.session.ground_item(ids[0]).unwrap().is_some()); + } + + #[test] + fn transfer_confirmation_never_retargets_after_real_underlying_source_change() { + let (_root, mut app) = managed_app("transfer-stale", SignalController::default()); + let ids = create_two_item_ground_pile(&mut app); + transfer_input(&mut app, Input::Pickup, None).unwrap(); + let selected_id = app.presentation.transfer_prompt.unwrap().selected_id; + assert_eq!(selected_id, ids[0]); + let before_external_history = app.session.events().len(); + + // Unit-level asynchronous-state simulation: a genuine recorded Session + // pickup changes the source while the Application prompt remains open. + app.session + .dispatch(CommittedAction::Pickup { + item_id: selected_id, + }) + .unwrap(); + let after_external_history = app.session.events().len(); + assert_eq!(after_external_history, before_external_history + 1); + let after_external_hash = app.session.canonical_hash().unwrap(); + + let unavailable = app.transfer_prompt_view(80, 16).unwrap().unwrap(); + assert_eq!(unavailable.content.item.title, "Unavailable selection"); + let mut work = WorkBudget::new(None); + let remaining = work.actions_remaining; + let cancelled = transfer_input(&mut app, Input::Confirm, Some(&mut work)).unwrap(); + assert_eq!(cancelled.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(work.actions_remaining, remaining - 1); + assert_eq!(app.transfer_prompt_kind(), None); + assert_eq!(app.session.events().len(), after_external_history); + assert_eq!(app.session.canonical_hash().unwrap(), after_external_hash); + assert!(app.session.ground_item(ids[1]).unwrap().is_some()); + assert!(app + .logs() + .last() + .unwrap() + .contains("selected item is no longer available")); + } + + #[test] + fn transfer_prompt_position_change_cancels_navigation_and_confirmation_without_retarget() { + let (_root, mut app) = managed_app("transfer-position-change", SignalController::default()); + let ids = create_two_item_ground_pile(&mut app); + transfer_input(&mut app, Input::Pickup, None).unwrap(); + let selected_id = app.presentation.transfer_prompt.unwrap().selected_id; + + // Unit-level underlying-state simulation: find one genuine legal recorded + // movement through Session dispatch; no position field is injected. + let moved = [ + hexwork_core::action::Direction::West, + hexwork_core::action::Direction::East, + hexwork_core::action::Direction::North, + hexwork_core::action::Direction::South, + hexwork_core::action::Direction::NorthWest, + hexwork_core::action::Direction::NorthEast, + hexwork_core::action::Direction::SouthWest, + hexwork_core::action::Direction::SouthEast, + ] + .into_iter() + .find(|direction| { + app.session + .dispatch(CommittedAction::Move(direction.clone())) + .is_ok() + }); + assert!( + moved.is_some(), + "generated start has no legal adjacent move" + ); + let after_move_history = app.session.events().len(); + let after_move_hash = app.session.canonical_hash().unwrap(); + assert!(matches!( + app.session.events().last().unwrap().kind, + hexwork_save::SessionEventKind::Action(CommittedAction::Move(_)) + )); + assert_eq!( + app.presentation.transfer_prompt.unwrap().selected_id, + selected_id + ); + + let unavailable = app.transfer_prompt_view(80, 16).unwrap().unwrap(); + assert!(unavailable.content.item.lines[0].contains("position changed")); + let mut work = WorkBudget::new(None); + let remaining = work.actions_remaining; + let cancelled = transfer_input(&mut app, Input::Down, Some(&mut work)).unwrap(); + assert_eq!(cancelled.barrier, InputBarrier::DiscardDrainedTerminalBatch); + assert_eq!(work.actions_remaining, remaining - 1); + assert_eq!(app.transfer_prompt_kind(), None); + assert_eq!(app.session.events().len(), after_move_history); + assert_eq!(app.session.canonical_hash().unwrap(), after_move_hash); + assert!(ids.contains(&selected_id)); + } + + #[test] + fn transfer_rejection_barrier_is_direct_and_generic_errors_remain_fatal() { + let (_root, mut app) = managed_app("transfer-rejection", SignalController::default()); + transfer_input(&mut app, Input::Inventory, None).unwrap(); + let item_id = app.curse_selected_inventory_item_for_test().unwrap(); + let before_hash = app.session.canonical_hash().unwrap(); + let before_history = app.session.events().len(); + transfer_input(&mut app, Input::Drop, None).unwrap(); + let error = transfer_input(&mut app, Input::Confirm, None).unwrap_err(); + assert!(error.is_input_rejection()); + assert_eq!(error.code, "drop-item-stuck"); + assert_eq!( + error.input_barrier(), + InputBarrier::DiscardDrainedTerminalBatch + ); + assert_eq!(app.transfer_prompt_kind(), None); + assert_eq!(app.presentation_screen(), Screen::Inventory); + assert_eq!(app.session.canonical_hash().unwrap(), before_hash); + assert_eq!(app.session.events().len(), before_history); + assert!(app.session.inventory_item(item_id).unwrap().is_some()); + + for error in [ + AppError::content("fatal-content", "x"), + AppError::from(SessionError::rejected("generic-rejected", "x")), + AppError::from(SessionError::runtime("fatal-runtime", "x")), + ] { + assert!(!error.is_input_rejection()); + assert_eq!(error.input_barrier(), InputBarrier::None); + } + } + + #[test] + fn transfer_followed_by_save_failure_remains_committed_and_dirty() { + let (root, mut app) = managed_app("transfer-save-failure", SignalController::default()); + transfer_input(&mut app, Input::Inventory, None).unwrap(); + let item_id = selected_inventory_id(&mut app); + transfer_input(&mut app, Input::Drop, None).unwrap(); + transfer_input(&mut app, Input::Confirm, None).unwrap(); + assert!(app.session.inventory_item(item_id).unwrap().is_none()); + assert!(app.session.ground_item(item_id).unwrap().is_some()); + assert!(app.dirty); + + let committed_hash = app.session.canonical_hash().unwrap(); + let committed_history = app.session.events().to_vec(); + let run_save = root + .0 + .join(RUNS_DIRECTORY_NAME) + .join(app.run_id().to_string()) + .join(hexwork_save::artifact::RUN_SAVE_FILE_NAME); + let durable_before = fs::read(&run_save).unwrap(); + fs::remove_file(&run_save).unwrap(); + fs::create_dir(&run_save).unwrap(); + + let error = app + .save(None, &mut JsonOutput::new(Vec::new())) + .unwrap_err(); + assert!(!error.is_input_rejection()); + assert_eq!(error.input_barrier(), InputBarrier::None); + assert_eq!(error.code, "managed-type-conflict"); + assert!(app.dirty); + assert_eq!(app.session.canonical_hash().unwrap(), committed_hash); + assert_eq!(app.session.events(), committed_history); + assert!(app.session.inventory_item(item_id).unwrap().is_none()); + assert!(app.session.ground_item(item_id).unwrap().is_some()); + + fs::remove_dir(&run_save).unwrap(); + fs::write(&run_save, durable_before).unwrap(); + app.save(None, &mut JsonOutput::new(Vec::new())).unwrap(); + assert!(!app.dirty); + assert!(run_save.is_file()); + assert_eq!(app.session.canonical_hash().unwrap(), committed_hash); + assert_eq!(app.session.events(), committed_history); + assert!(app.session.inventory_item(item_id).unwrap().is_none()); + assert!(app.session.ground_item(item_id).unwrap().is_some()); + } + + #[test] + fn transfer_prompt_preserves_clock_signal_and_fresh_load_policy() { + let signals = SignalController::default(); + let (_root, mut story) = managed_app("transfer-lifecycle", signals.clone()); + transfer_input(&mut story, Input::Inventory, None).unwrap(); + transfer_input(&mut story, Input::Drop, None).unwrap(); + assert!(story.wall_clock_ticks_enabled()); + signals.trigger(); + let error = story + .finish_signal_if_pending( + None, + &mut JsonOutput::new(Vec::new()), + RecoveryLimits::default(), + ) + .unwrap_err(); + assert_eq!(error.code, "signal-received"); + assert_eq!(story.transfer_prompt_kind(), None); + + let (load_root, mut original) = + managed_app("transfer-fresh-load", SignalController::default()); + transfer_input(&mut original, Input::Inventory, None).unwrap(); + transfer_input(&mut original, Input::Drop, None).unwrap(); + assert_eq!( + original.transfer_prompt_kind(), + Some(TransferPromptKind::Drop) + ); + assert!(original.inventory_browser_view(80, 16).unwrap().is_some()); + original + .save(None, &mut JsonOutput::new(Vec::new())) + .unwrap(); + let run_id = original.run_id(); + let pack = original.pack.clone(); + drop(original); + + let mut loaded = Application::load_with_output_checked( + pack, + &load_root.0, + run_id, + LoadConstraints::default(), + RecoveryLimits::default(), + &mut JsonOutput::new(Vec::new()), + SignalController::default(), + ) + .unwrap(); + assert_eq!(loaded.transfer_prompt_kind(), None); + assert!(loaded.inventory_browser_view(80, 16).unwrap().is_none()); + assert_eq!(loaded.presentation_screen(), Screen::Play); + } + #[test] fn presentation_views_follow_current_player_without_mutating_model() { let mut presentation = PresentationState::default(); diff --git a/crates/hexwork-cli/src/cli.rs b/crates/hexwork-cli/src/cli.rs index afc5010..eceb869 100644 --- a/crates/hexwork-cli/src/cli.rs +++ b/crates/hexwork-cli/src/cli.rs @@ -343,7 +343,7 @@ fn parse_daily(value: &str) -> Result<(u32, u32, u32), String> { Ok((year, month, day)) } -pub(crate) const HELP: &str = "HEXWORK v0.1.0 - a lantern in the deep\n\nUSAGE: hexwork [OPTIONS]\n\nOPERATIONS:\n --help Show this help\n --arena Read-only deterministic arena proof\n --replay FILE Verify a bounded v4 production replay\n --recover Finish pending managed recovery only\n\nPLAY/ROOTS:\n --data-dir PATH Managed lineage root (default: platform user data)\n --content-root PATH Exact content root (then HEXWORK_CONTENT_ROOT/assets/dev)\n --load RUN_ID Resume one managed run\n --headless-script FILE Run bounded HEXWORK-COMMANDS 1 input\n\nPRESENTATION (INTERACTIVE PLAY ONLY):\n --gamma 0.5..2 Post-shade gamma\n --fov 1..75 Perspective field of view in degrees\n --palette NAME default, protan, deutan, tritan, high-contrast\n\nCONSTRAINTS/SETUP:\n --seed N New seed, or load/replay seed constraint\n --daily YYYY-MM-DD Ironcartographer daily mode/seed constraint\n --story Story mode constraint\n --name NAME New-run display name\n --ancestry 1..4 Mudborn, Cant-born, Pale-touched, Foundling\n --calling 1..6 Ratcatcher, Lampbearer, Tinker, Corpse-tender, Toll-ward, Choir-deserter\n --trusted-work-limit N Trusted replay/recovery full/unattended/elapsed ceiling\n --export-ansi FILE Durable bounded replay event-text ANSI export\n\nKEYS: arrows/WASD/hjkl move · x look · Tab orbit · M tactical map"; +pub(crate) const HELP: &str = "HEXWORK v0.1.0 - a lantern in the deep\n\nUSAGE: hexwork [OPTIONS]\n\nOPERATIONS:\n --help Show this help\n --arena Read-only deterministic arena proof\n --replay FILE Verify a bounded v4 production replay\n --recover Finish pending managed recovery only\n\nPLAY/ROOTS:\n --data-dir PATH Managed lineage root (default: platform user data)\n --content-root PATH Exact content root (then HEXWORK_CONTENT_ROOT/assets/dev)\n --load RUN_ID Resume one managed run\n --headless-script FILE Run bounded HEXWORK-COMMANDS 1 input\n\nPRESENTATION (INTERACTIVE PLAY ONLY):\n --gamma 0.5..2 Post-shade gamma\n --fov 1..75 Perspective field of view in degrees\n --palette NAME default, protan, deutan, tritan, high-contrast\n\nCONSTRAINTS/SETUP:\n --seed N New seed, or load/replay seed constraint\n --daily YYYY-MM-DD Ironcartographer daily mode/seed constraint\n --story Story mode constraint\n --name NAME New-run display name\n --ancestry 1..4 Mudborn, Cant-born, Pale-touched, Foundling\n --calling 1..6 Ratcatcher, Lampbearer, Tinker, Corpse-tender, Toll-ward, Choir-deserter\n --trusted-work-limit N Trusted replay/recovery full/unattended/elapsed ceiling\n --export-ansi FILE Durable bounded replay event-text ANSI export\n\nKEYS: arrows/WASD/hjkl move · x look · i inventory · Tab orbit · m tactical map\nTRANSFERS: g requests pickup at your feet; - requests dropping the selected carried item.\n Enter confirms a transfer only in its prompt; Esc cancels. Inventory inspection is unchanged.\n Custom keymaps may leave pickup/drop unbound.\nHEADLESS TRANSFERS: input pickup / input drop (one command per line), then input confirm.\n input escape cancels the prompt; ordinary contextual Confirm behavior is unchanged."; #[cfg(test)] mod tests { @@ -449,6 +449,16 @@ mod tests { } } + #[test] + fn help_describes_explicit_transfer_controls_and_optional_bindings() { + assert!(HELP.contains("g requests pickup at your feet")); + assert!(HELP.contains("- requests dropping the selected carried item")); + assert!(HELP.contains("Enter confirms a transfer only in its prompt")); + assert!(HELP.contains("Custom keymaps may leave pickup/drop unbound")); + assert!(HELP.contains("input pickup / input drop (one command per line)")); + assert!(HELP.contains("ordinary contextual Confirm behavior is unchanged")); + } + #[test] fn canonical_setup_indices() { let cli = parse(&["hexwork", "--ancestry", "4", "--calling", "6"]).unwrap(); diff --git a/crates/hexwork-cli/src/entry.rs b/crates/hexwork-cli/src/entry.rs index d8bcdaa..a930e2b 100644 --- a/crates/hexwork-cli/src/entry.rs +++ b/crates/hexwork-cli/src/entry.rs @@ -23,11 +23,14 @@ use hexwork_term::remap::{load_keymap, Action, Keymap, LoadError, LoadSource}; use hexwork_term::size_ok; use hexwork_ui::inventory::{render_browser, BrowserBindings}; use hexwork_ui::logfmt::{recent_log_window, RecentLogWindow}; +use hexwork_ui::transfer::{ + render_transfer_prompt, TransferPromptBindings, TransferPromptKind as UITransferPromptKind, +}; use hexwork_ui::Screen; use crate::app::{ default_new_setup, recover_checked, recovery_limits, validate_identity_constraints, AppError, - Application, InputResult, LifecycleBoundary, LoadConstraints, WorkBudget, + Application, InputBarrier, InputResult, LifecycleBoundary, LoadConstraints, WorkBudget, }; use crate::cli::{Cli, ModeConstraint, Operation, HELP}; use crate::crash::{install_panic_hook, update_panic_state}; @@ -63,6 +66,7 @@ enum ColorMode { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum DrainedBatchResult { Drained, + PresentationBarrier, Quit, Lifecycle(LifecycleBoundary), } @@ -76,7 +80,12 @@ fn dispatch_drained_batch( for input in inputs { let result = match app.input_with_boundary(input, None, output, None, limits) { Ok(result) => result, - Err(error) if error.is_input_rejection() => continue, + 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 { @@ -85,6 +94,9 @@ fn dispatch_drained_batch( if result.lifecycle != LifecycleBoundary::None { return Ok(DrainedBatchResult::Lifecycle(result.lifecycle)); } + if result.barrier == InputBarrier::DiscardDrainedTerminalBatch { + return Ok(DrainedBatchResult::PresentationBarrier); + } } Ok(DrainedBatchResult::Drained) } @@ -239,13 +251,41 @@ fn inventory_browser_bindings(keymap: &Keymap) -> BrowserBindings { } } +fn transfer_prompt_bindings(keymap: &Keymap, kind: UITransferPromptKind) -> TransferPromptBindings { + TransferPromptBindings { + previous: binding_label(keymap, Action::Up, "up action"), + next: binding_label(keymap, Action::Down, "down action"), + confirm: binding_label(keymap, Action::Confirm, "confirm action"), + quit: binding_label(keymap, Action::Quit, "quit action"), + originating_action: keymap.primary_binding_label(match kind { + UITransferPromptKind::Pickup => Action::Pickup, + UITransferPromptKind::Drop => Action::Drop, + }), + } +} + +fn optional_action_hint(keymap: &Keymap, action: Action, verb: &str) -> Option { + keymap + .primary_binding_label(action) + .map(|label| format!("{label} {verb}")) +} + fn keymap_context_ribbon( screen: Screen, keymap: &Keymap, contextual_melee_hint: Option, + transfer_prompt: Option, ) -> String { - if let Some(hint) = contextual_melee_hint { - return hint; + if let Some(kind) = transfer_prompt { + let bindings = transfer_prompt_bindings(keymap, kind); + let mut ribbon = format!( + "transfer: {} confirm · {} cancel", + bindings.confirm, bindings.quit + ); + if let Some(origin) = bindings.originating_action { + let _ = write!(ribbon, " or {origin}"); + } + return ribbon; } let label = |action, fallback| binding_label(keymap, action, fallback); match screen { @@ -254,34 +294,61 @@ fn keymap_context_ribbon( label(Action::Confirm, "confirm"), label(Action::Contracts, "contracts action") ), - Screen::Map => format!( - "map: move vertically to change Z · {} return to live view", - label(Action::CameraToggle, "camera action") - ), - Screen::Inventory => format!( - "inventory: move to inspect · {} look · {} close", - label(Action::Look, "look action"), - label(Action::Inventory, "inventory action") - ), - Screen::Look => format!( - "look: move to inspect · {} close", - label(Action::Look, "look action") - ), + Screen::Map => match optional_action_hint(keymap, Action::Pickup, "pickup") { + Some(pickup) => format!( + "map: {pickup} · move vertically to change Z · {} return", + label(Action::CameraToggle, "camera action") + ), + None => format!( + "map: move vertically to change Z · {} return to live view", + label(Action::CameraToggle, "camera action") + ), + }, + Screen::Inventory => { + let mut parts = vec![ + "inventory: move to inspect".to_owned(), + format!("{} look", label(Action::Look, "look action")), + format!("{} close", label(Action::Inventory, "inventory action")), + ]; + if let Some(drop) = optional_action_hint(keymap, Action::Drop, "drop selected") { + parts.insert(1, drop); + } + parts.join(" · ") + } + Screen::Look => { + let mut parts = vec!["look: move to inspect".to_owned()]; + if let Some(drop) = optional_action_hint(keymap, Action::Drop, "drop selected") { + parts.push(drop); + } + parts.push(format!("{} close", label(Action::Look, "look action"))); + parts.join(" · ") + } Screen::Chapel => format!( "chapel: {} interact · {} close", label(Action::Confirm, "confirm"), label(Action::Chapel, "chapel action") ), - _ => format!( - "move · {} look · {} inventory · {} contracts · {} map · {} chapel · {} camera · {} quit", - label(Action::Look, "look action"), - label(Action::Inventory, "inventory action"), - label(Action::Contracts, "contracts action"), - label(Action::Map, "map action"), - label(Action::Chapel, "chapel action"), - label(Action::CameraToggle, "camera action"), - label(Action::Quit, "quit action") - ), + _ => { + if let Some(hint) = contextual_melee_hint { + return hint; + } + let mut ribbon = "move".to_owned(); + if let Some(pickup) = optional_action_hint(keymap, Action::Pickup, "pickup") { + let _ = write!(ribbon, " · {pickup}"); + } + let _ = write!( + ribbon, + " · {} look · {} inventory · {} contracts · {} map · {} chapel · {} camera · {} quit", + label(Action::Look, "look action"), + label(Action::Inventory, "inventory action"), + label(Action::Contracts, "contracts action"), + label(Action::Map, "map action"), + label(Action::Chapel, "chapel action"), + label(Action::CameraToggle, "camera action"), + label(Action::Quit, "quit action") + ); + ribbon + } } } @@ -668,7 +735,7 @@ fn run_terminal( let mut frame = FrameBuffer::new(width as usize, viewport_height); let mut previous = FrameBuffer::new(width as usize, viewport_height); let mut world_frame_valid = false; - let mut browser_was_open = false; + let mut opaque_modal_was_open = false; let ascii_only = overrides.no_color || overrides.force_ascii || !render_caps.unicode; let color_mode = if overrides.no_color { ColorMode::NoColor @@ -689,7 +756,7 @@ fn run_terminal( .map_err(|error| AppError::io("terminal-input", error.to_string()))?; let mut operation_sink = JsonOutput::new(io::sink()); match dispatch_drained_batch(&mut app, inputs, limits, &mut operation_sink)? { - DrainedBatchResult::Drained => {} + DrainedBatchResult::Drained | DrainedBatchResult::PresentationBarrier => {} DrainedBatchResult::Quit => { app.finish_signal_if_pending(None, &mut operation_sink, limits)?; break 'play; @@ -719,14 +786,25 @@ fn run_terminal( let mut operation_sink = JsonOutput::new(io::sink()); app.finish_signal_if_pending(None, &mut operation_sink, limits)?; } - let browser = app.inventory_browser_view(width as usize, viewport_height)?; + let prompt = app.transfer_prompt_view(width as usize, viewport_height)?; + let browser = if prompt.is_none() { + app.inventory_browser_view(width as usize, viewport_height)? + } else { + None + }; let presentation = app.presentation()?; let mut ansi = String::new(); - if let Some(browser) = browser { + if let Some(prompt) = prompt { + let bindings = transfer_prompt_bindings(backend.keymap(), prompt.content.kind); + let rows = render_transfer_prompt(&prompt.content, &bindings, &prompt.layout); + append_opaque_modal(&mut ansi, &rows, viewport_height, !opaque_modal_was_open); + opaque_modal_was_open = true; + world_frame_valid = false; + } else if let Some(browser) = browser { let bindings = inventory_browser_bindings(backend.keymap()); let rows = render_browser(&browser.content, &bindings, &browser.layout); - append_opaque_browser(&mut ansi, &rows, viewport_height, !browser_was_open); - browser_was_open = true; + append_opaque_modal(&mut ansi, &rows, viewport_height, !opaque_modal_was_open); + opaque_modal_was_open = true; world_frame_valid = false; } else { let state = app.state()?; @@ -737,13 +815,15 @@ fn run_terminal( encode_frame_full(&frame, color_mode, &mut ansi); } std::mem::swap(&mut previous, &mut frame); - browser_was_open = false; + opaque_modal_was_open = false; world_frame_valid = true; } + let prompt_kind = app.transfer_prompt_kind(); let context_ribbon = keymap_context_ribbon( app.presentation_screen(), backend.keymap(), app.contextual_melee_hint(), + prompt_kind, ); let recent_logs = recent_log_window(app.logs(), width as usize, RECENT_LOG_ROWS); append_terminal_chrome( @@ -1034,7 +1114,7 @@ fn encode_frame_full(next: &FrameBuffer, mode: ColorMode, output: &mut String) { output.push_str("\x1b[0m"); } -fn append_opaque_browser( +fn append_opaque_modal( output: &mut String, rows: &[String], viewport_height: usize, @@ -1234,6 +1314,9 @@ 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, @@ -1598,6 +1681,199 @@ mod tests { 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("../.."); @@ -1790,7 +2066,98 @@ mod tests { } #[test] - fn inventory_modal_batch_stops_after_story_lifecycle_and_discards_later_inputs() { + 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); @@ -1819,22 +2186,34 @@ mod tests { ) .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::Unknown, sentinel], + [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); @@ -2166,7 +2545,7 @@ mod tests { ), ]; for (screen, expected) in cases { - let ribbon = keymap_context_ribbon(screen, &keymap, None); + let ribbon = keymap_context_ribbon(screen, &keymap, None, None); for needle in expected { assert!(ribbon.contains(needle), "{screen:?}: {ribbon}"); } @@ -2184,11 +2563,96 @@ mod tests { let contextual = "Melee: confirm an adjacent hostile".to_owned(); assert_eq!( - keymap_context_ribbon(Screen::Play, &keymap, Some(contextual.clone())), + keymap_context_ribbon(Screen::Play, &keymap, Some(contextual.clone()), None), 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()), + 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()), + 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!( @@ -2548,7 +3012,7 @@ mod tests { 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); + 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); @@ -2601,7 +3065,7 @@ mod tests { terminal.apply(&initial); let mut modal = String::new(); - append_opaque_browser(&mut modal, &rows, world.h, true); + append_opaque_modal(&mut modal, &rows, world.h, true); terminal.apply(&modal); assert!(terminal.cells.contains(&'M')); @@ -2618,6 +3082,128 @@ mod tests { } } + #[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 { + mode: BrowserMode::List, + detail_returns_to_list: true, + total: 1, + selected: Some(0), + 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; @@ -2626,7 +3212,7 @@ mod tests { let rows = vec!["Inventory [capped]".to_owned(); 256]; let mut terminal = TerminalGrid::filled(WIDTH, height, 'X'); let mut ansi = String::new(); - append_opaque_browser(&mut ansi, &rows, VIEWPORT_HEIGHT, true); + 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!( diff --git a/crates/hexwork-cli/src/frontend_support.rs b/crates/hexwork-cli/src/frontend_support.rs index 638499c..4815332 100644 --- a/crates/hexwork-cli/src/frontend_support.rs +++ b/crates/hexwork-cli/src/frontend_support.rs @@ -319,6 +319,8 @@ fn parse_input(key: &str) -> Option { "look" => Input::Look, "map" => Input::Map, "chapel" => Input::Chapel, + "pickup" => Input::Pickup, + "drop" => Input::Drop, "camera" => Input::CameraToggle, "pick1" => Input::ChargenPick(0), "pick2" => Input::ChargenPick(1), @@ -628,6 +630,8 @@ mod tests { ("look", Input::Look), ("map", Input::Map), ("chapel", Input::Chapel), + ("pickup", Input::Pickup), + ("drop", Input::Drop), ("camera", Input::CameraToggle), ("pick1", Input::ChargenPick(0)), ("pick2", Input::ChargenPick(1)), diff --git a/crates/hexwork-cli/src/session.rs b/crates/hexwork-cli/src/session.rs index 58cb9a4..5e671f8 100644 --- a/crates/hexwork-cli/src/session.rs +++ b/crates/hexwork-cli/src/session.rs @@ -305,7 +305,6 @@ pub(crate) struct InventoryView { pub truncated: bool, } -#[cfg(test)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GroundTargetQuery { First, @@ -313,7 +312,6 @@ pub(crate) enum GroundTargetQuery { Before(u64), } -#[cfg(test)] #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GroundTargetView { pub entry: InventoryEntryView, @@ -1140,6 +1138,29 @@ impl GameSession { self.validate_session_state_only() } + /// Synthetic adapter proof only: curse one selected carried item through a + /// complete validated StateImage roundtrip. This is not an acquisition route. + #[cfg(test)] + pub(crate) fn curse_carried_item_for_test(&mut self, item_id: u64) -> Result<(), SessionError> { + self.ensure_operational()?; + let player = self.player_id()?; + let mut image = self.state.to_image().map_err(|error| { + SessionError::runtime("inventory-fixture-image", format!("{error:?}")) + })?; + let item = image + .packs + .get_mut(&player) + .and_then(|pack| pack.items.get_mut(&item_id)) + .ok_or_else(|| { + SessionError::runtime("inventory-fixture-item-missing", item_id.to_string()) + })?; + item.cursed = true; + self.state = State::from_image(image).map_err(|error| { + SessionError::runtime("inventory-fixture-restore", format!("{error:?}")) + })?; + self.validate_session_state_only() + } + pub(crate) fn canonical_hash(&self) -> Result { self.ensure_operational()?; self.state @@ -1456,7 +1477,6 @@ impl GameSession { } } -#[cfg(test)] impl GameSession { pub(crate) fn ground_target( &self, diff --git a/crates/hexwork-cli/tests/pty/transfers_live.py b/crates/hexwork-cli/tests/pty/transfers_live.py new file mode 100644 index 0000000..2b723e4 --- /dev/null +++ b/crates/hexwork-cli/tests/pty/transfers_live.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Bounded 80x24 stdlib PTY evidence for AC.11 live transfers.""" + +import errno +import fcntl +import json +import os +import pathlib +import re +import selectors +import struct +import subprocess +import sys +import termios +import time + +DEADLINE_SECONDS = 300.0 +CAPTURE_LIMIT = 4 * 1024 * 1024 +ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-_])") +PLAY = b"Recent feedback" + + +def stripped(data): + return ANSI.sub(b"", data).replace(b"\r", b"") + + +def terminate(child): + if child.poll() is None: + child.kill() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=5) + + +def only_run_id(data_dir): + values = sorted(path.name for path in (data_dir / "runs").iterdir() if path.is_dir()) + if len(values) != 1: + raise RuntimeError("expected exactly one run, got %r" % values) + return values[0] + + +def spawn(binary, data_dir, content, seed): + master, slave = os.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + size = struct.unpack("HHHH", fcntl.ioctl(slave, termios.TIOCGWINSZ, b"\0" * 8)) + child = subprocess.Popen( + [ + binary, + "--data-dir", + str(data_dir), + "--content-root", + str(content), + "--seed", + str(seed), + "--name", + "AC11 PTY delver", + "--ancestry", + "1", + "--calling", + "1", + ], + stdin=slave, + stdout=slave, + stderr=slave, + close_fds=True, + start_new_session=True, + ) + os.close(slave) + os.set_blocking(master, False) + return child, master, size[0] == 24 and size[1] == 80 + + +def drive(binary, data_dir, content, seed, steps): + child, master, exact_size = spawn(binary, data_dir, content, seed) + selector = selectors.DefaultSelector() + selector.register(master, selectors.EVENT_READ) + registered = True + captured = bytearray() + step = 0 + search_from = 0 + deadline = time.monotonic() + DEADLINE_SECONDS + try: + # Child termination and PTY EOF are independent conditions. Keep draining + # until both occur; never attempt further input once EOF is observed. + while child.poll() is None or registered: + remaining = deadline - time.monotonic() + if remaining <= 0.0: + raise RuntimeError("PTY child or EOF drain exceeded 300 second deadline") + if registered: + for _, _ in selector.select(timeout=min(0.25, remaining)): + try: + chunk = os.read(master, 65536) + except OSError as error: + if error.errno == errno.EIO: + chunk = b"" + elif error.errno in (errno.EAGAIN, errno.EWOULDBLOCK): + continue + else: + raise + if not chunk: + selector.unregister(master) + registered = False + break + if len(captured) + len(chunk) > CAPTURE_LIMIT: + raise RuntimeError("PTY capture exceeded 4 MiB") + captured.extend(chunk) + else: + try: + child.wait(timeout=min(0.25, remaining)) + except subprocess.TimeoutExpired: + pass + + plain = stripped(bytes(captured)) + while child.poll() is None and registered and step < len(steps): + marker, keys = steps[step] + found = plain.find(marker, search_from) + if found < 0: + break + os.write(master, keys) + search_from = found + len(marker) + step += 1 + + status = child.wait(timeout=5) + if step != len(steps): + raise RuntimeError( + "child exited before expected marker; step=%d/%d tail=%r" + % (step, len(steps), stripped(bytes(captured))[-5000:]) + ) + if status != 0: + raise RuntimeError( + "child exit %d; tail=%r" % (status, stripped(bytes(captured))[-5000:]) + ) + return bytes(captured), status, exact_size + finally: + selector.close() + os.close(master) + terminate(child) + + +def transfer_steps(mode, fixture): + route = fixture["route_keys"].encode("ascii") + slot = str(fixture["item_slot"]).encode("ascii") + if mode == "transfer": + pickup, drop = b"g", b"-" + play_marker = b"g pickup" + drop_hint = b"- drop selected" + elif mode == "remapped": + pickup, drop = b"r", b"v" + play_marker = b"r pickup" + drop_hint = b"v drop selected" + else: + raise RuntimeError("not a transfer mode: " + mode) + return [ + (play_marker, route + pickup), + (b"Pickup", pickup), + (b"transfer cancelled", pickup), + (b"Pickup", b"\r"), + (play_marker, b"i"), + (drop_hint, slot + b"\r"), + (b"Inventory detail", drop), + (b"Drop", b"\r"), + (b"Inventory", b"i"), + (play_marker, b"\x1b"), + ] + + +def validate_fixture(mode, fixture): + seed = fixture.get("seed") + if not isinstance(seed, int): + raise RuntimeError("fixture seed missing") + if mode in ("transfer", "remapped"): + route = fixture.get("route_keys") + if not isinstance(route, str) or any(key not in "wasd" for key in route): + raise RuntimeError("route is not default physical movement: %r" % route) + if fixture.get("route_len") != len(route) or len(route) >= 128: + raise RuntimeError("route length is not explicit and bounded") + if fixture.get("item_slot") != 4: + raise RuntimeError("Ratcatcher generated-item slot must be four") + if mode == "rejection": + prefix = fixture.get("prefix_keys") + rejected = fixture.get("rejected_key") + legal = fixture.get("legal_key") + if not isinstance(prefix, str) or any(key not in "wasd" for key in prefix): + raise RuntimeError("invalid rejection prefix") + if fixture.get("prefix_len") != len(prefix) or len(prefix) >= 128: + raise RuntimeError("rejection prefix is not bounded") + if rejected not in "wasd" or legal not in "wasd": + raise RuntimeError("rejection keys are not cardinal") + return seed + + +def main(): + if len(sys.argv) != 6: + raise RuntimeError( + "usage: transfers_live.py BINARY DATA CONTENT transfer|remapped|legacy|rejection FIXTURE" + ) + binary = sys.argv[1] + data_dir = pathlib.Path(sys.argv[2]) + content = pathlib.Path(sys.argv[3]) + mode = sys.argv[4] + fixture = json.loads(pathlib.Path(sys.argv[5]).read_text(encoding="utf-8")) + seed = validate_fixture(mode, fixture) + data_dir.mkdir(parents=True, exist_ok=True) + + if mode in ("transfer", "remapped"): + captured, status, exact_size = drive( + binary, data_dir, content, seed, transfer_steps(mode, fixture) + ) + plain = stripped(captured) + summary = { + "exit": status, + "window_80x24": exact_size, + "prompt_visible": plain.count(b"Pickup") >= 2, + "cancel_visible": b"transfer cancelled" in plain, + "inspect_visible": b"Inventory detail" in plain, + "drop_prompt_visible": b"Drop" in plain, + "default_pickup_hint_visible": b"g pickup" in plain, + "default_drop_hint_visible": b"- drop selected" in plain, + "remapped_pickup_hint_visible": b"r pickup" in plain, + "remapped_drop_hint_visible": b"v drop selected" in plain, + "session_over": b"HEXWORK session over" in plain, + "run_id": only_run_id(data_dir), + } + elif mode == "legacy": + captured, status, exact_size = drive( + binary, + data_dir, + content, + seed, + [(PLAY, b"g-\x1b")], + ) + plain = stripped(captured) + summary = { + "exit": status, + "window_80x24": exact_size, + "transfer_hints_absent": b"pickup" not in plain.lower() + and b"drop selected" not in plain.lower(), + "g_minus_unbound": b"Pickup" not in plain and b"Drop" not in plain, + "session_over": b"HEXWORK session over" in plain, + "run_id": only_run_id(data_dir), + } + elif mode == "rejection": + keys = ( + fixture["prefix_keys"] + + fixture["rejected_key"] + + fixture["legal_key"] + ).encode("ascii") + captured, status, exact_size = drive( + binary, + data_dir, + content, + seed, + [(PLAY, keys), (b"passage-blocked:", b"\x1b")], + ) + plain = stripped(captured) + summary = { + "exit": status, + "window_80x24": exact_size, + "ordinary_rejection_visible": b"passage-blocked:" in plain, + "session_over": b"HEXWORK session over" in plain, + "run_id": only_run_id(data_dir), + } + else: + raise RuntimeError("unknown mode: " + mode) + + sys.stdout.write(json.dumps(summary, separators=(",", ":")) + "\n") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + sys.stderr.write(("transfers live PTY scenario failed: %s\n" % error)[-8192:]) + raise diff --git a/crates/hexwork-cli/tests/transfers_lifecycle.rs b/crates/hexwork-cli/tests/transfers_lifecycle.rs new file mode 100644 index 0000000..133dd3b --- /dev/null +++ b/crates/hexwork-cli/tests/transfers_lifecycle.rs @@ -0,0 +1,730 @@ +//! AC12 actual-process Story/Classic transfer lifecycle evidence. +//! +//! Every gameplay mutation below comes from normal commands parsed by the +//! production binary. Decoding is used only to inspect durable results and to +//! label a replay from that durable history; no State, HP, position, item, or +//! event field is injected into a positive gameplay path. + +#![cfg(any(target_os = "linux", target_os = "macos"))] + +pub mod support; + +use hexwork_core::action::CommittedAction; +use hexwork_core::runtime::ActorRole; +use hexwork_save::artifact::MAX_EPITAPH_BYTES; +use hexwork_save::{ + decode_replay, decode_run, decode_vault, encode_replay, read_file_bounded, ProductionReplay, + RunId, SessionEvent, SessionEventKind, MAX_COMPRESSED_BYTES, MAX_MARKER_BYTES, MAX_VAULT_BYTES, +}; +use hexwork_worldgen::{Cell, ConnectorPlan, FloorPlan, PlanDestination, VoxelWrite, WorldPlan}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use support::{grate_mother_death_script, Harness}; + +const SEED: u64 = 223; +const SMALL_EVIDENCE_CAP: usize = 64 * 1024; +const CONFLICTING_REPLAY: &[u8] = b"ac12 deterministic regular replay collision\n"; +const TRANSFER_CYCLE: &str = + "input inventory\ninput drop\ninput confirm\ninput inventory\ninput pickup\ninput confirm\n"; +const POST_CHECKPOINT_DROP: &str = + "input inventory\ninput drop\ninput confirm\ninput inventory\nsave\nquit\n"; + +fn field<'a>(record: &'a serde_json::Value, name: &str) -> &'a serde_json::Value { + record + .get(name) + .unwrap_or_else(|| panic!("missing {name}: {record}")) +} + +fn bounded(path: impl AsRef, cap: usize) -> Vec { + let path = path.as_ref(); + read_file_bounded(path, cap) + .unwrap_or_else(|error| panic!("bounded read {}: {error}", path.display())) +} + +fn assert_success(output: &Output, stage: &str) { + assert!( + output.status.success(), + "{stage}: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_exit(output: &Output, expected: i32, code: &str) { + assert_eq!( + output.status.code(), + Some(expected), + "expected exit {expected} ({code}); stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains(code), + "missing {code}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn allocation_run_id(output: &Output) -> String { + Harness::records(output) + .iter() + .find(|record| field(record, "code") == "allocation-durable") + .and_then(|record| field(record, "run_id").as_str()) + .expect("allocation milestone run ID") + .to_owned() +} + +fn run_path(harness: &Harness, run_id: &str) -> PathBuf { + harness.data.join("runs").join(run_id).join("run.save") +} + +fn decode_saved( + harness: &Harness, + pack: &hexwork_content::RuntimePack, + run_id: &str, +) -> hexwork_save::DecodedRun { + decode_run( + &bounded(run_path(harness, run_id), MAX_COMPRESSED_BYTES), + pack.hash(), + ) + .expect("decode actual managed save") +} + +fn player_id(run: &hexwork_save::DecodedRun) -> u64 { + run.current + .actors + .iter() + .find(|actor| { + run.current + .identity(actor.id) + .is_some_and(|identity| identity.role == ActorRole::Player) + }) + .expect("one authoritative Player") + .id +} + +fn transfer_cycle_item(events: &[SessionEvent]) -> u64 { + let drop_id = match events.first().map(|event| &event.kind) { + Some(SessionEventKind::Action(CommittedAction::Drop { item_id })) => *item_id, + other => panic!("first durable action was not starter drop: {other:?}"), + }; + let pickup_id = match events.get(1).map(|event| &event.kind) { + Some(SessionEventKind::Action(CommittedAction::Pickup { item_id })) => *item_id, + other => panic!("second durable action was not matching pickup: {other:?}"), + }; + assert_eq!(pickup_id, drop_id); + assert_eq!(events[0].pre_tick, events[0].post_tick); + assert_eq!(events[1].pre_tick, events[1].post_tick); + drop_id +} + +fn new_command(harness: &Harness, script: &Path, story: bool) -> Command { + let mut command = harness.managed(script); + command + .arg("--seed") + .arg(SEED.to_string()) + .arg("--ancestry") + .arg("1") + .arg("--calling") + .arg("1"); + if story { + command.arg("--story"); + } + command +} + +fn load_command(harness: &Harness, script: &Path, run_id: &str, story: bool) -> Command { + let mut command = harness.managed(script); + command.arg("--load").arg(run_id); + if story { + command.arg("--story"); + } + command +} + +fn recover_command(harness: &Harness) -> Command { + let mut command = harness.command(); + command + .arg("--data-dir") + .arg(&harness.data) + .arg("--content-root") + .arg(&harness.content) + .arg("--recover"); + command +} + +fn replay_command(harness: &Harness, replay_path: &Path) -> Command { + let mut command = harness.command(); + command + .arg("--content-root") + .arg(&harness.content) + .arg("--replay") + .arg(replay_path); + command +} + +fn assert_actual_replay(harness: &Harness, replay_path: &Path, expected_hash: u64) { + let output = Harness::run(replay_command(harness, replay_path)); + assert_success(&output, "actual --replay"); + let stdout = String::from_utf8(output.stdout).expect("replay stdout UTF-8"); + assert!(stdout.contains("verified event-text replay"), "{stdout}"); + assert!( + stdout.contains(&format!("hash {expected_hash:#x}")), + "{stdout}" + ); +} + +fn input_name(delta: (i32, i32)) -> &'static str { + match delta { + (0, 1) => "north", + (-1, 0) => "west", + (1, 0) => "east", + (0, -1) => "south", + _ => unreachable!("only cardinal route deltas"), + } +} + +fn append_route( + output: &mut String, + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: BTreeSet, +) { + if start == goal { + return; + } + let directions = [(0, 1), (-1, 0), (1, 0), (0, -1)]; + let mut queue = VecDeque::from([start]); + let mut previous = BTreeMap::new(); + let mut seen = BTreeSet::from([start]); + while let Some(cell) = queue.pop_front() { + for delta in directions { + let next = [cell[0] + delta.0, cell[1] + delta.1, cell[2]]; + if seen.contains(&next) || blockers.contains(&next) || !passable(writes, next) { + continue; + } + previous.insert(next, (cell, delta)); + if next == goal { + let mut path = Vec::new(); + let mut cursor = goal; + while cursor != start { + let (prior, step) = previous[&cursor]; + path.push(step); + cursor = prior; + } + for step in path.into_iter().rev() { + output.push_str("input "); + output.push_str(input_name(step)); + output.push('\n'); + } + return; + } + seen.insert(next); + queue.push_back(next); + } + } + panic!("no deterministic ordinary route from {start:?} to {goal:?}"); +} + +fn voxel_at(writes: &[VoxelWrite], cell: Cell) -> hexwork_core::voxel::Voxel { + writes + .binary_search_by_key(&(cell[2], cell[1], cell[0]), |write| { + (write.cell[2], write.cell[1], write.cell[0]) + }) + .ok() + .map(|index| writes[index].voxel) + .unwrap_or_default() +} + +fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { + let body = voxel_at(writes, cell); + let head = voxel_at(writes, [cell[0], cell[1], cell[2] + 1]); + let below = voxel_at(writes, [cell[0], cell[1], cell[2] - 1]); + body.occ == 0 && head.occ == 0 && below.occ != 0 && body.fluid_amt <= 2 +} + +fn world_blockers(plan: &WorldPlan) -> BTreeSet { + plan.actors.iter().map(|actor| actor.position).collect() +} + +fn floor_blockers(plan: &FloorPlan) -> BTreeSet { + plan.actors.iter().map(|actor| actor.position).collect() +} + +fn paired<'a>(connectors: &'a [ConnectorPlan], source: &ConnectorPlan) -> &'a ConnectorPlan { + connectors + .iter() + .find(|connector| connector.transition_id == source.paired_transition_id) + .expect("paired connector") +} + +fn adjacent_access(writes: &[VoxelWrite], target: Cell, blockers: &BTreeSet) -> Cell { + [(0, 1), (-1, 0), (1, 0), (0, -1)] + .into_iter() + .map(|(dx, dy)| [target[0] + dx, target[1] + dy, target[2]]) + .find(|cell| passable(writes, *cell) && !blockers.contains(cell)) + .expect("Grate Mother has accessible adjacent cell") +} + +fn genuine_story_checkpoint_route(seed: u64) -> (String, Cell, u64) { + let town = hexwork_worldgen::milthollow(seed); + let floor1 = &hexwork_worldgen::playable_deep(seed)[0]; + let down = town + .connectors + .iter() + .find(|connector| connector.destination == PlanDestination::Deep { depth: 1 }) + .expect("Town to Deep-1 connector"); + let up = paired(&floor1.connectors, down); + let mut commands = String::new(); + append_route( + &mut commands, + &town.writes, + town.start, + down.access, + world_blockers(&town), + ); + commands.push_str("input confirm\ninput confirm\n"); + (commands, down.access, up.transition_id) +} + +fn grate_mother_death_script_from(seed: u64, town_start: Cell) -> String { + let town = hexwork_worldgen::milthollow(seed); + let floors = hexwork_worldgen::playable_deep(seed); + let floor1 = &floors[0]; + let floor2 = &floors[1]; + let down = town + .connectors + .iter() + .find(|connector| connector.destination == PlanDestination::Deep { depth: 1 }) + .expect("Town to Deep-1 connector"); + let floor1_up = paired(&floor1.connectors, down); + let floor1_down = floor1 + .connectors + .iter() + .find(|connector| connector.destination == PlanDestination::Deep { depth: 2 }) + .expect("Deep-1 to Deep-2 connector"); + let floor2_up = paired(&floor2.connectors, floor1_down); + let mother = floor2 + .actors + .iter() + .find(|actor| actor.template_id == "grate_mother") + .expect("generated Grate Mother"); + let blockers = floor_blockers(floor2); + let access = adjacent_access(&floor2.writes, mother.position, &blockers); + + let mut commands = String::new(); + append_route( + &mut commands, + &town.writes, + town_start, + down.access, + world_blockers(&town), + ); + commands.push_str("input confirm\n"); + append_route( + &mut commands, + &floor1.writes, + floor1_up.access, + floor1_down.access, + floor_blockers(floor1), + ); + commands.push_str("input confirm\n"); + append_route( + &mut commands, + &floor2.writes, + floor2_up.access, + access, + blockers, + ); + commands.push_str("input confirm\ninput confirm\ninput confirm\ninput confirm\n"); + commands +} + +fn milestone_positions(records: &[serde_json::Value], wanted: &[&str]) -> Vec { + let codes = records + .iter() + .filter_map(|record| field(record, "code").as_str()) + .collect::>(); + wanted + .iter() + .map(|wanted| { + codes + .iter() + .position(|code| code == wanted) + .unwrap_or_else(|| panic!("missing {wanted}: {codes:?}")) + }) + .collect() +} + +#[test] +fn binary_story_transfer_checkpoint_rollback_and_replay() { + let harness = Harness::new("ac12-story-transfer-lifecycle"); + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + let (checkpoint_route, return_cell, return_transition_id) = + genuine_story_checkpoint_route(SEED); + + let mut checkpoint_body = String::from(TRANSFER_CYCLE); + checkpoint_body.push_str(&checkpoint_route); + checkpoint_body.push_str("observe state\nsave\nquit\n"); + let checkpoint_script = harness.script("story-checkpoint.commands", &checkpoint_body); + let checkpoint_output = Harness::run(new_command(&harness, &checkpoint_script, true)); + assert_success(&checkpoint_output, "Story checkpoint process"); + let run_id = allocation_run_id(&checkpoint_output); + let checkpoint_saved = decode_saved(&harness, &pack, &run_id); + assert_eq!(checkpoint_saved.identity.seed, SEED); + assert_eq!( + checkpoint_saved.identity.ancestry, + hexwork_core::character::Ancestry::Mudborn + ); + assert_eq!( + checkpoint_saved.identity.calling, + hexwork_core::character::Calling::Ratcatcher + ); + let selected_id = transfer_cycle_item(&checkpoint_saved.events); + let player = player_id(&checkpoint_saved); + let checkpoint = checkpoint_saved + .story_checkpoint + .as_ref() + .expect("genuine Story checkpoint"); + let checkpoint_prefix = usize::try_from(checkpoint.after_event_count).unwrap(); + assert!( + checkpoint_prefix > 2, + "checkpoint prefix must be nonzero and post-route" + ); + assert_eq!(checkpoint_prefix, checkpoint_saved.events.len()); + assert!(matches!( + checkpoint_saved.events[checkpoint_prefix - 1].kind, + SessionEventKind::Action(CommittedAction::UseTransition { transition_id }) + if transition_id == return_transition_id + )); + let checkpoint_actor = checkpoint + .state + .image + .actors + .iter() + .find(|actor| actor.id == player) + .expect("checkpoint Player"); + assert_eq!(checkpoint_actor.pos, return_cell.map(|cell| cell as f32)); + assert_ne!(return_cell, hexwork_worldgen::milthollow(SEED).start); + let checkpoint_instance = checkpoint.state.image.packs[&player].items[&selected_id].clone(); + assert!(!checkpoint.state.image.items.contains_key(&selected_id)); + assert!(!checkpoint + .state + .image + .ground_items + .contains_key(&selected_id)); + + let post_drop_script = + harness.script("story-post-checkpoint-drop.commands", POST_CHECKPOINT_DROP); + let post_drop_output = Harness::run(load_command(&harness, &post_drop_script, &run_id, true)); + assert_success(&post_drop_output, "Story post-checkpoint drop process"); + let post_drop = decode_saved(&harness, &pack, &run_id); + let post_drop_image = post_drop.current.to_image().unwrap(); + assert_eq!(post_drop.events.len(), checkpoint_prefix + 1); + assert!(matches!( + post_drop.events[checkpoint_prefix].kind, + SessionEventKind::Action(CommittedAction::Drop { item_id }) if item_id == selected_id + )); + assert_eq!(post_drop_image.items[&selected_id], checkpoint_instance); + assert_eq!( + post_drop_image.ground_items[&selected_id], + return_cell.into() + ); + assert!(!post_drop_image.packs[&player] + .items + .contains_key(&selected_id)); + + let mut lethal_body = grate_mother_death_script_from(SEED, return_cell); + lethal_body.push_str("observe state\nsave\nquit\n"); + let lethal_script = harness.script("story-recorded-lethal.commands", &lethal_body); + let lethal_output = Harness::run(load_command(&harness, &lethal_script, &run_id, true)); + assert_success(&lethal_output, "Story recorded lethal/resurrection process"); + let restored = decode_saved(&harness, &pack, &run_id); + let restored_image = restored.current.to_image().unwrap(); + let active_checkpoint = restored.story_checkpoint.as_ref().unwrap(); + assert_eq!( + active_checkpoint.after_event_count as usize, + checkpoint_prefix + ); + assert_eq!(active_checkpoint.state.hash, checkpoint.state.hash); + assert_eq!(restored_image.packs, checkpoint.state.image.packs); + assert_eq!(restored_image.items, checkpoint.state.image.items); + assert_eq!( + restored_image.ground_items, + checkpoint.state.image.ground_items + ); + assert_eq!( + restored_image.packs[&player].items[&selected_id], + checkpoint_instance + ); + assert!(!restored_image.items.contains_key(&selected_id)); + assert!(!restored_image.ground_items.contains_key(&selected_id)); + assert!(restored_image.chapel_debts.contains_key(&player)); + + assert_eq!( + &restored.events[..checkpoint_prefix], + &checkpoint_saved.events[..checkpoint_prefix], + "resurrection must retain the genuine checkpoint prefix" + ); + assert!(matches!( + restored.events[checkpoint_prefix].kind, + SessionEventKind::Action(CommittedAction::Drop { item_id }) if item_id == selected_id + )); + let attack_count = restored.events[checkpoint_prefix + 1..] + .iter() + .filter(|event| { + matches!( + event.kind, + SessionEventKind::Action(CommittedAction::ResolveAttack { .. }) + ) + }) + .count(); + assert_eq!( + attack_count, 4, + "seed-223 route must record four combat exchanges" + ); + let restored_hash = restored.current.try_hash_state().unwrap(); + let resurrection = restored + .events + .iter() + .find_map(|event| match event.kind { + SessionEventKind::StoryResurrect { + checkpoint_after_event_count, + checkpoint_hash, + dead_state_hash, + restored_state_hash, + } => Some(( + checkpoint_after_event_count, + checkpoint_hash, + dead_state_hash, + restored_state_hash, + )), + _ => None, + }) + .expect("recorded Story resurrection event"); + assert_eq!(resurrection.0 as usize, checkpoint_prefix); + assert_eq!(resurrection.1, checkpoint.state.hash); + assert_ne!(resurrection.2, checkpoint.state.hash); + assert_eq!(resurrection.3, restored_hash); + + let replay = ProductionReplay { + identity: restored.identity.clone(), + events: restored.events.clone(), + expected_final_tick: restored.current.tick, + expected_final_hash: restored_hash, + }; + let replay_path = harness.root.join("story-transfer-history.hexdemo"); + fs::write(&replay_path, encode_replay(&replay).unwrap()).unwrap(); + let decoded_replay = decode_replay(&bounded(&replay_path, MAX_COMPRESSED_BYTES), pack.hash()) + .expect("decode replay labeled from durable Story history"); + assert_eq!(decoded_replay.events, restored.events); + assert_actual_replay(&harness, &replay_path, restored_hash); +} + +#[test] +fn binary_classic_transfer_death_recovery_and_replay() { + let harness = Harness::new("ac12-classic-transfer-lifecycle"); + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + let initial_script = harness.script( + "classic-transfer-cycle.commands", + &format!("{TRANSFER_CYCLE}save\nquit\n"), + ); + let initial_output = Harness::run(new_command(&harness, &initial_script, false)); + assert_success(&initial_output, "Classic transfer cycle process"); + let run_id = allocation_run_id(&initial_output); + let parsed_run_id = run_id.parse::().unwrap(); + let run_dir = harness.data.join("runs").join(&run_id); + let initial = decode_saved(&harness, &pack, &run_id); + assert_eq!(initial.identity.seed, SEED); + assert_eq!( + initial.identity.ancestry, + hexwork_core::character::Ancestry::Mudborn + ); + assert_eq!( + initial.identity.calling, + hexwork_core::character::Calling::Ratcatcher + ); + let selected_id = transfer_cycle_item(&initial.events); + let player = player_id(&initial); + let selected_instance = + initial.current.to_image().unwrap().packs[&player].items[&selected_id].clone(); + + let replay_path = run_dir.join("replay.hexdemo"); + let pending_path = run_dir.join("death.pending"); + let committed_path = run_dir.join("death.committed"); + let epitaph_path = run_dir.join("epitaph.txt"); + let managed_run_path = run_dir.join("run.save"); + let vault_path = harness.data.join("lineage-vault.json"); + let living_vault = bounded(&vault_path, MAX_VAULT_BYTES); + fs::write(&replay_path, CONFLICTING_REPLAY).expect("install regular replay-file conflict"); + + let lethal_script = harness.script( + "classic-recorded-lethal.commands", + &grate_mother_death_script(SEED), + ); + let failed = Harness::run(load_command(&harness, &lethal_script, &run_id, false)); + assert_exit(&failed, 3, "immutable-artifact-mismatch"); + let failed_records = Harness::records(&failed); + let failed_positions = milestone_positions( + &failed_records, + &["terminal-snapshot-durable", "pending-death-durable"], + ); + assert!(failed_positions[0] < failed_positions[1]); + let failed_codes = failed_records + .iter() + .filter_map(|record| field(record, "code").as_str()) + .collect::>(); + for forbidden in [ + "replay-durable", + "epitaph-durable", + "vault-death-durable", + "committed-death-durable", + "run-retired-durable", + "pending-retired-durable", + ] { + assert!(!failed_codes.contains(&forbidden), "unexpected {forbidden}"); + } + assert!(managed_run_path.is_file()); + assert!(pending_path.is_file()); + assert!(!committed_path.exists()); + assert!(!epitaph_path.exists()); + assert_eq!( + bounded(&replay_path, SMALL_EVIDENCE_CAP), + CONFLICTING_REPLAY + ); + assert_eq!(bounded(&vault_path, MAX_VAULT_BYTES), living_vault); + + let terminal = decode_saved(&harness, &pack, &run_id); + assert_eq!( + &terminal.events[..initial.events.len()], + initial.events.as_slice() + ); + assert_eq!(transfer_cycle_item(&terminal.events), selected_id); + assert_eq!( + terminal + .events + .iter() + .filter(|event| matches!( + event.kind, + SessionEventKind::Action(CommittedAction::ResolveAttack { .. }) + )) + .count(), + 4 + ); + let terminal_image = terminal.current.to_image().unwrap(); + let terminal_player = terminal_image + .actors + .iter() + .find(|actor| actor.id == player) + .expect("terminal Player"); + assert!(!terminal_player.alive && terminal_player.hp <= 0); + assert_eq!( + terminal_image.packs[&player].items[&selected_id], + selected_instance + ); + let terminal_hash = terminal.current.try_hash_state().unwrap(); + let terminal_bytes = bounded(&managed_run_path, MAX_COMPRESSED_BYTES); + let pending_bytes = bounded(&pending_path, MAX_MARKER_BYTES); + + let retained = || { + ( + bounded(&managed_run_path, MAX_COMPRESSED_BYTES), + bounded(&pending_path, MAX_MARKER_BYTES), + bounded(&replay_path, SMALL_EVIDENCE_CAP), + bounded(&vault_path, MAX_VAULT_BYTES), + ) + }; + let retained_before = ( + terminal_bytes, + pending_bytes, + CONFLICTING_REPLAY.to_vec(), + living_vault, + ); + let blocked_recover = Harness::run(recover_command(&harness)); + assert_exit(&blocked_recover, 3, "immutable-artifact-mismatch"); + assert_eq!(retained(), retained_before); + + fs::remove_file(&replay_path).expect("remove only regular replay obstacle"); + let recovered = Harness::run(recover_command(&harness)); + assert_success(&recovered, "independent Classic recovery"); + let recovered_records = Harness::records(&recovered); + let recovery_positions = milestone_positions( + &recovered_records, + &[ + "replay-durable", + "epitaph-durable", + "vault-death-durable", + "committed-death-durable", + "run-retired-durable", + "pending-retired-durable", + ], + ); + assert!(recovery_positions.windows(2).all(|pair| pair[0] < pair[1])); + assert!(!managed_run_path.exists()); + assert!(!pending_path.exists()); + assert!(committed_path.is_file()); + assert!(epitaph_path.is_file()); + + let replay_bytes = bounded(&replay_path, MAX_COMPRESSED_BYTES); + let replay = decode_replay(&replay_bytes, pack.hash()).expect("death-produced replay"); + assert_eq!(replay.identity.run_id, parsed_run_id); + assert_eq!(replay.events, terminal.events); + assert_eq!(transfer_cycle_item(&replay.events), selected_id); + assert_eq!(replay.expected_final_tick, terminal.current.tick); + assert_eq!(replay.expected_final_hash, terminal_hash); + assert_actual_replay(&harness, &replay_path, terminal_hash); + + let vault = decode_vault(&bounded(&vault_path, MAX_VAULT_BYTES)).unwrap(); + let receipt = vault + .applied_deaths + .get(&parsed_run_id) + .expect("death receipt for actual run"); + assert_eq!(receipt.terminal_core_hash, terminal_hash); + let selected_lot = vault + .current + .auction_lots + .iter() + .find(|lot| lot.origin_run_id == parsed_run_id && lot.origin_item_id == selected_id) + .expect("selected transferred instance has death provenance"); + assert_eq!(selected_lot.template_id, selected_instance.template); + assert_eq!(selected_lot.material, selected_instance.material); + assert_eq!(selected_lot.identified, selected_instance.identified); + assert_eq!(selected_lot.cursed, selected_instance.cursed); + assert_eq!(selected_lot.blessed, selected_instance.blessed); + assert_eq!(selected_lot.condition, selected_instance.condition); + assert_eq!(selected_lot.silvered, selected_instance.silvered); + assert_eq!(selected_lot.illegal, selected_instance.illegal); + assert_eq!(selected_lot.tags, selected_instance.tags); + assert_eq!(selected_lot.fuel, selected_instance.fuel); + assert_eq!(selected_lot.wet_ticks, selected_instance.wet_ticks); + + let stable_completed = || { + ( + bounded(&vault_path, MAX_VAULT_BYTES), + bounded(&replay_path, MAX_COMPRESSED_BYTES), + bounded(&epitaph_path, MAX_EPITAPH_BYTES), + bounded(&committed_path, MAX_MARKER_BYTES), + ) + }; + let completed = stable_completed(); + let repeated = Harness::run(recover_command(&harness)); + assert_success(&repeated, "idempotent repeated recovery"); + let repeated_codes = Harness::records(&repeated) + .iter() + .filter_map(|record| field(record, "code").as_str().map(str::to_owned)) + .collect::>(); + assert!(repeated_codes.iter().all(|code| ![ + "replay-durable", + "epitaph-durable", + "vault-death-durable", + "committed-death-durable", + "run-retired-durable", + "pending-retired-durable", + ] + .contains(&code.as_str()))); + assert_eq!(stable_completed(), completed); +} diff --git a/crates/hexwork-cli/tests/transfers_live.rs b/crates/hexwork-cli/tests/transfers_live.rs new file mode 100644 index 0000000..02940c1 --- /dev/null +++ b/crates/hexwork-cli/tests/transfers_live.rs @@ -0,0 +1,629 @@ +//! AC.11 production-process evidence for ordinary generated item transfers. +//! +//! Positive gameplay paths use only immutable worldgen plans and real binary +//! inputs. The PTY rejection case is an ordinary blocked move; it is not the +//! cfg(test)-only cursed-item fixture used by lower-level adapter tests. + +// Public within this test target so the shared harness retains its intended API. +#[path = "support/mod.rs"] +pub mod support; + +use hexwork_core::action::{CommittedAction, Direction}; +use hexwork_core::runtime::ActorRole; +use hexwork_save::{ + decode_run, encode_replay, read_file_bounded, DecodedRun, ProductionReplay, SessionEventKind, + MAX_COMPRESSED_BYTES, +}; +use hexwork_term::remap::DEFAULT_KEYMAP_TOML; +use hexwork_term::Input; +use hexwork_worldgen::{Cell, VoxelWrite}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs; +use std::path::Path; +use std::process::Command; + +use support::Harness; + +const SEED: u64 = 140; +const NAME: &str = "AC11 transfer delver"; + +#[derive(Clone, Debug)] +struct GeneratedRoute { + template: String, + cell: Cell, + quantity: u16, + inputs: Vec, +} + +fn only_run_id(harness: &Harness) -> String { + let mut runs = fs::read_dir(harness.data.join("runs")) + .unwrap() + .map(|entry| entry.unwrap()) + .filter(|entry| entry.file_type().unwrap().is_dir()) + .map(|entry| entry.file_name().into_string().unwrap()) + .collect::>(); + runs.sort(); + assert_eq!(runs.len(), 1, "expected one managed run: {runs:?}"); + runs.pop().unwrap() +} + +fn decode_managed_run(harness: &Harness, run_id: &str) -> DecodedRun { + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + decode_run( + &read_file_bounded( + harness.data.join("runs").join(run_id).join("run.save"), + MAX_COMPRESSED_BYTES, + ) + .unwrap(), + pack.hash(), + ) + .unwrap() +} + +fn player_id(run: &DecodedRun) -> u64 { + run.current + .actors + .iter() + .find(|actor| { + run.current + .identity(actor.id) + .is_some_and(|identity| identity.role == ActorRole::Player) + }) + .expect("one saved Player") + .id +} + +fn run_ok(command: Command, label: &str) -> std::process::Output { + let output = Harness::run(command); + assert!( + output.status.success(), + "{label}: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn explicit_new_command(harness: &Harness, script: &Path) -> Command { + let mut command = harness.managed(script); + command + .arg("--seed") + .arg(SEED.to_string()) + .arg("--name") + .arg(NAME) + // CLI choices are one-based: 1 = Mudborn, 1 = Ratcatcher (three-item kit). + .arg("--ancestry") + .arg("1") + .arg("--calling") + .arg("1"); + command +} + +fn input_name(input: Input) -> &'static str { + match input { + Input::Up => "north", + Input::Down => "south", + Input::Left => "west", + Input::Right => "east", + _ => panic!("route contains non-cardinal input: {input:?}"), + } +} + +fn physical_key(input: Input) -> char { + match input { + Input::Up => 'w', + Input::Down => 's', + Input::Left => 'a', + Input::Right => 'd', + _ => panic!("route contains non-cardinal input: {input:?}"), + } +} + +fn append_route_script(body: &mut String, route: &[Input]) { + for input in route { + body.push_str("input "); + body.push_str(input_name(*input)); + body.push('\n'); + } +} + +fn voxel_at(writes: &[VoxelWrite], cell: Cell) -> hexwork_core::voxel::Voxel { + writes + .binary_search_by_key(&(cell[2], cell[1], cell[0]), |write| { + (write.cell[2], write.cell[1], write.cell[0]) + }) + .ok() + .map(|index| writes[index].voxel) + .unwrap_or_default() +} + +fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { + let body = voxel_at(writes, cell); + let head = voxel_at(writes, [cell[0], cell[1], cell[2] + 1]); + let below = voxel_at(writes, [cell[0], cell[1], cell[2] - 1]); + body.occ == 0 && head.occ == 0 && below.occ != 0 && body.fluid_amt <= 2 +} + +fn route_between( + writes: &[VoxelWrite], + start: Cell, + goal: Cell, + blockers: &BTreeSet, +) -> Option> { + if start == goal { + return Some(Vec::new()); + } + let directions = [ + (0, 1, Input::Up), + (-1, 0, Input::Left), + (1, 0, Input::Right), + (0, -1, Input::Down), + ]; + let mut queue = VecDeque::from([start]); + let mut previous = BTreeMap::new(); + let mut seen = BTreeSet::from([start]); + while let Some(cell) = queue.pop_front() { + for (dx, dy, input) in directions { + let next = [cell[0] + dx, cell[1] + dy, cell[2]]; + if seen.contains(&next) || blockers.contains(&next) || !passable(writes, next) { + continue; + } + previous.insert(next, (cell, input)); + if next == goal { + let mut route = Vec::new(); + let mut cursor = goal; + while cursor != start { + let (prior, direction) = previous[&cursor]; + route.push(direction); + cursor = prior; + } + route.reverse(); + return Some(route); + } + seen.insert(next); + queue.push_back(next); + } + } + None +} + +fn generated_route(seed: u64) -> GeneratedRoute { + let town = hexwork_worldgen::milthollow(seed); + let blockers = town + .actors + .iter() + .map(|actor| actor.position) + .collect::>(); + town.items + .iter() + .filter_map(|item| { + route_between(&town.writes, town.start, item.position, &blockers).map(|inputs| { + GeneratedRoute { + template: item.template_id.clone(), + cell: item.position, + quantity: item.quantity, + inputs, + } + }) + }) + .min_by_key(|route| (route.inputs.len(), route.cell)) + .expect("immutable town plan has reachable ordinary generated loot") +} + +fn blocked_move_route(seed: u64) -> (Vec, Input, Input) { + let town = hexwork_worldgen::milthollow(seed); + let blockers = town + .actors + .iter() + .map(|actor| actor.position) + .collect::>(); + let directions = [ + (0, 1, Input::Up), + (-1, 0, Input::Left), + (1, 0, Input::Right), + (0, -1, Input::Down), + ]; + let mut queue = VecDeque::from([town.start]); + let mut previous = BTreeMap::new(); + let mut seen = BTreeSet::from([town.start]); + while let Some(cell) = queue.pop_front() { + for (dx, dy, rejected) in directions { + let wall = [cell[0] + dx, cell[1] + dy, cell[2]]; + let body = voxel_at(&town.writes, wall); + let head = voxel_at(&town.writes, [wall[0], wall[1], wall[2] + 1]); + if body.occ == 0 && head.occ == 0 { + continue; + } + if let Some((_, _, legal)) = directions.into_iter().find(|(lx, ly, _)| { + let next = [cell[0] + lx, cell[1] + ly, cell[2]]; + next != wall && passable(&town.writes, next) && !blockers.contains(&next) + }) { + let route = route_between(&town.writes, town.start, cell, &blockers).unwrap(); + return (route, rejected, legal); + } + } + for (dx, dy, input) in directions { + let next = [cell[0] + dx, cell[1] + dy, cell[2]]; + if seen.contains(&next) || blockers.contains(&next) || !passable(&town.writes, next) { + continue; + } + previous.insert(next, (cell, input)); + seen.insert(next); + queue.push_back(next); + } + } + panic!("immutable town plan has no ordinary blocked-move witness") +} + +fn generated_item_ids(run: &DecodedRun, route: &GeneratedRoute) -> Vec { + run.current + .ground_items + .iter() + .filter_map(|(&id, &cell)| { + (cell == tuple(route.cell) + && run + .current + .items + .get(&id) + .is_some_and(|item| item.template == route.template)) + .then_some(id) + }) + .collect() +} + +fn tuple(cell: Cell) -> (i32, i32, i32) { + (cell[0], cell[1], cell[2]) +} + +fn inventory_slot(run: &DecodedRun, item_id: u64) -> usize { + run.current.packs[&player_id(run)] + .items + .keys() + .position(|id| *id == item_id) + .expect("selected item is carried") + + 1 +} + +fn action_events(run: &DecodedRun) -> Vec<&CommittedAction> { + run.events + .iter() + .map(|event| match &event.kind { + SessionEventKind::Action(action) => action, + other => panic!("unexpected lifecycle event in living Classic run: {other:?}"), + }) + .collect() +} + +#[test] +fn binary_transfer_route_save_load_and_replay() { + let harness = Harness::new("transfers-live-headless"); + let route = generated_route(SEED); + assert!(route.inputs.len() < 128, "generated route stays bounded"); + + // Capture the actual initialized binary state before transfer. This is a + // living run artifact, not a fixture or a hand-built State. + let baseline_script = harness.script("baseline.commands", "observe state\nsave\nquit\n"); + run_ok( + explicit_new_command(&harness, &baseline_script), + "baseline process", + ); + let run_id = only_run_id(&harness); + let baseline = decode_managed_run(&harness, &run_id); + assert_eq!(baseline.identity.seed, SEED); + assert_eq!(baseline.identity.display_name, NAME); + assert!(baseline.events.is_empty()); + let candidates = generated_item_ids(&baseline, &route); + assert_eq!(candidates.len(), usize::from(route.quantity)); + let item_id = *candidates.first().expect("generated candidate ID"); + let exact_instance = baseline.current.items[&item_id].clone(); + let baseline_next_id = baseline.current.to_image().unwrap().next_id; + + let mut pickup_body = String::new(); + append_route_script(&mut pickup_body, &route.inputs); + pickup_body.push_str("input pickup\ninput confirm\nobserve inventory\n"); + let expected_slot = baseline.current.packs[&player_id(&baseline)].items.len() + 1; + pickup_body.push_str(&format!( + "input inventory\ninput pick{expected_slot}\ninput confirm\ninput look\nobserve state\nsave\nquit\n" + )); + let pickup_script = harness.script("pickup-generated.commands", &pickup_body); + let pickup_output = run_ok( + { + let mut command = harness.managed(&pickup_script); + command.arg("--load").arg(&run_id); + command + }, + "generated pickup process", + ); + let pickup_records = Harness::records(&pickup_output); + let pickup_inventory = pickup_records + .iter() + .find(|record| record["detail"] == "inventory") + .expect("post-pickup inventory observation"); + assert!(pickup_inventory["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry.as_str().unwrap().starts_with(&format!("{item_id}:")))); + + let picked = decode_managed_run(&harness, &run_id); + let picked_player = player_id(&picked); + assert_eq!( + picked.current.packs[&picked_player].items[&item_id], + exact_instance + ); + assert!(!picked.current.items.contains_key(&item_id)); + assert!(!picked.current.ground_items.contains_key(&item_id)); + assert_eq!(picked.current.to_image().unwrap().next_id, baseline_next_id); + let picked_hash = picked.current.try_hash_state().unwrap(); + let pickup_index = picked + .events + .iter() + .position(|event| { + matches!( + event.kind, + SessionEventKind::Action(CommittedAction::Pickup { item_id: actual }) + if actual == item_id + ) + }) + .expect("recorded generated pickup"); + assert_eq!( + picked.events[pickup_index].pre_tick, + picked.events[pickup_index].post_tick + ); + let picked_events = picked.events.clone(); + + // This independent process restores the authoritative pickup snapshot, + // selects and inspects that same stable ID, then drops it through the real UI. + let selected_slot = inventory_slot(&picked, item_id); + let drop_script = harness.script( + "inspect-drop-generated.commands", + &format!( + "input inventory\ninput pick{selected_slot}\ninput confirm\ninput look\ninput drop\ninput confirm\nobserve inventory\nobserve state\nsave\nquit\n" + ), + ); + let drop_output = run_ok( + { + let mut command = harness.managed(&drop_script); + command.arg("--load").arg(&run_id); + command + }, + "independent inspect/drop process", + ); + let dropped = decode_managed_run(&harness, &run_id); + let dropped_player = player_id(&dropped); + assert_eq!(dropped.current.items[&item_id], exact_instance); + assert_eq!(dropped.current.ground_items[&item_id], tuple(route.cell)); + assert!(!dropped.current.packs[&dropped_player] + .items + .contains_key(&item_id)); + assert_eq!( + dropped.current.to_image().unwrap().next_id, + baseline_next_id + ); + assert_eq!( + &dropped.events[..picked_events.len()], + picked_events.as_slice() + ); + assert!(matches!( + dropped.events.last().unwrap().kind, + SessionEventKind::Action(CommittedAction::Drop { item_id: actual }) if actual == item_id + )); + assert_eq!( + dropped.events.last().unwrap().pre_tick, + dropped.events.last().unwrap().post_tick + ); + let final_hash = dropped.current.try_hash_state().unwrap(); + let final_tick = dropped.current.tick; + let state_observation = Harness::records(&drop_output) + .into_iter() + .find(|record| { + record["detail"] + .as_str() + .is_some_and(|detail| detail.starts_with("mode=")) + }) + .expect("post-drop state observation"); + assert_eq!( + state_observation["canonical_hash"].as_u64(), + Some(final_hash) + ); + assert_eq!( + state_observation["count"].as_u64(), + Some(dropped.events.len() as u64) + ); + assert_ne!( + picked_hash, final_hash, + "drop must change exact ownership state" + ); + + // Replay origin: all identity/events/tick/hash fields below come from the + // decoded durable binary-recorded living run. No event or State is fabricated. + let replay = ProductionReplay { + identity: dropped.identity.clone(), + events: dropped.events.clone(), + expected_final_tick: final_tick, + expected_final_hash: final_hash, + }; + let replay_path = harness.root.join("living-transfer.hexdemo"); + fs::write(&replay_path, encode_replay(&replay).unwrap()).unwrap(); + let replayed = run_ok( + { + let mut command = harness.command(); + command + .arg("--content-root") + .arg(&harness.content) + .arg("--replay") + .arg(&replay_path); + command + }, + "actual replay process", + ); + let replay_text = String::from_utf8(replayed.stdout).unwrap(); + assert!(replay_text.contains("verified event-text replay")); + assert!(replay_text.contains(&format!("hash {final_hash:#x}"))); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn run_pty(harness: &Harness, mode: &str, fixture: &serde_json::Value) -> serde_json::Value { + let fixture_path = harness.root.join(format!("{mode}.json")); + fs::write(&fixture_path, serde_json::to_vec(fixture).unwrap()).unwrap(); + let scenario = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/pty/transfers_live.py"); + let output = run_ok( + { + let mut command = Command::new("python3"); + command + .current_dir(&harness.cwd) + .arg(scenario) + .arg(env!("CARGO_BIN_EXE_hexwork")) + .arg(&harness.data) + .arg(&harness.content) + .arg(mode) + .arg(fixture_path); + command + }, + &format!("{mode} PTY driver"), + ); + serde_json::from_slice(&output.stdout).expect("bounded PTY JSON summary") +} + +fn transfer_fixture(route: &GeneratedRoute) -> serde_json::Value { + serde_json::json!({ + "seed": SEED, + "route_keys": route.inputs.iter().copied().map(physical_key).collect::(), + "route_len": route.inputs.len(), + "template": route.template, + "cell": route.cell, + // Ratcatcher starts with three items; the acquired generated item is fourth. + "item_slot": 4, + }) +} + +fn assert_pty_transfer_artifact( + harness: &Harness, + summary: &serde_json::Value, + route: &GeneratedRoute, +) { + assert_eq!(summary["exit"], 0); + assert_eq!(summary["window_80x24"], true); + assert_eq!(summary["prompt_visible"], true); + assert_eq!(summary["cancel_visible"], true); + assert_eq!(summary["inspect_visible"], true); + assert_eq!(summary["drop_prompt_visible"], true); + assert_eq!(summary["session_over"], true); + let run_id = summary["run_id"].as_str().unwrap(); + let saved = decode_managed_run(harness, run_id); + let actions = action_events(&saved); + assert_eq!( + actions.len(), + route.inputs.len() + 2, + "cancel/inspect add no event" + ); + for (actual, expected) in actions.iter().zip(&route.inputs) { + let expected = match expected { + Input::Up => Direction::North, + Input::Down => Direction::South, + Input::Left => Direction::West, + Input::Right => Direction::East, + _ => unreachable!(), + }; + assert!(matches!(actual, CommittedAction::Move(direction) if *direction == expected)); + } + let item_id = match actions[route.inputs.len()] { + CommittedAction::Pickup { item_id } => *item_id, + other => panic!("expected pickup after route, got {other:?}"), + }; + assert!(matches!( + actions[route.inputs.len() + 1], + CommittedAction::Drop { item_id: actual } if *actual == item_id + )); + let player = player_id(&saved); + let item = saved + .current + .items + .get(&item_id) + .expect("same item is loose after drop"); + assert_eq!(item.template, route.template); + assert_eq!(saved.current.ground_items[&item_id], tuple(route.cell)); + assert!(!saved.current.packs[&player].items.contains_key(&item_id)); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn binary_pty_transfer_prompt_cancel_confirm_and_rejection() { + let route = generated_route(SEED); + let transfer_harness = Harness::new("transfers-live-pty-default"); + let summary = run_pty(&transfer_harness, "transfer", &transfer_fixture(&route)); + assert_eq!(summary["default_pickup_hint_visible"], true); + assert_eq!(summary["default_drop_hint_visible"], true); + assert_pty_transfer_artifact(&transfer_harness, &summary, &route); + + // Separate ordinary movement rejection: no curse fixture exists in the + // shipped binary. The rejected wall move adds no event, while the legal key + // in the same drained PTY input batch still commits and the session continues. + let rejection_harness = Harness::new("transfers-live-pty-move-rejection"); + let (prefix, rejected, legal) = blocked_move_route(SEED); + let rejection = run_pty( + &rejection_harness, + "rejection", + &serde_json::json!({ + "seed": SEED, + "prefix_keys": prefix.iter().copied().map(physical_key).collect::(), + "rejected_key": physical_key(rejected).to_string(), + "legal_key": physical_key(legal).to_string(), + "prefix_len": prefix.len(), + }), + ); + assert_eq!(rejection["exit"], 0); + assert_eq!(rejection["window_80x24"], true); + assert_eq!(rejection["ordinary_rejection_visible"], true); + assert_eq!(rejection["session_over"], true); + let rejected_run = decode_managed_run( + &rejection_harness, + rejection["run_id"].as_str().expect("rejection run ID"), + ); + let actions = action_events(&rejected_run); + assert_eq!(actions.len(), prefix.len() + 1); + assert!(!actions.iter().any(|action| matches!( + action, + CommittedAction::Pickup { .. } | CommittedAction::Drop { .. } + ))); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn binary_pty_optional_transfer_keymaps() { + let route = generated_route(SEED); + let remapped = Harness::new("transfers-live-pty-remapped"); + fs::create_dir_all(&remapped.data).unwrap(); + let remapped_text = DEFAULT_KEYMAP_TOML + .replace("pickup = [\"g\"]", "pickup = [\"r\"]") + .replace("drop = [\"-\"]", "drop = [\"v\"]"); + fs::write(remapped.data.join("keymap.toml"), remapped_text).unwrap(); + let summary = run_pty(&remapped, "remapped", &transfer_fixture(&route)); + assert_eq!(summary["remapped_pickup_hint_visible"], true); + assert_eq!(summary["remapped_drop_hint_visible"], true); + assert_pty_transfer_artifact(&remapped, &summary, &route); + + let legacy = Harness::new("transfers-live-pty-legacy"); + fs::create_dir_all(&legacy.data).unwrap(); + let legacy_text = DEFAULT_KEYMAP_TOML + .lines() + .filter(|line| !line.starts_with("pickup = ") && !line.starts_with("drop = ")) + .chain(std::iter::once("")) + .collect::>() + .join("\n"); + fs::write(legacy.data.join("keymap.toml"), legacy_text).unwrap(); + let legacy_summary = run_pty(&legacy, "legacy", &serde_json::json!({ "seed": SEED })); + assert_eq!(legacy_summary["exit"], 0); + assert_eq!(legacy_summary["window_80x24"], true); + assert_eq!(legacy_summary["transfer_hints_absent"], true); + assert_eq!(legacy_summary["g_minus_unbound"], true); + assert_eq!(legacy_summary["session_over"], true); + let saved = decode_managed_run( + &legacy, + legacy_summary["run_id"].as_str().expect("legacy run ID"), + ); + assert!( + saved.events.is_empty(), + "unbound transfer keys committed no action" + ); +} diff --git a/docs/06-implementation-checklist.md b/docs/06-implementation-checklist.md index 46ad923..df9c5f3 100644 --- a/docs/06-implementation-checklist.md +++ b/docs/06-implementation-checklist.md @@ -165,13 +165,14 @@ Checked historical items below describe earlier component milestones, not presen ## Phase 12 — QoL, accessibility, audio [03 §3.10; 05 §5.4–5.5; 01 §7] -**Status:** `[code: yes] [integrated: partial] [gate: partial]` — accepted live slices now include rooted physical-key remapping, persisted gamma/FOV/five-palette boot controls, bounded recent feedback with typed atomic input rejection, and a read-only carried Inventory/Look browser. Independent reviews, actual PTY/headless evidence, and final workspace gates passed for these slices. In-game sliders, full history/search, screenreader/undo/dyslexia controls, audio wiring, and live accessibility signoff remain open; component palette tests are not certification. +**Status:** `[code: yes] [integrated: partial] [gate: partial]` — accepted live slices now include rooted physical-key remapping, persisted gamma/FOV/five-palette boot controls, bounded recent feedback with typed atomic input rejection, and a carried Inventory/Look browser with separate confirmed own-cell pickup/drop. Independent reviews, actual PTY/headless evidence, and final workspace gates passed for these slices. Transfers include shared structural-limit preflight, additive v4 history, and real Story/Classic process lifecycle proofs; this is not equipment/item-use/economy completion. In-game sliders, full history/search, screenreader/undo/dyslexia controls, audio wiring, and live accessibility signoff remain open; component palette tests are not certification. - [x] Boot presentation controls: interactive `--gamma`, `--fov`, `--palette` apply to actual frames and persist explicit choices in bounded `/presentation.json`; defaults do not create a file. Headless/recovery/replay ignore optional preferences and reject explicit presentation overrides. This is not an in-game sliders gate. - [ ] Kindness layer: pause-step hybrid, undo-able interface mistakes (never sim), persistent tooltips, in-game gamma/FOV/shake sliders - [ ] Palettes: protan/deutan/tritan-safe + high-contrast + no-flicker with measured contrast tests; threat/loot/exit never hue-only - [x] Bounded live recent-feedback panel: four critical chrome rows plus header/three message rows at 80×24; cell-safe wrapping, stale-row clearing, tooltip-title inventory summaries, and atomic input-rejection feedback. This does not complete searchable history or semantic narration. -- [x] Read-only carried Inventory/Look browser: bounded six-item stable-ID pages beyond the old 64-entry presentation prefix, scrollable bounded tooltips, correct modal/clock/lifecycle behavior, loaded-key hints, opaque display and true full redraw on close. Actual PTY/headless tests prove browsing adds no committed action. No item transfer/equipment/world-look claim. +- [x] Carried Inventory/Look browser: bounded six-item stable-ID pages beyond the old 64-entry presentation prefix, scrollable bounded tooltips, correct modal/clock/lifecycle behavior, loaded-key hints, opaque display and true full redraw on close. Browsing/inspection alone adds no committed action; mutation uses the separate confirmed transfer path below. No equipment/world-look completion claim. +- [x] Confirmed live own-cell pickup/drop: exact-ID atomic ownership updates, shared structural admission, optional g/- bindings preserving legacy maps, explicit prompts and batch barriers, and lifecycle-first handling. Real generated-loot headless/80×24 PTY tests prove pickup/inspection/drop, save/load and replay; real Story checkpoint rollback and Classic death/recovery preserve transfer history/ownership. Raw/compressed-byte/proof/durability failures remain fatal save-boundary failures, not rolled-back input rejection. Cursed-drop rejection is Core/Application/batch fixture evidence, not ordinary cursed-gear acquisition; no item physics, gear bonuses, sale/buyback or full-loop completion claim. - [ ] Screenreader event stream (threat>exit>loot>flavor, throttled, interruptible); scripted winnability-from-stream test - [x] Boot-time remappable physical keys: optional `/keymap.toml`, 64 KiB/type-checked loader, missing-only embedded fallback, shared immutable chargen/play mapping and truthful labels. Actual PTY tests decode selected identity and west/east moves across save/load. No live rebind or all-terminal certification implied. - [ ] Reduced-motion, dyslexia log layout, colorblind live certification diff --git a/docs/live-integration-plan.md b/docs/live-integration-plan.md index de26e1b..2d9cc63 100644 --- a/docs/live-integration-plan.md +++ b/docs/live-integration-plan.md @@ -395,7 +395,7 @@ Bounded live recent feedback is now accepted too: cell-safe chronological recent A real read-only carried Inventory/Look browser is now accepted, with bounded stable-ID paging, truthful bounded tooltips, modal lifecycle/clock handling and actual PTY/headless purity evidence. Independent query/UI/caller closures and parent final workspace gates passed. Closing and first-world rendering use true all-cell redraw, including blank cells; opaque clearing covers the physical viewport beyond UI caps. -Pickup/drop mutation and any replay-version change are the next planning candidate and require explicit all-table atomicity and compatibility decisions; no destructive format bump is implied by research. In-game settings UI, world-look, screenreader/audio integration, and live visual/accessibility certification remain open. Tactical slice-selection range is still a separate policy gap. Parent review and a concrete next-node contract precede further edits. +Confirmed own-cell pickup/drop is now accepted; see phase12-wiring-plan.md T.0–T.13. Core/Session atomically move exact instances between existing ownership tables after shared structural preflight; tags 9/10 deliberately extend the existing v4 action vocabulary without changing state hashing or invalidating genuine old-v4 fixtures. Explicit prompts, optional bindings and terminal batch barriers are live. Ordinary generated-loot binary/80×24 PTY tests, independent load/replay, actual Story checkpoint rollback and Classic terminal/pending/archive-conflict recovery are reviewed and passing. Later raw/compressed-byte/proof/filesystem failures remain fatal save-boundary failures and do not roll back committed actions. Cursed-drop rejection has fixture-backed Core/App/batch evidence only; no ordinary cursed-gear acquisition, equipment effects, item physics, full sale/buyback or broad playable-loop completion is claimed. In-game settings UI, world-look, screenreader/audio integration, and live visual/accessibility certification remain open. Tactical slice-selection range is still a separate policy gap. Next priority is a focused first-session usability playthrough before selecting another large system. ### Review Strategy diff --git a/docs/phase12-wiring-plan.md b/docs/phase12-wiring-plan.md index 29823a3..558a42b 100644 --- a/docs/phase12-wiring-plan.md +++ b/docs/phase12-wiring-plan.md @@ -314,7 +314,7 @@ No Core action, item ownership, save/replay schema, or keymap migration was intr ## Accepted handoff: atomic pickup/drop with additive v4 history -Status: **accepted handoff; T.0 pretransfer-v4 fixture prerequisite complete**. Parent-grounded after accepted browser `7834d52`; full independent plan closure found no remaining substantive defects after the T.1 structural/save boundary correction. Parent accepted T.8's scratch measurements as feasibility evidence, not final implementation/performance evidence, and T.9's genuine artifact/provenance/tests after final workspace gates. Actual Core transfer, virtual event append and combined dispatch measurements remain integration acceptance requirements. Core/Save/Term/pure-UI/Session foundations are implemented as recorded in T.10; live caller integration remains unimplemented. Continue in the ordered ownership stages below. This is a narrow acquisition/placement slice, not completion of HAUL, equipment, world-look, or Phase 12. +Status: **accepted live transfer slice; final combined workspace gates passed**. Parent-grounded after accepted browser `7834d52`; full independent plan closure found no remaining substantive defects after the T.1 structural/save boundary correction. Parent accepted T.8's scratch measurements as feasibility evidence, not final implementation/performance evidence, and T.9's genuine artifact/provenance/tests after final workspace gates. Actual Core transfer, virtual event append and combined dispatch measurements remain integration acceptance requirements. Core/Save/Term/pure-UI/Session foundations are recorded in T.10; live caller review and process acceptance are recorded in T.12–T.13. Historical handoff stages below are retained as the verification contract, not outstanding implementation tasks. This is a narrow acquisition/placement slice, not completion of HAUL, equipment, world-look, or Phase 12. ### T.0 Evidence and policy decisions @@ -487,6 +487,34 @@ The actual release benchmark now takes nine samples per case of prepare_transfer Parent final foundation gates passed: workspace formatting, locked strict workspace all-target clippy, and locked workspace all-target tests, run with a 7,200-second tool ceiling and complete retained output. Immutable old-v4 fixtures remain unchanged. This accepts a coherent foundations checkpoint only. Live App/Entry/headless transfer wiring, confirmation/error batch barriers, optional modal hints, composed-terminal close proofs, actual pickup/drop PTY/headless routes and transfer-bearing Story/Classic process lifecycle evidence remain the next integration stage; no playable-loop or whole-phase gate is claimed. +### T.11 Live caller handoff contracts + +Application owner: `app.rs`, plus exactly the three outer Session query-guard removals. Entry/headless owner: `entry.rs` and `frontend_support.rs` with their inline adapter/render/parser tests. A separate process-evidence owner follows reviewed caller code and owns new transfer process/PTY tests plus minimal test-root registration. Parent owns user-facing HELP text/tests and the verified fixture-provenance label correction; no runtime parse or fixture-byte change is implied by those edits. Neither edits Core/Save/Term/pure UI or fixture bytes. Shared crate-private APIs are `InputBarrier::{None, DiscardDrainedTerminalBatch}` (Copy/Eq), `DispatchResult.barrier: InputBarrier`, `AppError::input_barrier(&self) -> InputBarrier`, `TransferPromptSnapshot { content: TransferPromptContent, layout: TransferPromptLayout }`, `Application::transfer_prompt_view(width, height) -> Result, AppError>` and `Application::transfer_prompt_kind() -> Option`. Generic/fatal AppError construction always has barrier None. Only direct transfer input rejection attaches Discard; preserve Result-based headless failure. Entry may return a distinct private PresentationBarrier batch result; it must not reset the scheduler/last-frame clock as lifecycle rollback does. + +Failed opening attempts with no target (Pickup in Play/Map or Drop in an empty carried browser) also consume one UI action and discard the current terminal batch tail, preventing a queued Confirm from becoming an unrelated contextual action. They log factual empty feedback without a committed action. Drop in ordinary Play remains ignored, without selecting an implicit item. Querying/drawing a stale selected item never substitutes another ID: render a neutral unavailable/position-changed explanation, and confirmation cancels. Explicit pickup navigation may select another ID only at the unchanged original player cell; cancel with feedback/barrier if that cell changed. Rendering performs only direct selected-ID lookup, never the whole-ground query. + +If required for Application/Entry rejection tests, the Application owner may add one narrowly named cfg(test)-only Session fixture setter for a selected carried item's cursed flag and one cfg(test)-only Application forwarding helper `curse_selected_inventory_item_for_test() -> Result`. It must validate the constructed fixture, expose no production CLI/debug flag, and never count as ordinary acquisition or replay evidence. Parent found only `black_iron_hatchet` marked cursed in the content pack and no current spawn/kit references to it; do not change worldgen/content to fabricate a route or break the v4 baseline. Real binary/PTY proofs use ordinary generated loot and may separately demonstrate an ordinary recoverable movement rejection; actual transfer-rejection batch behavior is pinned through real Application/batch adapters with explicitly synthetic curse setup. Report this distinction rather than claiming gameplay-acquired cursed gear. Positive pickup/drop/save/load/replay and Story/Classic lifecycle proofs must use ordinary routes and real recorded actions. + +### T.12 Live caller verification checkpoint — caller reviews accepted; process gates pending + +Entry/headless runtime, parser, batch barriers, modal hints and composed-terminal rendering are independently accepted at their scoped level; formal generated-loot/PTY/lifecycle process tests remain the separate next node. Application review found a real pending-death precedence bug: an active prompt could attempt a transfer and fail dead-actor validation before normal lifecycle handling. Closure now checks liveness before modal charging/routing and runs normal lifecycle/signal completion. Independent review accepted real recorded Story/Classic death batch regressions, fatal pre-publication managed-type-conflict save failure with committed ownership/dirty state retained and successful retry, normal save/load prompt reset, exact empty/stale budget charging and first-survivor browser restoration. Final default-parallel CLI lib test run passed 206 tests with one pre-existing ignored test; strict clippy and owned formatting passed. This accepts caller code only, not the formal process gates or final combined workspace snapshot. Two disjoint auto-discovered integration test targets (`transfers_live.rs` plus its PTY driver, and `transfers_lifecycle.rs`) now own the remaining proof work without runtime, Cargo or root-registration edits. + +Parent preliminary real-binary smoke (not a checked-in AC.11 test): seed 140, name `transfer smoke`, Mudborn/Ratcatcher, ordinary headless Inventory→Drop→Confirm→close→Pickup→Confirm. Observed inventory counts 3→2→3 and journals 0→1→2; canonical hash changed from 12658691636566435834 to 18239729260302020971 after Drop, then returned to the initial hash after Pickup. A second process loaded the same saved hash, two events and exact observed inventory. This is starter-item cycle/save/load evidence only, not generated-loot, PTY, full Instance comparison, replay or lifecycle acceptance. + +Parent also corrected a human-readable v4 fixture provenance error: `--calling 1` is Ratcatcher, not Lampbearer. Direct decoded ancestry/calling assertions for all three immutable artifacts and byte-identical re-encoding passed; only the provenance label and test assertions changed, never fixture bytes. HELP text/test now describe explicit transfer confirmation and optional bindings. Earlier whole-package formatter invocations touched peer-owned whitespace during implementation; final owners/reviewers audit actual semantic diffs and use scoped formatting, not blind reverts. + +### T.13 Final live-transfer acceptance + +Caller reviews and AC.11/AC.12 process-evidence reviews are accepted. Parent final combined command passed workspace formatting, locked strict all-target clippy and locked all-target tests, including the new auto-discovered `transfers_live` and `transfers_lifecycle` targets, with a 7,200-second ceiling. This is the post-last-code/test-edit gate; earlier incomplete, pre-fix, cancelled and pre-last-edit runs are not substituted for it. + +AC.11 uses ordinary generated Milthollow loot identified by immutable worldgen template/cell/quantity and the actual initial managed save's stable IDs. Separate real binary processes perform pickup/confirmation, load, inspection, drop of the same exact Instance/ID, save and replay verification. Existing recorded history, ownership maps, next_id, action spans and final hashes are asserted. Actual 80×24 fd-PTY tests cover visible prompts/cancellation/inspection/drop, default and remapped controls, legacy optional-absent maps, saved event sequences and a separate ordinary blocked-move rejection followed by legal movement. The driver has a 300-second deadline, 4 MiB capture cap, child-status plus PTY-EOF draining, and cleanup; outer Harness bounds are more generous. Emitted-transcript checks prove real labels were rendered, not a reconstructed final screen matrix; composed terminal-cell clearing is separately tested by Entry. Living-run replay files are encoded from actual durable recorded history and verified by the real binary, not claimed to be death-emitted archives. + +AC.12 uses explicit seed 223 / Mudborn / Ratcatcher and ordinary binary commands. Story transfer events before a genuine Deep→Town checkpoint and after it are retained through real lethal combat/resurrection; decoded restored ownership matches checkpoint maps, with chapel debt asserted separately. Replay is derived from that actual durable history and verified by the binary. Classic's real transfer-bearing death produces terminal save and pending intent before a conflicting regular archive file triggers immutable-artifact-mismatch. A blocked independent recovery preserves evidence bytes; removal of only the obstacle followed by another recovery publishes the ordered durable artifacts and retires run/pending. The death-produced replay, receipt, selected-item auction provenance and repeated recovery idempotence are checked. Lethal routing targets the generated Grate Mother neighborhood, while the journal assertions establish actual attacks/death rather than pinning every attack's target ID; no stronger target-specific claim is required here. + +All three old-v4 binary fixture SHA-256 values remain pinned and unchanged. The provenance text's calling label was deliberately corrected using decoded identity evidence; that correction is not artifact regeneration. No format/hash version, content pack, global replay budget or automatic config migration was changed by caller integration. Synthetic cursed-drop setup remains confined to Core/App/batch tests; no ordinary cursed-item acquisition, all-debt/equipment semantics, item physics, sale/buyback, full accessibility, cross-platform performance or 1.0 ship claim follows from this slice. + +Next: a short first-session usability playthrough to identify the highest-impact player-facing blockers before selecting another major implementation node. + ## Historical component integration sketch Two workstreams, disjoint files, one merge point. WS1 (a11y) owns -- 2.54.0