diff --git a/crates/hexwork-cli/src/app.rs b/crates/hexwork-cli/src/app.rs index b860ab0..2e370f5 100644 --- a/crates/hexwork-cli/src/app.rs +++ b/crates/hexwork-cli/src/app.rs @@ -14,6 +14,7 @@ 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::Screen; use crate::loop_::AutosaveCadence; @@ -25,7 +26,8 @@ use crate::presentation_settings::{ use crate::replay::GameSessionReplayVerifier; use crate::session::{ reconcile_dispatch, BurdenPresentation, CarriedLampPresentation, ControllerEvent, GameSession, - PlayController, PlayerLiveness, SessionError, SessionErrorClass, TickOutcome, + InventoryPage, InventoryPageQuery, PlayController, PlayerLiveness, SessionError, + SessionErrorClass, TickOutcome, }; use crate::signals::{interrupted, SignalController}; @@ -161,12 +163,57 @@ pub(crate) struct PresentationSnapshot { pub wall_clock_paused: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum InventoryBrowserOrigin { + Inventory, + DirectLook, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct InventoryBrowserState { + origin: InventoryBrowserOrigin, + mode: BrowserMode, + page_anchor: Option, + selected_id: Option, + selected_slot: Option, + detail_offset: usize, + last_viewport_width: usize, + last_viewport_height: usize, + last_body_height: usize, +} + +impl InventoryBrowserState { + fn new(origin: InventoryBrowserOrigin) -> Self { + Self { + origin, + mode: match origin { + InventoryBrowserOrigin::Inventory => BrowserMode::List, + InventoryBrowserOrigin::DirectLook => BrowserMode::Detail, + }, + page_anchor: None, + selected_id: None, + selected_slot: None, + detail_offset: 0, + last_viewport_width: 80, + last_viewport_height: 16, + last_body_height: 12, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InventoryBrowserSnapshot { + pub content: BrowserContent, + pub layout: BrowserLayout, +} + #[derive(Clone, Copy, Debug, PartialEq)] struct PresentationState { camera: Camera, orbit_yaw: f32, tactical_z: Option, screen: Screen, + inventory_browser: Option, } impl Default for PresentationState { @@ -176,6 +223,7 @@ impl Default for PresentationState { orbit_yaw: 0.0, tactical_z: None, screen: Screen::Play, + inventory_browser: None, } } } @@ -248,6 +296,8 @@ pub(crate) struct Application { logs: Vec, #[cfg(test)] canonical_scan_count: usize, + #[cfg(test)] + inventory_test_detail: Option, } impl Application { @@ -353,6 +403,8 @@ impl Application { logs: vec!["The managed lantern is lit.".into()], #[cfg(test)] canonical_scan_count: 1, + #[cfg(test)] + inventory_test_detail: None, }) } @@ -408,7 +460,11 @@ impl Application { } pub(crate) fn presentation_screen(&self) -> Screen { - self.presentation.screen + match self.presentation.inventory_browser { + Some(browser) if browser.mode == BrowserMode::List => Screen::Inventory, + Some(_) => Screen::Look, + None => self.presentation.screen, + } } pub(crate) fn wall_clock_ticks_enabled(&self) -> bool { @@ -466,7 +522,7 @@ impl Application { }; Ok(PresentationSnapshot { view, - screen: self.presentation.screen, + screen: self.presentation_screen(), tactical_z, hp: player.hp, location: player.location, @@ -477,6 +533,209 @@ impl Application { }) } + pub(crate) fn inventory_browser_view( + &mut self, + viewport_width: usize, + viewport_height: usize, + ) -> Result, AppError> { + let Some(mut browser) = self.presentation.inventory_browser else { + return Ok(None); + }; + browser.last_viewport_width = viewport_width; + browser.last_viewport_height = viewport_height; + let page = self.refresh_inventory_page(&mut browser)?; + let detail_entry = if browser.mode == BrowserMode::Detail { + match browser.selected_id { + Some(id) => self.session.inventory_item(id)?, + None => None, + } + } else { + None + }; + let mut content = BrowserContent { + mode: browser.mode, + detail_returns_to_list: browser.origin == InventoryBrowserOrigin::Inventory, + total: page.total, + selected: browser.selected_slot, + has_before: page.has_before, + has_after: page.has_after, + titles: page + .entries + .iter() + .map(|entry| entry.tooltip.title.clone()) + .collect(), + detail: { + #[cfg(test)] + if let Some(detail) = self.inventory_test_detail.clone() { + Some(detail) + } else { + detail_entry.as_ref().map(|entry| entry.tooltip.clone()) + } + #[cfg(not(test))] + detail_entry.as_ref().map(|entry| entry.tooltip.clone()) + }, + detail_offset: browser.detail_offset, + clipped: page.entries.iter().any(|entry| entry.tooltip_clipped) + || detail_entry + .as_ref() + .is_some_and(|entry| entry.tooltip_clipped), + }; + let layout = layout_browser(&content, viewport_width, viewport_height); + browser.last_body_height = layout.body_height; + browser.detail_offset = browser.detail_offset.min(layout.max_detail_offset); + content.detail_offset = browser.detail_offset; + self.presentation.inventory_browser = Some(browser); + Ok(Some(InventoryBrowserSnapshot { content, layout })) + } + + #[cfg(test)] + fn set_inventory_detail_for_test(&mut self, detail: hexwork_ui::look::Tooltip) { + assert!(self.presentation.inventory_browser.is_some()); + self.inventory_test_detail = Some(detail); + } + + fn refresh_inventory_page( + &self, + browser: &mut InventoryBrowserState, + ) -> Result { + let query = browser + .page_anchor + .map(InventoryPageQuery::AtOrAfter) + .unwrap_or(InventoryPageQuery::First); + let page = self.session.inventory_page(query)?; + apply_inventory_page(browser, &page, browser.selected_id, None); + Ok(page) + } + + fn open_inventory_browser(&mut self, origin: InventoryBrowserOrigin) -> Result<(), AppError> { + #[cfg(test)] + { + self.inventory_test_detail = None; + } + let mut browser = InventoryBrowserState::new(origin); + let _ = self.refresh_inventory_page(&mut browser)?; + self.presentation.inventory_browser = Some(browser); + self.push_log(match origin { + InventoryBrowserOrigin::Inventory => "inventory browser opened".into(), + InventoryBrowserOrigin::DirectLook => "inventory detail opened".into(), + }); + Ok(()) + } + + fn handle_inventory_browser_input(&mut self, input: Input) -> Result<(), AppError> { + let Some(mut browser) = self.presentation.inventory_browser else { + return Ok(()); + }; + let page = self.refresh_inventory_page(&mut browser)?; + match browser.mode { + BrowserMode::List => match input { + Input::Up => { + if let Some(slot) = browser.selected_slot { + if slot > 0 { + select_inventory_slot(&mut browser, &page, slot - 1); + } else if page.has_before { + if let Some(first) = page.entries.first() { + let prior = self.session.inventory_page( + InventoryPageQuery::Before(first.instance_id), + )?; + if !prior.entries.is_empty() { + let last = prior.entries.len() - 1; + apply_inventory_page(&mut browser, &prior, None, Some(last)); + } + } + } + } + } + Input::Down => { + if let Some(slot) = browser.selected_slot { + if slot + 1 < page.entries.len() { + select_inventory_slot(&mut browser, &page, slot + 1); + } else if page.has_after { + if let Some(last) = page.entries.last() { + let next = self + .session + .inventory_page(InventoryPageQuery::After(last.instance_id))?; + if !next.entries.is_empty() { + apply_inventory_page(&mut browser, &next, None, Some(0)); + } + } + } + } + } + Input::Left => { + if page.has_before { + if let Some(first) = page.entries.first() { + let prior = self + .session + .inventory_page(InventoryPageQuery::Before(first.instance_id))?; + if !prior.entries.is_empty() { + let selected_slot = browser.selected_slot; + apply_inventory_page(&mut browser, &prior, None, selected_slot); + } + } + } + } + Input::Right => { + if page.has_after { + if let Some(last) = page.entries.last() { + let next = self + .session + .inventory_page(InventoryPageQuery::After(last.instance_id))?; + if !next.entries.is_empty() { + let selected_slot = browser.selected_slot; + apply_inventory_page(&mut browser, &next, None, selected_slot); + } + } + } + } + Input::ChargenPick(slot) if slot < page.entries.len() => { + select_inventory_slot(&mut browser, &page, slot); + } + Input::Confirm | Input::Look if browser.selected_id.is_some() => { + browser.mode = BrowserMode::Detail; + browser.detail_offset = 0; + } + Input::Quit | Input::Inventory => { + self.presentation.inventory_browser = None; + return Ok(()); + } + _ => {} + }, + BrowserMode::Detail => match input { + Input::Up => browser.detail_offset = browser.detail_offset.saturating_sub(1), + Input::Down => browser.detail_offset = browser.detail_offset.saturating_add(1), + Input::Left => { + browser.detail_offset = browser + .detail_offset + .saturating_sub(browser.last_body_height); + } + Input::Right => { + browser.detail_offset = browser + .detail_offset + .saturating_add(browser.last_body_height); + } + Input::Look | Input::Quit => { + if browser.origin == InventoryBrowserOrigin::Inventory { + browser.mode = BrowserMode::List; + browser.detail_offset = 0; + } else { + self.presentation.inventory_browser = None; + return Ok(()); + } + } + Input::Inventory => { + self.presentation.inventory_browser = None; + return Ok(()); + } + _ => {} + }, + } + self.presentation.inventory_browser = Some(browser); + let _ = + self.inventory_browser_view(browser.last_viewport_width, browser.last_viewport_height)?; + Ok(()) + } + pub(crate) fn input( &mut self, input: Input, @@ -504,6 +763,18 @@ impl Application { ) -> Result { self.check_signal_boundary(command_index, output, recovery_limits)?; self.ensure_playable()?; + if self.presentation.inventory_browser.is_some() { + if let Some(work) = work { + work.charge_action()?; + } + self.handle_inventory_browser_input(input)?; + let lifecycle = self.finish_boundary(command_index, output, recovery_limits)?; + self.finish_signal_after_boundary(lifecycle)?; + return Ok(DispatchResult { + input: InputResult::Continue, + lifecycle, + }); + } if self .presentation .tactical_step(input, self.presentation.screen == Screen::Contracts) @@ -573,26 +844,11 @@ impl Application { self.save(command_index, output)?; result = InputResult::Quit; } - ControllerEvent::Control(Input::Inventory) | ControllerEvent::Control(Input::Look) => { - let view = self.session.inventory_view(MAX_PRESENTED_ENTRIES)?; - let labels = view - .entries - .iter() - .map(|entry| entry.tooltip.title.as_str()) - .collect::>(); - self.presentation.screen = if input == Input::Look { - Screen::Look - } else { - Screen::Inventory - }; - let shown = labels.len(); - let suffix = if view.truncated { - format!(" ({shown} of {} shown)", view.total) - } else { - format!(" ({} total)", view.total) - }; - self.push_log(format!("inventory {}{suffix}", labels.join(", "))); - self.presentation.screen = Screen::Play; + ControllerEvent::Control(Input::Inventory) => { + self.open_inventory_browser(InventoryBrowserOrigin::Inventory)?; + } + ControllerEvent::Control(Input::Look) => { + self.open_inventory_browser(InventoryBrowserOrigin::DirectLook)?; } ControllerEvent::Control(Input::Chapel) => { self.presentation.screen = Screen::Chapel; @@ -887,6 +1143,27 @@ impl Application { .map_err(|detail| AppError::io("output", detail)) } + #[cfg(test)] + pub(crate) fn dispatch_contextual_action_while_browser_open_for_test( + &mut self, + ) -> Result<(), AppError> { + debug_assert!(self.presentation.inventory_browser.is_some()); + let ControllerEvent::Dispatch { action, source } = + self.controller.adapt(Input::Confirm, &self.session) + else { + return Err(AppError::content( + "test-contextual-action-missing", + "no contextual action available", + )); + }; + let outcome = self.session.dispatch(action)?; + let _ = self.controller.dispatch_succeeded(&source); + self.dirty = true; + self.cadence.advance(outcome.elapsed_ticks()); + self.push_log(outcome.log_text()); + Ok(()) + } + pub(crate) fn game_over(&self) -> bool { self.terminal_lifecycle != TerminalLifecycle::Active } @@ -972,6 +1249,40 @@ impl Application { } } +fn apply_inventory_page( + browser: &mut InventoryBrowserState, + page: &InventoryPage, + preferred_id: Option, + preferred_slot: Option, +) { + browser.page_anchor = page.entries.first().map(|entry| entry.instance_id); + if page.entries.is_empty() { + browser.selected_id = None; + browser.selected_slot = None; + browser.detail_offset = 0; + return; + } + let slot = preferred_id + .and_then(|id| { + page.entries + .iter() + .position(|entry| entry.instance_id == id) + }) + .or_else(|| preferred_slot.map(|slot| slot.min(page.entries.len() - 1))) + .unwrap_or(0); + select_inventory_slot(browser, page, slot); +} + +fn select_inventory_slot(browser: &mut InventoryBrowserState, page: &InventoryPage, slot: usize) { + if let Some(entry) = page.entries.get(slot) { + if browser.selected_id != Some(entry.instance_id) { + browser.detail_offset = 0; + } + browser.selected_id = Some(entry.instance_id); + browser.selected_slot = Some(slot); + } +} + fn emit_open_milestones( output: &mut JsonOutput, milestones: &[ArtifactMilestone], @@ -1240,6 +1551,36 @@ mod tests { (root, app) } + fn modal_input(app: &mut Application, input: Input) { + app.input_with_boundary( + input, + None, + &mut JsonOutput::new(Vec::new()), + None, + RecoveryLimits::default(), + ) + .unwrap(); + } + + fn browser_cursor(app: &Application) -> (Option, Option, usize) { + let browser = app + .presentation + .inventory_browser + .expect("inventory browser open"); + ( + browser.selected_id, + browser.selected_slot, + browser.detail_offset, + ) + } + + fn long_detail() -> hexwork_ui::look::Tooltip { + hexwork_ui::look::Tooltip { + title: "Long valid test detail".to_owned(), + lines: (0..256).map(|index| format!("row {index:03}")).collect(), + } + } + #[test] fn startup_pending_refuses_before_root_allocation_or_load() { let root = Scratch::new("startup-signal"); @@ -1349,20 +1690,73 @@ mod tests { } #[test] - fn inventory_feedback_uses_tooltip_titles_and_honest_totals() { - let (_root, mut app) = managed_app("inventory-log", SignalController::default()); - let view = app.session.inventory_view(MAX_PRESENTED_ENTRIES).unwrap(); - let expected_titles = view - .entries - .iter() - .map(|entry| entry.tooltip.title.clone()) - .collect::>(); - assert!( - !expected_titles.is_empty(), - "starter inventory must be presented" - ); + fn inventory_refresh_retains_present_id_but_missing_selection_uses_first_or_none() { + let (_root, mut app) = + managed_app("inventory-refresh-selection", SignalController::default()); + let ids = app.session.replace_player_inventory_for_test(6).unwrap(); + modal_input(&mut app, Input::Inventory); + modal_input(&mut app, Input::ChargenPick(3)); + assert_eq!(browser_cursor(&app), (Some(ids[3]), Some(3), 0)); + + let retained = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(retained.content.selected, Some(3)); + assert_eq!(browser_cursor(&app), (Some(ids[3]), Some(3), 0)); + + let survivors = [ids[0], ids[1], ids[2], ids[4], ids[5]]; + app.session + .retain_player_inventory_for_test(&survivors) + .unwrap(); + let list = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(list.content.selected, Some(0)); + assert_eq!(browser_cursor(&app), (Some(ids[0]), Some(0), 0)); + + modal_input(&mut app, Input::ChargenPick(3)); + modal_input(&mut app, Input::Confirm); + assert_eq!(browser_cursor(&app).0, Some(ids[4])); + app.session + .retain_player_inventory_for_test(&[ids[0], ids[1], ids[2], ids[5]]) + .unwrap(); + let detail = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(detail.content.mode, BrowserMode::Detail); + assert_eq!(detail.content.selected, Some(0)); + assert_eq!(browser_cursor(&app), (Some(ids[0]), Some(0), 0)); + + app.session.retain_player_inventory_for_test(&[]).unwrap(); + let empty = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(empty.content.total, 0); + assert_eq!(empty.content.selected, None); + assert_eq!(browser_cursor(&app), (None, None, 0)); + } - let raw_diagnostics = view + #[test] + fn inventory_detail_pages_by_actual_bounded_layout_body_height() { + let (_root, mut app) = + managed_app("inventory-detail-page-height", SignalController::default()); + modal_input(&mut app, Input::Look); + app.set_inventory_detail_for_test(long_detail()); + + let large = app.inventory_browser_view(1, 257).unwrap().unwrap(); + assert_eq!(large.layout.body_height, 252); + assert!(large.layout.max_detail_offset > 252); + modal_input(&mut app, Input::Right); + assert_eq!(browser_cursor(&app).2, 252); + modal_input(&mut app, Input::Left); + assert_eq!(browser_cursor(&app).2, 0); + + let normal = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(normal.layout.body_height, 12); + assert!(normal.layout.max_detail_offset >= 24); + modal_input(&mut app, Input::Right); + assert_eq!(browser_cursor(&app).2, 12); + modal_input(&mut app, Input::Left); + assert_eq!(browser_cursor(&app).2, 0); + } + + #[test] + fn inventory_navigation_and_look_do_not_mutate_hash_tick_or_history() { + let (_root, mut app) = managed_app("inventory-modal", SignalController::default()); + let view = app.session.inventory_view(MAX_PRESENTED_ENTRIES).unwrap(); + let raw_tuples = view .entries .iter() .map(|entry| { @@ -1371,84 +1765,325 @@ mod tests { entry.instance_id, entry.template_id, entry.effective_identified ) }) - .filter(|raw| expected_titles.iter().all(|title| !title.contains(raw))) .collect::>(); - assert!( - !raw_diagnostics.is_empty(), - "raw tuple coverage must use sentinels distinguishable from visible titles" - ); - let raw_template_sentinels = view + let raw_templates = view .entries .iter() .map(|entry| entry.template_id.clone()) - .filter(|template| { - template.len() >= 8 - && expected_titles - .iter() - .all(|title| !title.contains(template.as_str())) - }) + .filter(|template| template.len() >= 8) .collect::>(); - assert!( - !raw_template_sentinels.is_empty(), - "raw template coverage must use sentinels distinguishable from visible titles" - ); + let before_hash = app.session.canonical_hash().unwrap(); + let before_tick = app.tick(); + let before_history = app.session.events().len(); + let mut output = JsonOutput::new(Vec::new()); - let hidden_use_sentinels = view - .entries - .iter() - .flat_map(|entry| { - app.pack - .item(&entry.template_id) - .expect("inventory template exists in canonical pack") - .uses - .iter() - }) - .filter(|use_text| { - use_text.len() >= 12 - && expected_titles - .iter() - .all(|title| !title.contains(use_text.as_str())) - }) - .cloned() - .collect::>(); - assert!( - !hidden_use_sentinels.is_empty(), - "canonical carried templates must provide distinguishable hidden-use phrases" + app.input_with_boundary( + Input::Inventory, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert_eq!(app.presentation_screen(), Screen::Inventory); + let list = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(list.content.total, view.total); + assert_eq!(list.content.titles.len(), view.total.min(6)); + assert_eq!( + list.content.selected, + (!list.content.titles.is_empty()).then_some(0) ); + let visible_list = format!("{:?}", list.content); + for raw in raw_tuples.iter().chain(raw_templates.iter()) { + assert!(!visible_list.contains(raw), "internal cursor leaked: {raw}"); + } + for input in [Input::Down, Input::ChargenPick(1), Input::Confirm] { + app.input_with_boundary(input, None, &mut output, None, RecoveryLimits::default()) + .unwrap(); + } + assert_eq!(app.presentation_screen(), Screen::Look); + let detail = app.inventory_browser_view(80, 16).unwrap().unwrap(); + assert_eq!(detail.content.mode, BrowserMode::Detail); + assert!(detail.content.detail.is_some()); app.input_with_boundary( - Input::Inventory, + Input::Look, None, - &mut JsonOutput::new(Vec::new()), + &mut output, None, RecoveryLimits::default(), ) .unwrap(); - let log = app.logs().last().unwrap(); - for title in expected_titles { - assert!( - log.contains(&title), - "missing tooltip title {title:?}: {log:?}" - ); + assert_eq!(app.presentation_screen(), Screen::Inventory); + app.input_with_boundary( + Input::Quit, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); + assert_eq!(app.presentation_screen(), Screen::Play); + assert_eq!(app.session.canonical_hash().unwrap(), before_hash); + assert_eq!(app.tick(), before_tick); + assert_eq!(app.session.events().len(), before_history); + } + + #[test] + fn inventory_actual_navigation_reaches_all_items_and_page_boundaries() { + let (_root, mut app) = managed_app("inventory-multipage", SignalController::default()); + let ids = app.session.replace_player_inventory_for_test(14).unwrap(); + modal_input(&mut app, Input::Inventory); + let mut reached = vec![browser_cursor(&app).0.unwrap()]; + for expected in ids.iter().copied().skip(1) { + modal_input(&mut app, Input::Down); + assert_eq!(browser_cursor(&app).0, Some(expected)); + reached.push(expected); } - assert!(log.contains(&format!("({} total)", view.total))); - for raw in raw_diagnostics { - assert!(!log.contains(&raw), "raw diagnostic tuple leaked: {raw:?}"); + assert_eq!(reached, ids); + modal_input(&mut app, Input::Down); + assert_eq!(browser_cursor(&app).0, ids.last().copied()); + + modal_input(&mut app, Input::Left); + assert_eq!(browser_cursor(&app), (Some(ids[7]), Some(1), 0)); + modal_input(&mut app, Input::Left); + assert_eq!(browser_cursor(&app), (Some(ids[1]), Some(1), 0)); + modal_input(&mut app, Input::Left); + assert_eq!(browser_cursor(&app), (Some(ids[1]), Some(1), 0)); + modal_input(&mut app, Input::Up); + assert_eq!(browser_cursor(&app).0, Some(ids[0])); + modal_input(&mut app, Input::Up); + assert_eq!(browser_cursor(&app).0, Some(ids[0])); + modal_input(&mut app, Input::Right); + assert_eq!(browser_cursor(&app), (Some(ids[6]), Some(0), 0)); + modal_input(&mut app, Input::Right); + assert_eq!(browser_cursor(&app), (Some(ids[12]), Some(0), 0)); + modal_input(&mut app, Input::Right); + assert_eq!(browser_cursor(&app), (Some(ids[12]), Some(0), 0)); + modal_input(&mut app, Input::Down); + assert_eq!(browser_cursor(&app), (Some(ids[13]), Some(1), 0)); + } + + #[test] + fn inventory_consumes_controls_and_quit_closes_before_run_quit() { + let (_root, mut app) = managed_app("inventory-precedence", SignalController::default()); + let mut output = JsonOutput::new(Vec::new()); + let before_hash = app.session.canonical_hash().unwrap(); + let before_history = app.session.events().len(); + app.input_with_boundary( + Input::Inventory, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + let before_tick = app.tick(); + let mut work = WorkBudget::new(None); + let actions_before = work.actions_remaining; + for input in [ + Input::Left, + Input::Map, + Input::Contracts, + Input::CameraToggle, + Input::PauseStep, + ] { + app.input_with_boundary( + input, + None, + &mut output, + Some(&mut work), + RecoveryLimits::default(), + ) + .unwrap(); } - for template in raw_template_sentinels { - assert!( - !log.contains(&template), - "raw template ID leaked: {template:?}" - ); + assert_eq!(work.actions_remaining, actions_before - 5); + assert_eq!(app.tick(), before_tick); + assert_eq!(app.session.canonical_hash().unwrap(), before_hash); + assert_eq!(app.session.events().len(), before_history); + assert_eq!(app.presentation_screen(), Screen::Inventory); + let result = app + .input_with_boundary( + Input::Quit, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert_eq!(result.input, InputResult::Continue); + assert_eq!(app.presentation_screen(), Screen::Play); + assert!(!app.game_over()); + } + + #[test] + fn contract_board_precedes_inventory_and_look_through_application() { + let (_root, mut app) = + managed_app("inventory-board-precedence", SignalController::default()); + let mut output = JsonOutput::new(Vec::new()); + for input in crate::entry::test_routes::contract_board_inputs(223) { + app.input_with_boundary(input, None, &mut output, None, RecoveryLimits::default()) + .unwrap(); } - for hidden_use in hidden_use_sentinels { - assert!( - !log.contains(&hidden_use), - "hidden canonical use leaked: {hidden_use:?}" - ); + app.input_with_boundary( + Input::Contracts, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert_eq!(app.presentation_screen(), Screen::Contracts); + assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); + + for input in [Input::Inventory, Input::Look] { + 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()); } } + #[test] + fn inventory_over_map_preserves_slice_pause_policy_and_direct_look_closes() { + let (_root, mut app) = managed_app("inventory-map", SignalController::default()); + let mut output = JsonOutput::new(Vec::new()); + app.input_with_boundary( + Input::Map, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + app.input_with_boundary( + Input::Down, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + let map = app.presentation().unwrap(); + assert_eq!(map.screen, Screen::Map); + assert!(map.wall_clock_paused); + app.input_with_boundary( + Input::Inventory, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + let modal = app.presentation().unwrap(); + assert_eq!(modal.screen, Screen::Inventory); + assert_eq!(modal.tactical_z, map.tactical_z); + assert_eq!(modal.view, map.view); + assert_eq!(modal.wall_clock_paused, map.wall_clock_paused); + app.input_with_boundary( + Input::Inventory, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert_eq!(app.presentation().unwrap(), map); + + app.input_with_boundary( + Input::Look, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert_eq!(app.presentation_screen(), Screen::Look); + app.input_with_boundary( + Input::Quit, + None, + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert_eq!(app.presentation().unwrap(), map); + } + + #[test] + fn inventory_all_play_close_paths_restore_underlying_play() { + let (_root, mut app) = + managed_app("inventory-play-close-paths", SignalController::default()); + modal_input(&mut app, Input::Inventory); + modal_input(&mut app, Input::Inventory); + assert_eq!(app.presentation_screen(), Screen::Play); + + modal_input(&mut app, Input::Inventory); + modal_input(&mut app, Input::Confirm); + assert_eq!(app.presentation_screen(), Screen::Look); + modal_input(&mut app, Input::Inventory); + assert_eq!(app.presentation_screen(), Screen::Play); + + modal_input(&mut app, Input::Inventory); + modal_input(&mut app, Input::Confirm); + modal_input(&mut app, Input::Look); + assert_eq!(app.presentation_screen(), Screen::Inventory); + modal_input(&mut app, Input::Quit); + assert_eq!(app.presentation_screen(), Screen::Play); + } + + #[test] + fn inventory_classic_policy_and_fresh_new_load_have_no_browser_state() { + let root = Scratch::new("inventory-new-load"); + let pack = RuntimePack::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")).unwrap(); + let mut output = JsonOutput::new(Vec::new()); + let mut original = Application::new_with_output_checked( + pack.clone(), + &root.0, + default_new_setup(41, Mode::Classic, None, None, None), + RecoveryLimits::default(), + &mut output, + SignalController::default(), + ) + .unwrap(); + assert!(original.inventory_browser_view(80, 16).unwrap().is_none()); + assert!(original.wall_clock_ticks_enabled()); + modal_input(&mut original, Input::Inventory); + assert!(original.wall_clock_ticks_enabled()); + original.save(None, &mut output).unwrap(); + let run_id = original.run_id(); + drop(original); + + let mut loaded = Application::load_with_output_checked( + pack.clone(), + &root.0, + run_id, + LoadConstraints::default(), + RecoveryLimits::default(), + &mut output, + SignalController::default(), + ) + .unwrap(); + assert!(loaded.inventory_browser_view(80, 16).unwrap().is_none()); + assert_eq!(loaded.presentation_screen(), Screen::Play); + assert!(loaded.wall_clock_ticks_enabled()); + drop(loaded); + + let fresh_root = Scratch::new("inventory-fresh-new"); + let mut fresh = Application::new_with_output_checked( + pack, + &fresh_root.0, + default_new_setup(42, Mode::Story, None, None, None), + RecoveryLimits::default(), + &mut output, + SignalController::default(), + ) + .unwrap(); + assert!(fresh.inventory_browser_view(80, 16).unwrap().is_none()); + assert_eq!(fresh.presentation_screen(), Screen::Play); + } + #[test] fn pending_signal_saves_living_run_then_reports_interruption() { let signals = SignalController::default(); @@ -1462,6 +2097,15 @@ mod tests { RecoveryLimits::default(), ) .unwrap(); + app.input_with_boundary( + Input::Inventory, + Some(1), + &mut output, + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert!(app.inventory_browser_view(80, 16).unwrap().is_some()); signals.trigger(); let error = app .finish_signal_if_pending(Some(1), &mut output, RecoveryLimits::default()) diff --git a/crates/hexwork-cli/src/entry.rs b/crates/hexwork-cli/src/entry.rs index 69d2740..84322eb 100644 --- a/crates/hexwork-cli/src/entry.rs +++ b/crates/hexwork-cli/src/entry.rs @@ -8,7 +8,7 @@ use std::time::Instant; use hexwork_content::RuntimePack; use hexwork_core::character::{describe as describe_ancestry, kit, ALL_ANCESTRIES, ALL_CALLINGS}; use hexwork_core::modes::Mode; -use hexwork_render::ansi::encode_diff; +use hexwork_render::ansi::{encode_diff, encode_full}; use hexwork_render::camera::View; use hexwork_render::env as render_env; use hexwork_render::scene::{render, render_tactical}; @@ -21,6 +21,7 @@ use hexwork_term::backend::{refusal_message, Backend}; use hexwork_term::keymap::KEYMAP_FILE_NAME; use hexwork_term::remap::{load_keymap, Action, Keymap, LoadError, LoadSource}; use hexwork_term::size_ok; +use hexwork_ui::inventory::{render_browser, BrowserBindings}; use hexwork_ui::logfmt::{recent_log_window, RecentLogWindow}; use hexwork_ui::Screen; @@ -222,6 +223,22 @@ fn binding_label(keymap: &Keymap, action: Action, fallback: &str) -> String { .unwrap_or_else(|| fallback.to_owned()) } +fn inventory_browser_bindings(keymap: &Keymap) -> BrowserBindings { + BrowserBindings { + up: binding_label(keymap, Action::Up, "up action"), + down: binding_label(keymap, Action::Down, "down action"), + left: binding_label(keymap, Action::Left, "left action"), + right: binding_label(keymap, Action::Right, "right action"), + inspect: binding_label(keymap, Action::Confirm, "confirm action"), + back: binding_label(keymap, Action::Look, "look action"), + close: binding_label(keymap, Action::Inventory, "inventory action"), + quit: binding_label(keymap, Action::Quit, "quit action"), + picks: std::array::from_fn(|slot| { + binding_label(keymap, Action::ChargenPick(slot), "unavailable") + }), + } +} + fn keymap_context_ribbon( screen: Screen, keymap: &Keymap, @@ -650,6 +667,8 @@ fn run_terminal( let viewport_height = height.saturating_sub(TERMINAL_CHROME_ROWS) as usize; 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 ascii_only = overrides.no_color || overrides.force_ascii || !render_caps.unicode; let color_mode = if overrides.no_color { ColorMode::NoColor @@ -700,11 +719,27 @@ 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 presentation = app.presentation()?; - let state = app.state()?; - render_with_preferences(state, presentation.view, &tuning, ascii_only, &mut frame); let mut ansi = String::new(); - encode_frame_diff(&previous, &frame, color_mode, &mut ansi); + 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; + world_frame_valid = false; + } else { + let state = app.state()?; + render_with_preferences(state, presentation.view, &tuning, ascii_only, &mut frame); + if world_frame_valid { + encode_frame_diff(&previous, &frame, color_mode, &mut ansi); + } else { + encode_frame_full(&frame, color_mode, &mut ansi); + } + std::mem::swap(&mut previous, &mut frame); + browser_was_open = false; + world_frame_valid = true; + } let context_ribbon = keymap_context_ribbon( app.presentation_screen(), backend.keymap(), @@ -722,7 +757,6 @@ fn run_terminal( backend .flush_str(&ansi) .map_err(|error| AppError::io("terminal-write", error.to_string()))?; - std::mem::swap(&mut previous, &mut frame); update_panic_state(&panic_state, &app, None); if app.game_over() { let mut operation_sink = JsonOutput::new(io::sink()); @@ -961,6 +995,62 @@ fn encode_frame_diff( output.push_str("\x1b[0m"); } +fn encode_frame_full(next: &FrameBuffer, mode: ColorMode, output: &mut String) { + if mode == ColorMode::Truecolor { + encode_full(next, output); + return; + } + if next.w == 0 || next.h == 0 { + return; + } + let count = next.w.saturating_mul(next.h).min(next.cells.len()); + if count == 0 { + return; + } + output.push_str("\x1b[H"); + for y in 0..next.h { + let _ = write!(output, "\x1b[{};1H", y + 1); + for x in 0..next.w { + let index = y * next.w + x; + if index >= count { + break; + } + let cell = next.cells[index]; + match mode { + ColorMode::Ansi256 => { + let foreground = hexwork_render::ansi::quantize_256(cell.fg); + let background = hexwork_render::ansi::quantize_256(cell.bg); + let _ = write!( + output, + "\x1b[38;5;{foreground}m\x1b[48;5;{background}m{}", + cell.glyph + ); + } + ColorMode::NoColor => output.push(cell.glyph), + ColorMode::Truecolor => unreachable!("handled before full loop"), + } + } + } + output.push_str("\x1b[0m"); +} + +fn append_opaque_browser( + output: &mut String, + rows: &[String], + viewport_height: usize, + clear_viewport: bool, +) { + let viewport_height = viewport_height.min(usize::from(u16::MAX)); + if clear_viewport { + for row in 0..viewport_height { + let _ = write!(output, "\x1b[{};1H\x1b[2K", row + 1); + } + } + for (row, line) in rows.iter().take(viewport_height).enumerate() { + let _ = write!(output, "\x1b[{};1H\x1b[2K{line}", row + 1); + } +} + fn append_terminal_chrome( output: &mut String, viewport_height: usize, @@ -1171,6 +1261,73 @@ mod tests { frame } + #[derive(Clone, Debug, PartialEq, Eq)] + struct TerminalGrid { + width: usize, + height: usize, + cells: Vec, + row: usize, + column: usize, + } + + impl TerminalGrid { + fn filled(width: usize, height: usize, glyph: char) -> Self { + Self { + width, + height, + cells: vec![glyph; width.saturating_mul(height)], + row: 0, + column: 0, + } + } + + fn apply(&mut self, ansi: &str) { + let mut chars = ansi.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' && chars.next_if_eq(&'[').is_some() { + let mut parameters = String::new(); + let command = loop { + let Some(value) = chars.next() else { + return; + }; + if ('@'..='~').contains(&value) { + break value; + } + parameters.push(value); + }; + if command == 'H' || command == 'f' { + let mut values = parameters + .split(';') + .map(|value| value.parse::().unwrap_or(1)); + self.row = values.next().unwrap_or(1).saturating_sub(1); + self.column = values.next().unwrap_or(1).saturating_sub(1); + } else if command == 'K' && parameters == "2" && self.row < self.height { + let start = self.row * self.width; + self.cells[start..start + self.width].fill(' '); + } + continue; + } + match ch { + '\r' => self.column = 0, + '\n' => { + self.row = self.row.saturating_add(1); + self.column = 0; + } + _ if self.row < self.height && self.column < self.width => { + self.cells[self.row * self.width + self.column] = ch; + self.column += 1; + } + _ => self.column = self.column.saturating_add(1), + } + } + } + + fn row(&self, row: usize) -> &[char] { + let start = row * self.width; + &self.cells[start..start + self.width] + } + } + fn remapped_keymap() -> Keymap { let text = DEFAULT_KEYMAP_TOML .replace("look = [\"x\"]", "look = [\"r\"]") @@ -1486,11 +1643,22 @@ mod tests { &mut JsonOutput::new(io::sink()), ) .unwrap(); + app.input( + Input::Inventory, + None, + &mut JsonOutput::new(io::sink()), + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert!(app.inventory_browser_view(80, 16).unwrap().is_some()); + app.dispatch_contextual_action_while_browser_open_for_test() + .unwrap(); let run_id = app.run_id(); let mut milestone_bytes = Vec::new(); let error = dispatch_drained_batch( &mut app, - [route[lethal_index]], + [Input::Unknown], RecoveryLimits::default(), &mut JsonOutput::new(SignalOnFlush { bytes: &mut milestone_bytes, @@ -1620,6 +1788,80 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn inventory_modal_batch_stops_after_story_lifecycle_and_discards_later_inputs() { + let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let pack = RuntimePack::load(&content).unwrap(); + let route = test_routes::grate_mother_death_inputs(223); + let root = PathBuf::from(format!( + "/var/tmp/hexwork-entry-inventory-story-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let mut app = managed_story(&root, pack); + assert_eq!( + dispatch_drained_batch( + &mut app, + route[..route.len() - 1].iter().copied(), + RecoveryLimits::default(), + &mut JsonOutput::new(io::sink()), + ) + .unwrap(), + DrainedBatchResult::Drained + ); + app.input( + Input::Inventory, + None, + &mut JsonOutput::new(io::sink()), + None, + RecoveryLimits::default(), + ) + .unwrap(); + assert!(app.inventory_browser_view(80, 16).unwrap().is_some()); + app.dispatch_contextual_action_while_browser_open_for_test() + .unwrap(); + let sentinel = test_routes::story_grate_mother_death_inputs_after_rollback(223) + .into_iter() + .next() + .expect("post-rollback route begins with movement"); + assert_eq!( + dispatch_drained_batch( + &mut app, + [Input::Unknown, sentinel], + RecoveryLimits::default(), + &mut JsonOutput::new(io::sink()), + ) + .unwrap(), + DrainedBatchResult::Lifecycle(LifecycleBoundary::StoryResolved) + ); + assert!(app.inventory_browser_view(80, 16).unwrap().is_none()); + assert_eq!(app.presentation_screen(), Screen::Play); + let restored = observe_state(&mut app); + assert!( + restored + .detail + .contains("location=Town(\"milthollow\");chapel_debt=1"), + "{}", + restored.detail + ); + let events_after_boundary = restored.event_count; + assert_eq!( + dispatch_drained_batch( + &mut app, + [sentinel], + RecoveryLimits::default(), + &mut JsonOutput::new(io::sink()), + ) + .unwrap(), + DrainedBatchResult::Drained + ); + assert_eq!( + observe_state(&mut app).event_count, + events_after_boundary + 1 + ); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn terminal_batch_stops_after_actual_story_death_and_fresh_movement_remains_effective() { let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); @@ -2310,7 +2552,10 @@ mod tests { append_terminal_chrome(&mut frame, 16, 80, &snapshot, &ribbon, &logs); assert!(frame.contains("you wait;"), "rendered chrome: {frame:?}"); - assert!(frame.contains("inventory "), "rendered chrome: {frame:?}"); + assert!( + frame.contains("inventory browser opened"), + "rendered chrome: {frame:?}" + ); assert!( frame.contains("tactical map z="), "rendered chrome: {frame:?}" @@ -2322,6 +2567,106 @@ mod tests { let _ = std::fs::remove_dir_all(data); } + #[test] + fn inventory_hints_follow_loaded_keymap_through_entry_helper() { + let keymap = remapped_keymap(); + let bindings = inventory_browser_bindings(&keymap); + assert_eq!(bindings.inspect, "9"); + assert_eq!(bindings.back, "r"); + assert_eq!(bindings.close, "g"); + assert_eq!(bindings.quit, "0"); + assert_eq!(bindings.picks[0], "7"); + assert_eq!(bindings.picks[1], "8"); + assert_ne!(bindings.inspect, "Enter"); + assert_ne!(bindings.picks[0], "1"); + } + + #[test] + fn inventory_close_full_redraw_restores_every_unchanged_world_cell() { + let mut world = FrameBuffer::new(8, 4); + world.cells[0].glyph = '@'; + world.cells[world.w * 3 + 7].glyph = '#'; + let expected = world + .cells + .iter() + .map(|cell| cell.glyph) + .collect::>(); + let rows = vec!["Inventory".to_owned(), "MENU TEXT".to_owned()]; + + for mode in [ColorMode::Truecolor, ColorMode::Ansi256, ColorMode::NoColor] { + let mut terminal = TerminalGrid::filled(world.w, world.h, '?'); + let mut initial = String::new(); + encode_frame_full(&world, mode, &mut initial); + terminal.apply(&initial); + + let mut modal = String::new(); + append_opaque_browser(&mut modal, &rows, world.h, true); + terminal.apply(&modal); + assert!(terminal.cells.contains(&'M')); + + let mut close = String::new(); + encode_frame_full(&world, mode, &mut close); + terminal.apply(&close); + assert_eq!(terminal.cells, expected, "failed full redraw for {mode:?}"); + assert!( + terminal.cells[1..world.w * 3 + 7] + .iter() + .all(|cell| *cell == ' '), + "default blank cells retained modal text for {mode:?}" + ); + } + } + + #[test] + fn inventory_entry_clears_full_capped_physical_viewport_and_preserves_chrome() { + const WIDTH: usize = 20; + const VIEWPORT_HEIGHT: usize = 300; + let height = VIEWPORT_HEIGHT + usize::from(TERMINAL_CHROME_ROWS); + let rows = vec!["Inventory [capped]".to_owned(); 256]; + let mut terminal = TerminalGrid::filled(WIDTH, height, 'X'); + let mut ansi = String::new(); + append_opaque_browser(&mut ansi, &rows, VIEWPORT_HEIGHT, true); + let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let pack = RuntimePack::load(&content).unwrap(); + let data = PathBuf::from(format!( + "/var/tmp/hexwork-entry-capped-viewport-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&data); + let app = managed_application(&data, pack, Mode::Story, SignalController::default()); + let presentation = app.presentation().unwrap(); + let logs = RecentLogWindow { + rows: Vec::new(), + omitted_entries: 0, + clipped: false, + }; + append_terminal_chrome( + &mut ansi, + VIEWPORT_HEIGHT, + WIDTH, + &presentation, + "chrome ribbon", + &logs, + ); + terminal.apply(&ansi); + + assert!(terminal.row(255).iter().any(|cell| *cell != ' ')); + assert!(terminal.row(256).iter().all(|cell| *cell == ' ')); + assert!(terminal + .row(VIEWPORT_HEIGHT - 1) + .iter() + .all(|cell| *cell == ' ')); + assert_eq!(terminal.row(VIEWPORT_HEIGHT)[0], 'H'); + assert!(terminal + .row(VIEWPORT_HEIGHT + 1) + .starts_with(&['c', 'h', 'r', 'o', 'm', 'e'])); + assert_eq!( + ansi.matches("\x1b[2K").count(), + VIEWPORT_HEIGHT + rows.len() + ); + let _ = std::fs::remove_dir_all(data); + } + #[test] fn interactive_chargen_only_requests_missing_new_run_choices() { let missing = Cli::parse([OsString::from("hexwork")]).unwrap(); diff --git a/crates/hexwork-cli/src/session.rs b/crates/hexwork-cli/src/session.rs index 2ad7ca1..d788b30 100644 --- a/crates/hexwork-cli/src/session.rs +++ b/crates/hexwork-cli/src/session.rs @@ -16,7 +16,7 @@ use hexwork_core::character::{ }; use hexwork_core::hymn::HymnCase; use hexwork_core::inventory::Pack; -use hexwork_core::item::{self, ItemTemplate}; +use hexwork_core::item::{self, Instance, ItemTemplate}; use hexwork_core::light::LightSource; use hexwork_core::modes::Mode; use hexwork_core::npc::{Job, Townsfolk}; @@ -61,6 +61,12 @@ const PLAYER_STRIKE_DAMAGE: i32 = 3; /// canonical player with the production baseline health. const STORY_RESURRECTION_HP: i32 = 10; const CORPSE_TEMPLATE: &str = "corpse"; +const INVENTORY_PAGE_SIZE: usize = 6; +const INVENTORY_TOOLTIP_TEXT_LIMIT: usize = 16 * 1024; +const MAX_TOOLTIP_USE_ROWS: usize = 256; +const INVENTORY_TOOLTIP_CLIPPED_LINE: &str = "[content clipped]"; +const INVENTORY_TOOLTIP_USES_CLIPPED_LINE: &str = + "Uses omitted: bounded inspection could not retain them."; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] enum PlanLocation { @@ -260,6 +266,32 @@ pub(crate) struct InventoryEntryView { pub template_id: String, pub effective_identified: bool, pub tooltip: Tooltip, + pub tooltip_clipped: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InventoryPageQuery { + First, + AtOrAfter(u64), + After(u64), + Before(u64), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InventoryPage { + pub entries: Vec, + pub total: usize, + pub has_before: bool, + pub has_after: bool, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct InventoryQueryProbes { + page: usize, + boundary: usize, + projected: usize, + direct_lookup: usize, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -975,6 +1007,87 @@ impl GameSession { .map_err(|error| SessionError::runtime("snapshot-invalid", format!("{error:?}"))) } + /// Unit-fixture seam: restore a valid StateImage after replacing only the + /// player's carried inventory with clones of an existing valid instance. + #[cfg(test)] + pub(crate) fn replace_player_inventory_for_test( + &mut self, + count: usize, + ) -> Result, SessionError> { + self.ensure_operational()?; + let player = self.player_id()?; + let prototype = self + .player_pack()? + .items + .values() + .next() + .ok_or_else(|| SessionError::runtime("inventory-fixture-empty", "player pack"))? + .clone(); + let mut image = self.state.to_image().map_err(|error| { + SessionError::runtime("inventory-fixture-image", format!("{error:?}")) + })?; + let carried = image + .packs + .get_mut(&player) + .ok_or_else(|| SessionError::runtime("player-table-missing", "packs"))?; + carried.items.clear(); + carried.equipped_weapon = None; + carried.equipped_armor = None; + let mut ids = Vec::with_capacity(count); + for index in 0..count { + let id = 100_000 + + u64::try_from(index).map_err(|error| { + SessionError::runtime("inventory-fixture-id", error.to_string()) + })? * 2; + let mut instance = prototype.clone(); + instance.id = id; + carried.add(instance); + ids.push(id); + } + image.next_id = ids.last().map_or(100_000, |id| id + 1); + self.state = State::from_image(image).map_err(|error| { + SessionError::runtime("inventory-fixture-restore", format!("{error:?}")) + })?; + self.validate_session_state_only()?; + Ok(ids) + } + + /// Unit-fixture seam paired with `replace_player_inventory_for_test`: + /// retain only named carried IDs, then restore the complete valid image. + #[cfg(test)] + pub(crate) fn retain_player_inventory_for_test( + &mut self, + retained: &[u64], + ) -> Result<(), SessionError> { + self.ensure_operational()?; + let player = self.player_id()?; + let retained = retained.iter().copied().collect::>(); + let mut image = self.state.to_image().map_err(|error| { + SessionError::runtime("inventory-fixture-image", format!("{error:?}")) + })?; + let carried = image + .packs + .get_mut(&player) + .ok_or_else(|| SessionError::runtime("player-table-missing", "packs"))?; + carried.items.retain(|id, _| retained.contains(id)); + if carried + .equipped_weapon + .is_some_and(|id| !retained.contains(&id)) + { + carried.equipped_weapon = None; + } + if carried + .equipped_armor + .is_some_and(|id| !retained.contains(&id)) + { + carried.equipped_armor = None; + } + self.state = State::from_image(image).map_err(|error| { + SessionError::runtime("inventory-fixture-restore", format!("{error:?}")) + })?; + self.validate_session_state_only() + } + pub(crate) fn canonical_hash(&self) -> Result { self.ensure_operational()?; self.state @@ -1083,54 +1196,214 @@ impl GameSession { } pub(crate) fn inventory_view(&self, limit: usize) -> Result { - let player = self.player_id()?; - let carried = self - .state - .packs - .get(&player) - .ok_or_else(|| SessionError::runtime("player-table-missing", "packs"))?; + self.ensure_operational()?; + let carried = self.player_pack()?; let total = carried.items.len(); let mut entries = Vec::new(); entries .try_reserve(total.min(limit)) .map_err(|error| SessionError::runtime("inventory-view-capacity", error.to_string()))?; for instance in carried.items.values().take(limit) { + entries.push(self.project_inventory_entry(instance)?); + } + Ok(InventoryView { + entries, + total, + truncated: total > limit, + }) + } + + fn player_pack(&self) -> Result<&Pack, SessionError> { + let player = self.player_id()?; + self.state + .packs + .get(&player) + .ok_or_else(|| SessionError::runtime("player-table-missing", "packs")) + } + + fn project_inventory_entry( + &self, + instance: &Instance, + ) -> Result { + let effective_identified = self.state.is_effectively_identified(instance); + let (tooltip, tooltip_clipped) = if instance.template == CORPSE_TEMPLATE { + bounded_corpse_tooltip(instance.material)? + } else { let content = self.pack.item(&instance.template).ok_or_else(|| { SessionError::content("item-template-missing", &instance.template) })?; - let effective_identified = self.state.is_effectively_identified(instance); - let uses = content.uses.iter().map(String::as_str).collect::>(); - entries.push(InventoryEntryView { - instance_id: instance.id, - template_id: instance.template.clone(), + bounded_item_tooltip( + &content.name, effective_identified, - tooltip: item_tooltip( - &content.name, - effective_identified, - &uses, - &content.material, - ), - }); + &content.uses, + &content.material, + ) + }; + Ok(InventoryEntryView { + instance_id: instance.id, + template_id: instance.template.clone(), + effective_identified, + tooltip, + tooltip_clipped, + }) + } +} + +impl GameSession { + pub(crate) fn inventory_page( + &self, + query: InventoryPageQuery, + ) -> Result { + self.inventory_page_with_probes( + query, + #[cfg(test)] + None, + ) + } + + fn inventory_page_with_probes( + &self, + query: InventoryPageQuery, + #[cfg(test)] probes: Option<&std::cell::Cell>, + ) -> Result { + use std::ops::Bound::{Excluded, Included, Unbounded}; + + self.ensure_operational()?; + let carried = self.player_pack()?; + let total = carried.items.len(); + let mut candidates = Vec::with_capacity(INVENTORY_PAGE_SIZE + 1); + let backwards = matches!(query, InventoryPageQuery::Before(_)); + match query { + InventoryPageQuery::First => { + for (_, instance) in carried.items.range(..) { + #[cfg(test)] + record_inventory_page_probe(probes); + candidates.push(instance); + if candidates.len() == INVENTORY_PAGE_SIZE + 1 { + break; + } + } + } + InventoryPageQuery::AtOrAfter(anchor) => { + for (_, instance) in carried.items.range((Included(anchor), Unbounded)) { + #[cfg(test)] + record_inventory_page_probe(probes); + candidates.push(instance); + if candidates.len() == INVENTORY_PAGE_SIZE + 1 { + break; + } + } + if candidates.is_empty() { + for (_, instance) in carried.items.range(..).rev() { + #[cfg(test)] + record_inventory_page_probe(probes); + candidates.push(instance); + if candidates.len() == INVENTORY_PAGE_SIZE + 1 { + break; + } + } + candidates.reverse(); + if candidates.len() > INVENTORY_PAGE_SIZE { + candidates.remove(0); + } + } + } + InventoryPageQuery::After(boundary) => { + for (_, instance) in carried.items.range((Excluded(boundary), Unbounded)) { + #[cfg(test)] + record_inventory_page_probe(probes); + candidates.push(instance); + if candidates.len() == INVENTORY_PAGE_SIZE + 1 { + break; + } + } + } + InventoryPageQuery::Before(boundary) => { + for (_, instance) in carried.items.range((Unbounded, Excluded(boundary))).rev() { + #[cfg(test)] + record_inventory_page_probe(probes); + candidates.push(instance); + if candidates.len() == INVENTORY_PAGE_SIZE + 1 { + break; + } + } + candidates.reverse(); + if candidates.len() > INVENTORY_PAGE_SIZE { + candidates.remove(0); + } + } } - Ok(InventoryView { + + if !backwards && candidates.len() > INVENTORY_PAGE_SIZE { + candidates.pop(); + } + let first = candidates.first().map(|instance| instance.id); + let last = candidates.last().map(|instance| instance.id); + let has_before = if let Some(first) = first { + #[cfg(test)] + record_inventory_boundary_probe(probes); + carried.items.range(..first).next_back().is_some() + } else { + false + }; + let has_after = if let Some(last) = last { + #[cfg(test)] + record_inventory_boundary_probe(probes); + carried + .items + .range((Excluded(last), Unbounded)) + .next() + .is_some() + } else { + false + }; + let mut entries = Vec::with_capacity(candidates.len()); + for instance in candidates { + #[cfg(test)] + record_inventory_projection_probe(probes); + entries.push(self.project_inventory_entry(instance)?); + } + Ok(InventoryPage { entries, total, - truncated: total > limit, + has_before, + has_after, }) } - #[cfg(test)] - pub(crate) fn look_inventory_item( + pub(crate) fn inventory_item( &self, instance_id: u64, - ) -> Result { - self.inventory_view(usize::MAX)? - .entries - .into_iter() - .find(|entry| entry.instance_id == instance_id) - .ok_or_else(|| SessionError::content("inventory-item-missing", instance_id.to_string())) + ) -> Result, SessionError> { + self.inventory_item_with_probes( + instance_id, + #[cfg(test)] + None, + ) + } + + fn inventory_item_with_probes( + &self, + instance_id: u64, + #[cfg(test)] probes: Option<&std::cell::Cell>, + ) -> Result, SessionError> { + self.ensure_operational()?; + let carried = self.player_pack()?; + #[cfg(test)] + record_inventory_direct_lookup_probe(probes); + carried + .items + .get(&instance_id) + .map(|instance| { + #[cfg(test)] + record_inventory_projection_probe(probes); + self.project_inventory_entry(instance) + }) + .transpose() } +} +impl GameSession { #[cfg(test)] pub(crate) fn content_hash(&self) -> &str { self.pack.hash() @@ -3389,6 +3662,203 @@ fn validate_plan_content( Ok(()) } +struct InventoryTextBudget { + remaining: usize, + clipped: bool, + #[cfg(test)] + consumed_input: usize, + #[cfg(test)] + logical_use_reads: usize, +} + +impl InventoryTextBudget { + fn new(fixed_output_bytes: usize) -> Self { + Self { + remaining: INVENTORY_TOOLTIP_TEXT_LIMIT + .saturating_sub(INVENTORY_TOOLTIP_CLIPPED_LINE.len()) + .saturating_sub(fixed_output_bytes), + clipped: fixed_output_bytes + INVENTORY_TOOLTIP_CLIPPED_LINE.len() + > INVENTORY_TOOLTIP_TEXT_LIMIT, + #[cfg(test)] + consumed_input: 0, + #[cfg(test)] + logical_use_reads: 0, + } + } + + fn reserve_output(&mut self, bytes: usize) -> bool { + if bytes > self.remaining { + self.clipped = true; + return false; + } + self.remaining -= bytes; + true + } + + fn sanitized_prefix(&mut self, input: &str) -> String { + let mut output = String::new(); + output.reserve(input.len().min(self.remaining)); + for character in input.chars() { + let input_bytes = character.len_utf8(); + if input_bytes > self.remaining { + self.clipped = true; + break; + } + self.remaining -= input_bytes; + #[cfg(test)] + { + self.consumed_input += input_bytes; + } + if character.is_control() { + output.push(' '); + } else { + output.push(character); + } + } + output + } +} + +fn bounded_item_tooltip( + name: &str, + identified: bool, + uses: &[String], + material: &str, +) -> (Tooltip, bool) { + bounded_item_tooltip_inner(name, identified, uses, material).0 +} + +fn bounded_item_tooltip_inner( + name: &str, + identified: bool, + uses: &[String], + material: &str, +) -> ((Tooltip, bool), InventoryTextBudget) { + const MADE_OF_BYTES: usize = "Made of ".len() + ".".len(); + const UNIDENTIFIED_SUFFIX_BYTES: usize = " (unidentified)".len(); + const UNIDENTIFIED_LINE: &str = "[unidentified — you haven't learned this yet]"; + const TRIAL_LINE: &str = "Trial would reveal: safe uses in the Lampwrights' testing chapel, or pay the tinker, or ask a ghost."; + const USES_HEADING_BYTES: usize = "Uses:".len(); + const USE_BULLET_BYTES: usize = "· ".len(); + const EMPTY_USES_LINE: &str = "No recorded uses — flag this: everything has two uses."; + + let fixed_output_bytes = if !identified { + UNIDENTIFIED_SUFFIX_BYTES + UNIDENTIFIED_LINE.len() + MADE_OF_BYTES + TRIAL_LINE.len() + } else { + MADE_OF_BYTES + + USES_HEADING_BYTES + + EMPTY_USES_LINE.len() + + INVENTORY_TOOLTIP_USES_CLIPPED_LINE.len() + }; + let mut budget = InventoryTextBudget::new(fixed_output_bytes); + let bounded_name = budget.sanitized_prefix(name); + let bounded_material = budget.sanitized_prefix(material); + let mut bounded_uses = Vec::new(); + let mut uses_omitted = false; + if identified { + for use_text in uses.iter().take(MAX_TOOLTIP_USE_ROWS) { + #[cfg(test)] + { + budget.logical_use_reads += 1; + } + let bounded = budget.sanitized_prefix(use_text); + if budget.clipped { + uses_omitted = true; + break; + } + if bounded.trim().is_empty() { + continue; + } + if !budget.reserve_output(USE_BULLET_BYTES) { + uses_omitted = true; + break; + } + bounded_uses.push(bounded); + } + if uses.len() > MAX_TOOLTIP_USE_ROWS { + uses_omitted = true; + } + } + budget.clipped |= uses_omitted; + let use_refs = bounded_uses.iter().map(String::as_str).collect::>(); + let mut tooltip = item_tooltip(&bounded_name, identified, &use_refs, &bounded_material); + if uses_omitted && bounded_uses.is_empty() { + tooltip.lines.retain(|line| line != EMPTY_USES_LINE); + tooltip + .lines + .push(INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned()); + } + if budget.clipped { + tooltip + .lines + .push(INVENTORY_TOOLTIP_CLIPPED_LINE.to_owned()); + } + ((tooltip, budget.clipped), budget) +} + +fn bounded_corpse_tooltip(material: u8) -> Result<(Tooltip, bool), SessionError> { + let material = inventory_material_name(material) + .ok_or_else(|| SessionError::runtime("inventory-material-invalid", material.to_string()))?; + Ok(( + Tooltip { + title: "Corpse".to_owned(), + lines: vec!["Remains.".to_owned(), format!("Made of {material}.")], + }, + false, + )) +} + +fn inventory_material_name(material_id: u8) -> Option<&'static str> { + use hexwork_core::voxel::material; + + Some(match material_id { + material::AIR => "AIR", + material::WOOD => "WOOD", + material::IRON => "IRON", + material::COPPER => "COPPER", + material::STONE => "STONE", + material::BRASS => "BRASS", + material::FLESH => "FLESH", + material::SAINT_BONE => "SAINT_BONE", + material::GLASS => "GLASS", + material::ROPE => "ROPE", + material::PAPER => "PAPER", + _ => return None, + }) +} + +#[cfg(test)] +fn update_inventory_probes( + probes: Option<&std::cell::Cell>, + update: impl FnOnce(&mut InventoryQueryProbes), +) { + if let Some(probes) = probes { + let mut value = probes.get(); + update(&mut value); + probes.set(value); + } +} + +#[cfg(test)] +fn record_inventory_page_probe(probes: Option<&std::cell::Cell>) { + update_inventory_probes(probes, |value| value.page += 1); +} + +#[cfg(test)] +fn record_inventory_boundary_probe(probes: Option<&std::cell::Cell>) { + update_inventory_probes(probes, |value| value.boundary += 1); +} + +#[cfg(test)] +fn record_inventory_projection_probe(probes: Option<&std::cell::Cell>) { + update_inventory_probes(probes, |value| value.projected += 1); +} + +#[cfg(test)] +fn record_inventory_direct_lookup_probe(probes: Option<&std::cell::Cell>) { + update_inventory_probes(probes, |value| value.direct_lookup += 1); +} + fn validate_item_conversion(pack: &RuntimePack, id: &str) -> Result<(), SessionError> { let schema = pack .item(id) @@ -7499,6 +7969,371 @@ mod tests { .any(|interaction| { matches!(interaction.kind, InteractionKind::Return) })); } + fn replace_player_inventory(game: &mut GameSession, count: usize) -> Vec { + let player = game.player_id().unwrap(); + let prototype = game.state.packs[&player] + .items + .values() + .next() + .unwrap() + .clone(); + let mut image = game.state.to_image().unwrap(); + let carried = image.packs.get_mut(&player).unwrap(); + carried.items.clear(); + let mut ids = Vec::with_capacity(count); + for index in 0..count { + let id = 10_000 + u64::try_from(index).unwrap() * 2; + let mut instance = prototype.clone(); + instance.id = id; + carried.add(instance); + ids.push(id); + } + image.next_id = ids.last().map_or(10_000, |id| id + 1); + game.state = State::from_image(image).unwrap(); + ids + } + + #[test] + fn inventory_pages_reach_items_beyond_64_without_full_pack_projection() { + let mut game = session(330); + let expected = replace_player_inventory(&mut game, 70); + let mut reached = Vec::new(); + let mut query = InventoryPageQuery::First; + loop { + let page = game.inventory_page(query).unwrap(); + assert!(page.entries.len() <= INVENTORY_PAGE_SIZE); + reached.extend(page.entries.iter().map(|entry| entry.instance_id)); + if !page.has_after { + break; + } + query = InventoryPageQuery::After(page.entries.last().unwrap().instance_id); + } + assert_eq!(reached, expected); + assert_eq!(reached.len(), 70); + } + + #[test] + fn inventory_anchor_refresh_handles_removal_and_empty_pack() { + let mut game = session(331); + let ids = replace_player_inventory(&mut game, 12); + let player = game.player_id().unwrap(); + game.state + .packs + .get_mut(&player) + .unwrap() + .items + .remove(&ids[4]); + + let refreshed = game + .inventory_page(InventoryPageQuery::AtOrAfter(ids[4])) + .unwrap(); + assert_eq!(refreshed.entries[0].instance_id, ids[5]); + let tail = game + .inventory_page(InventoryPageQuery::AtOrAfter(u64::MAX)) + .unwrap(); + assert_eq!( + tail.entries + .iter() + .map(|entry| entry.instance_id) + .collect::>(), + ids[6..].to_vec() + ); + assert!(tail.has_before); + assert!(!tail.has_after); + + let exhausted_after = game + .inventory_page(InventoryPageQuery::After(*ids.last().unwrap())) + .unwrap(); + assert!(exhausted_after.entries.is_empty()); + let exhausted_before = game + .inventory_page(InventoryPageQuery::Before(ids[0])) + .unwrap(); + assert!(exhausted_before.entries.is_empty()); + + game.state.packs.get_mut(&player).unwrap().items.clear(); + for query in [ + InventoryPageQuery::First, + InventoryPageQuery::AtOrAfter(ids[0]), + InventoryPageQuery::After(ids[0]), + InventoryPageQuery::Before(ids[0]), + ] { + let empty = game.inventory_page(query).unwrap(); + assert!(empty.entries.is_empty()); + assert_eq!(empty.total, 0); + assert!(!empty.has_before); + assert!(!empty.has_after); + } + } + + #[test] + fn inventory_selected_lookup_is_direct_and_read_only() { + let mut game = session(332); + let ids = replace_player_inventory(&mut game, 70); + let before = atomic_snapshot(&game); + let probes = std::cell::Cell::new(InventoryQueryProbes::default()); + let selected = game + .inventory_item_with_probes(ids[65], Some(&probes)) + .unwrap() + .unwrap(); + assert_eq!(selected.instance_id, ids[65]); + assert_eq!( + probes.get(), + InventoryQueryProbes { + projected: 1, + direct_lookup: 1, + ..InventoryQueryProbes::default() + } + ); + probes.set(InventoryQueryProbes::default()); + assert!(game + .inventory_item_with_probes(u64::MAX, Some(&probes)) + .unwrap() + .is_none()); + assert_eq!(probes.get().direct_lookup, 1); + assert_eq!(probes.get().projected, 0); + assert_eq!(atomic_snapshot(&game), before); + } + + #[test] + fn inventory_range_queries_inspect_at_most_seven_candidates() { + let mut game = session(333); + let ids = replace_player_inventory(&mut game, 20); + let cases = [ + (InventoryPageQuery::First, ids[0]), + (InventoryPageQuery::AtOrAfter(ids[3]), ids[3]), + (InventoryPageQuery::AtOrAfter(ids[3] + 1), ids[4]), + (InventoryPageQuery::AtOrAfter(u64::MAX), ids[14]), + (InventoryPageQuery::After(ids[3]), ids[4]), + (InventoryPageQuery::Before(ids[10]), ids[4]), + ]; + for (query, expected_first) in cases { + let probes = std::cell::Cell::new(InventoryQueryProbes::default()); + let page = game + .inventory_page_with_probes(query, Some(&probes)) + .unwrap(); + assert_eq!(page.entries[0].instance_id, expected_first); + assert!(probes.get().page <= INVENTORY_PAGE_SIZE + 1); + assert!(probes.get().boundary <= 2); + assert!(probes.get().projected <= INVENTORY_PAGE_SIZE); + assert_eq!(probes.get().direct_lookup, 0); + } + } + + #[test] + fn inventory_page_does_not_project_entries_outside_requested_range() { + let mut game = session(334); + let ids = replace_player_inventory(&mut game, 8); + let player = game.player_id().unwrap(); + game.state + .packs + .get_mut(&player) + .unwrap() + .items + .get_mut(&ids[6]) + .unwrap() + .template = "poison-template-outside-page".to_owned(); + + let first = game.inventory_page(InventoryPageQuery::First).unwrap(); + assert_eq!(first.entries.len(), INVENTORY_PAGE_SIZE); + assert_eq!(first.entries.last().unwrap().instance_id, ids[5]); + assert!(first.has_after); + assert_eq!( + game.inventory_page(InventoryPageQuery::After(ids[5])) + .unwrap_err() + .code, + "item-template-missing" + ); + } + + #[test] + fn inventory_projection_caps_logical_use_inspection_and_reports_omissions() { + let mut late_use = vec![" \t ".to_owned(); MAX_TOOLTIP_USE_ROWS]; + late_use.push("real use after the inspection cap".to_owned()); + let ((late_tooltip, late_clipped), late_budget) = + bounded_item_tooltip_inner("name", true, &late_use, "IRON"); + assert!(late_clipped); + assert_eq!(late_budget.logical_use_reads, MAX_TOOLTIP_USE_ROWS); + assert!(late_budget.consumed_input <= INVENTORY_TOOLTIP_TEXT_LIMIT); + assert!(!late_tooltip + .lines + .iter() + .any(|line| line.contains("real use"))); + assert!(!late_tooltip + .lines + .iter() + .any(|line| line.contains("No recorded uses"))); + assert!(late_tooltip + .lines + .contains(&INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned())); + assert_eq!( + late_tooltip.lines.last().unwrap(), + INVENTORY_TOOLTIP_CLIPPED_LINE + ); + + let blank_uses = vec![" \n\t ".to_owned(); MAX_TOOLTIP_USE_ROWS]; + let ((blank_tooltip, blank_clipped), blank_budget) = + bounded_item_tooltip_inner("name", true, &blank_uses, "IRON"); + assert!(!blank_clipped); + assert_eq!(blank_budget.logical_use_reads, MAX_TOOLTIP_USE_ROWS); + assert!(blank_tooltip + .lines + .iter() + .any(|line| line.contains("No recorded uses"))); + assert!(!blank_tooltip + .lines + .contains(&INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned())); + + let exhausted_name = "x".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT); + let pending_use = vec!["retained only if budget remains".to_owned()]; + let ((exhausted_tooltip, exhausted_clipped), exhausted_budget) = + bounded_item_tooltip_inner(&exhausted_name, true, &pending_use, "IRON"); + assert!(exhausted_clipped); + assert_eq!(exhausted_budget.logical_use_reads, 1); + assert!(exhausted_budget.consumed_input <= INVENTORY_TOOLTIP_TEXT_LIMIT); + assert!(!exhausted_tooltip + .lines + .iter() + .any(|line| line.contains("No recorded uses"))); + assert!(exhausted_tooltip + .lines + .contains(&INVENTORY_TOOLTIP_USES_CLIPPED_LINE.to_owned())); + + let hidden_uses = vec!["secret".to_owned(); MAX_TOOLTIP_USE_ROWS + 1]; + let ((hidden_tooltip, hidden_clipped), hidden_budget) = + bounded_item_tooltip_inner("unknown", false, &hidden_uses, "IRON"); + assert!(!hidden_clipped); + assert_eq!(hidden_budget.logical_use_reads, 0); + assert!(!hidden_tooltip + .lines + .iter() + .any(|line| line.contains("secret"))); + } + + #[test] + fn inventory_projection_caps_utf8_before_allocation_and_discloses_clipping() { + let huge_name = format!("name\n{}", "🜃".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT)); + let huge_material = format!("IRON\t{}", "é".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT)); + let huge_use = format!("burn\r{}", "界".repeat(INVENTORY_TOOLTIP_TEXT_LIMIT)); + for ((tooltip, clipped), budget) in [ + bounded_item_tooltip_inner(&huge_name, true, &[], "IRON"), + bounded_item_tooltip_inner("name", true, &[], &huge_material), + bounded_item_tooltip_inner("name", true, &[huge_use], "IRON"), + ] { + let projected_bytes = + tooltip.title.len() + tooltip.lines.iter().map(String::len).sum::(); + assert!(clipped); + assert_eq!( + tooltip.lines.last().unwrap(), + INVENTORY_TOOLTIP_CLIPPED_LINE + ); + assert!(projected_bytes <= INVENTORY_TOOLTIP_TEXT_LIMIT); + assert!(budget.consumed_input <= INVENTORY_TOOLTIP_TEXT_LIMIT); + assert!(tooltip.title.is_char_boundary(tooltip.title.len())); + assert!(!tooltip.title.chars().any(char::is_control)); + assert!(!tooltip + .lines + .iter() + .any(|line| line.chars().any(char::is_control))); + } + + let game = session(335); + let old = game.inventory_view(1).unwrap().entries.remove(0); + let id = old.instance_id; + let paged = game + .inventory_page(InventoryPageQuery::First) + .unwrap() + .entries + .remove(0); + let direct = game.inventory_item(id).unwrap().unwrap(); + assert_eq!(old, paged); + assert_eq!(old, direct); + } + + #[test] + fn inventory_projection_accepts_carried_corpse_but_rejects_missing_noncorpse_template() { + use hexwork_core::voxel::material; + + let materials = [ + (material::AIR, "AIR"), + (material::WOOD, "WOOD"), + (material::IRON, "IRON"), + (material::COPPER, "COPPER"), + (material::STONE, "STONE"), + (material::BRASS, "BRASS"), + (material::FLESH, "FLESH"), + (material::SAINT_BONE, "SAINT_BONE"), + (material::GLASS, "GLASS"), + (material::ROPE, "ROPE"), + (material::PAPER, "PAPER"), + ]; + let mut game = session(336); + let player = game.player_id().unwrap(); + let mut image = game.state.to_image().unwrap(); + for (material_id, _) in materials { + let id = 90_000 + u64::from(material_id); + let mut corpse = Instance::corpse(id, [0.0; 3]); + corpse.material = material_id; + image.packs.get_mut(&player).unwrap().add(corpse); + } + image.next_id = 90_011; + game.state = State::from_image(image).unwrap(); + let bytes = game.encode_current_run().unwrap(); + let restored = + GameSession::restore_from_bytes(content_root(), &bytes, Mode::Classic).unwrap(); + let canonical = restored.canonical_hash().unwrap(); + let idf = restored.state.idf.clone(); + for (material_id, material_name) in materials { + let entry = restored + .inventory_item(90_000 + u64::from(material_id)) + .unwrap() + .unwrap(); + assert_eq!(entry.tooltip.title, "Corpse"); + assert_eq!( + entry.tooltip.lines, + vec!["Remains.".to_owned(), format!("Made of {material_name}.")] + ); + assert!(!entry.tooltip_clipped); + } + assert_eq!(restored.state.idf, idf); + assert_eq!(restored.canonical_hash().unwrap(), canonical); + + let mut invalid_material = session(350); + let player = invalid_material.player_id().unwrap(); + let id = 91_000; + let mut corpse = Instance::corpse(id, [0.0; 3]); + corpse.material = 199; + invalid_material + .state + .packs + .get_mut(&player) + .unwrap() + .add(corpse); + let error = invalid_material.inventory_item(id).unwrap_err(); + assert_eq!(error.class, SessionErrorClass::Runtime); + assert_eq!(error.code, "inventory-material-invalid"); + + let mut missing_template = session(351); + let player = missing_template.player_id().unwrap(); + let id = missing_template.state.packs[&player] + .items + .keys() + .next() + .copied() + .unwrap(); + missing_template + .state + .packs + .get_mut(&player) + .unwrap() + .items + .get_mut(&id) + .unwrap() + .template = "ordinary-template-missing".to_owned(); + let error = missing_template.inventory_item(id).unwrap_err(); + assert_eq!(error.class, SessionErrorClass::Content); + assert_eq!(error.code, "item-template-missing"); + } + #[test] fn descendant_known_template_changes_real_inventory_knowledge() { let pack = RuntimePack::load(content_root()).unwrap(); @@ -7522,7 +8357,8 @@ mod tests { unknown.state.idf.insert("hymn_card_blank".into(), 1); assert!( !unknown - .look_inventory_item(unknown_item.instance_id) + .inventory_item(unknown_item.instance_id) + .unwrap() .unwrap() .effective_identified ); @@ -8039,6 +8875,12 @@ mod tests { let diagnostic_hash = game.diagnostic_hash(); assert_eq!(game.state().unwrap_err(), frozen); + assert_eq!(game.inventory_view(1).unwrap_err(), frozen); + assert_eq!( + game.inventory_page(InventoryPageQuery::First).unwrap_err(), + frozen + ); + assert_eq!(game.inventory_item(0).unwrap_err(), frozen); assert_eq!(game.snapshot().unwrap_err(), frozen); assert_eq!(game.encode_current_run().unwrap_err(), frozen); assert_eq!(game.encode_replay().unwrap_err(), frozen); diff --git a/crates/hexwork-cli/tests/phase11_gate.rs b/crates/hexwork-cli/tests/phase11_gate.rs index 7568323..bd9f5d0 100644 --- a/crates/hexwork-cli/tests/phase11_gate.rs +++ b/crates/hexwork-cli/tests/phase11_gate.rs @@ -3,6 +3,8 @@ #[path = "phase12/feedback.rs"] pub mod feedback; +#[path = "phase12/inventory.rs"] +mod inventory; #[path = "phase12/keymap.rs"] mod keymap; #[path = "phase11/negative.rs"] diff --git a/crates/hexwork-cli/tests/phase12/inventory.rs b/crates/hexwork-cli/tests/phase12/inventory.rs new file mode 100644 index 0000000..1bc1bb9 --- /dev/null +++ b/crates/hexwork-cli/tests/phase12/inventory.rs @@ -0,0 +1,124 @@ +//! Phase 12 read-only inventory browser process gates. + +use super::support::Harness; +use hexwork_core::action::{CommittedAction, Direction}; +use hexwork_core::runtime::ActorRole; +use hexwork_save::{decode_run, read_file_bounded, SessionEventKind, MAX_COMPRESSED_BYTES}; +use std::fs; +use std::process::Command; + +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) -> hexwork_save::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(run: &hexwork_save::DecodedRun) -> &hexwork_core::actor::Actor { + run.current + .actors + .iter() + .find(|actor| { + run.current + .identity(actor.id) + .is_some_and(|identity| identity.role == ActorRole::Player) + }) + .expect("one saved Player") +} + +#[test] +fn binary_headless_inventory_modal_navigation_is_observational() { + let harness = Harness::new("inventory-headless"); + let script = harness.script( + "inventory.commands", + "input inventory\ninput pick2\ninput confirm\ninput south\ninput look\ninput inventory\ninput west\nobserve state\nquit\n", + ); + let output = Harness::run(harness.managed(&script)); + assert!( + output.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let records = Harness::records(&output); + assert_eq!( + records + .iter() + .filter(|record| record["record_kind"] == "command_ack") + .count(), + 8 + ); + let observation = records + .iter() + .find(|record| record["record_kind"] == "observation") + .expect("state observation after browser and movement"); + assert_eq!(observation["count"], 1); + + let run_id = only_run_id(&harness); + let saved = decode_managed_run(&harness, &run_id); + assert_eq!(saved.events.len(), 1); + assert!(matches!( + &saved.events[0].kind, + SessionEventKind::Action(CommittedAction::Move(Direction::West)) + )); + assert_eq!(player(&saved).pos, [20.0, 31.0, 8.0]); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn binary_pty_inventory_select_look_close_then_move() { + let harness = Harness::new("inventory-pty"); + let scenario = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/pty/inventory_scenario.py"); + let output = Harness::run({ + 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); + command + }); + assert!( + output.status.success(), + "inventory PTY failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let summary: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(summary["exit"], 0); + assert_eq!(summary["list_visible"], true); + assert_eq!(summary["different_item_selected"], true); + assert_eq!(summary["detail_visible"], true); + assert_eq!(summary["closed_without_exit"], true); + assert_eq!(summary["session_over"], true); + + let run_id = summary["run_id"].as_str().unwrap(); + let saved = decode_managed_run(&harness, run_id); + assert_eq!(saved.identity.display_name, "the delver"); + assert_eq!(saved.events.len(), 1); + assert!(matches!( + &saved.events[0].kind, + SessionEventKind::Action(CommittedAction::Move(Direction::West)) + )); + assert_eq!(player(&saved).pos, [20.0, 31.0, 8.0]); +} diff --git a/crates/hexwork-cli/tests/pty/inventory_scenario.py b/crates/hexwork-cli/tests/pty/inventory_scenario.py new file mode 100644 index 0000000..8bfaf51 --- /dev/null +++ b/crates/hexwork-cli/tests/pty/inventory_scenario.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Bounded stdlib PTY proof for the read-only inventory browser.""" + +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 = 240.0 +CAPTURE_LIMIT = 4 * 1024 * 1024 +ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-_])") +LIST_CUE = b"Inventory" +FIRST_TITLE = b"Lampwright lantern" +SECOND_TITLE = b"Lamp oil (flask)" +DETAIL_CUE = b"Inventory detail" +DETAIL_FACT = b"burn in lanterns for light" +PLAY_CUE = b"inventory" + + +def stripped(data): + return ANSI.sub(b"", data).replace(b"\r", b"") + + +def spawn(binary, data_dir, content): + master, slave = os.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + child = subprocess.Popen( + [ + binary, + "--data-dir", + str(data_dir), + "--content-root", + str(content), + "--seed", + "223", + "--ancestry", + "1", + "--calling", + "2", + ], + stdin=slave, + stdout=slave, + stderr=slave, + close_fds=True, + start_new_session=True, + ) + os.close(slave) + os.set_blocking(master, False) + return child, master + + +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 drive(binary, data_dir, content): + child, master = spawn(binary, data_dir, content) + selector = selectors.DefaultSelector() + selector.register(master, selectors.EVENT_READ) + registered = True + captured = bytearray() + stage = 0 + search_from = 0 + list_visible = False + different_item_selected = False + detail_visible = False + closed_without_exit = False + deadline = time.monotonic() + DEADLINE_SECONDS + try: + while child.poll() is None or registered: + remaining = deadline - time.monotonic() + if remaining <= 0.0: + raise RuntimeError("PTY child or EOF drain exceeded 240 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)) + if child.poll() is not None or not registered: + continue + if stage == 0: + found = plain.find(b"Recent feedback", search_from) + if found >= 0: + os.write(master, b"i") + search_from = found + len(b"Recent feedback") + stage = 1 + elif stage == 1: + first = plain.find(FIRST_TITLE, search_from) + second = plain.find(SECOND_TITLE, search_from) + inventory = plain.find(LIST_CUE, search_from) + if first >= 0 and second >= 0 and inventory >= 0: + list_visible = True + os.write(master, b"2") + search_from = max(first, second, inventory) + 1 + stage = 2 + elif stage == 2: + marker = plain.find(b"> [2] Lamp oil (flask)", search_from) + if marker >= 0: + different_item_selected = True + os.write(master, b"\r") + search_from = marker + 1 + stage = 3 + elif stage == 3: + detail = plain.find(DETAIL_CUE, search_from) + fact = plain.find(DETAIL_FACT, search_from) + title = plain.find(SECOND_TITLE, search_from) + if detail >= 0 and fact >= 0 and title >= 0: + detail_visible = True + os.write(master, b"xi") + search_from = max(detail, fact, title) + 1 + stage = 4 + elif stage == 4: + found = plain.find(b"move", search_from) + inventory_hint = plain.find(PLAY_CUE, search_from) + if found >= 0 and inventory_hint >= 0: + closed_without_exit = child.poll() is None + os.write(master, b"a\x1b") + search_from = max(found, inventory_hint) + 1 + stage = 5 + + status = child.wait(timeout=5) + if stage != 5: + raise RuntimeError( + "child exited before inventory flow completed; stage=%d tail=%r" + % (stage, stripped(bytes(captured))[-4000:]) + ) + if status != 0: + raise RuntimeError( + "child exit %d; tail=%r" % (status, stripped(bytes(captured))[-4000:]) + ) + return ( + bytes(captured), + status, + list_visible, + different_item_selected, + detail_visible, + closed_without_exit, + ) + finally: + selector.close() + os.close(master) + terminate(child) + + +def main(): + if len(sys.argv) != 4: + raise RuntimeError("usage: inventory_scenario.py BINARY DATA CONTENT") + binary = sys.argv[1] + data_dir = pathlib.Path(sys.argv[2]) + content = pathlib.Path(sys.argv[3]) + data_dir.mkdir(parents=True, exist_ok=True) + captured, status, list_visible, selected, detail_visible, closed = drive( + binary, data_dir, content + ) + plain = stripped(captured) + summary = { + "exit": status, + "list_visible": list_visible, + "different_item_selected": selected, + "detail_visible": detail_visible, + "closed_without_exit": closed, + "session_over": b"HEXWORK session over" in plain, + "run_id": only_run_id(data_dir), + } + sys.stdout.write(json.dumps(summary, separators=(",", ":")) + "\n") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + sys.stderr.write(("inventory PTY scenario failed: %s\n" % error)[-8192:]) + raise diff --git a/crates/hexwork-cli/tests/routes.rs b/crates/hexwork-cli/tests/routes.rs index 70d647f..c9acc5f 100644 --- a/crates/hexwork-cli/tests/routes.rs +++ b/crates/hexwork-cli/tests/routes.rs @@ -70,6 +70,30 @@ pub fn blocked_move_route(seed: u64) -> BlockedMoveRoute { panic!("no deterministic blocked-move route from {:?}", town.start); } +#[allow(dead_code)] +pub fn contract_board_inputs(seed: u64) -> Vec { + let town = hexwork_worldgen::milthollow(seed); + let board = town + .interactions + .iter() + .find(|interaction| { + matches!( + interaction.kind, + hexwork_worldgen::InteractionKind::NoticeBoard + ) + }) + .expect("canonical contract board interaction"); + let mut inputs = Vec::new(); + append_route( + &mut inputs, + &town.writes, + town.start, + board.access, + actor_blockers_world(&town), + ); + inputs +} + pub fn grate_mother_death_inputs(seed: u64) -> Vec { let town = hexwork_worldgen::milthollow(seed); grate_mother_death_inputs_from(seed, town.start) diff --git a/crates/hexwork-ui/src/inventory.rs b/crates/hexwork-ui/src/inventory.rs new file mode 100644 index 0000000..223a3a0 --- /dev/null +++ b/crates/hexwork-ui/src/inventory.rs @@ -0,0 +1,683 @@ +//! Bounded, presentation-only inventory browser layout and rendering. +//! +//! The parent owns modal state, input routing, terminal clearing, and redraws. +//! This module only projects a caller-supplied snapshot into plain, fitted rows. +//! Wrapping and fitting use the crate's existing scalar/cell-width model; this is +//! not a claim of full grapheme or terminal certification. + +use crate::logfmt::format_log; +use crate::look::Tooltip; +use crate::text::fit_terminal_cells; +use unicode_width::UnicodeWidthChar; + +const MAX_WIDTH: usize = 512; +const MAX_HEIGHT: usize = 256; +const ITEM_SLOTS: usize = 6; +const DETAIL_CHROME_ROWS: usize = 4; +const TEXT_BUDGET_BYTES: usize = 16 * 1024; +const BINDING_BUDGET_BYTES: usize = 64; +const CLIPPED_NOTICE: &str = "[Text clipped to the inventory display budget.]"; + +struct BoundedText { + text: String, + consumed: usize, + clipped: bool, +} + +/// Which inventory-browser presentation is active. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BrowserMode { + List, + Detail, +} + +/// Presentation-only inventory data supplied by the caller. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BrowserContent { + pub mode: BrowserMode, + pub detail_returns_to_list: bool, + pub total: usize, + pub selected: Option, + pub has_before: bool, + pub has_after: bool, + pub titles: Vec, + pub detail: Option, + pub detail_offset: usize, + pub clipped: bool, +} + +/// Already-loaded, player-facing key labels supplied by the caller. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BrowserBindings { + pub up: String, + pub down: String, + pub left: String, + pub right: String, + pub inspect: String, + pub back: String, + pub close: String, + pub quit: String, + pub picks: [String; ITEM_SLOTS], +} + +/// Bounded dimensions and the single retained detail-wrap result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BrowserLayout { + pub width: usize, + pub height: usize, + pub body_height: usize, + pub max_detail_offset: usize, + pub detail_rows: Vec, + pub viewport_capped: bool, +} + +/// Calculate bounded browser dimensions and wrap detail text exactly once. +#[must_use] +pub fn layout_browser(content: &BrowserContent, width: usize, height: usize) -> BrowserLayout { + let viewport_capped = width > MAX_WIDTH || height > MAX_HEIGHT; + let width = width.min(MAX_WIDTH); + let height = height.min(MAX_HEIGHT); + let body_height = height.saturating_sub(DETAIL_CHROME_ROWS); + + let (bounded_lines, projection_clipped) = bounded_detail(content.detail.as_ref()); + let mut detail_rows = Vec::new(); + if width > 0 { + for line in bounded_lines { + detail_rows.extend(format_log(&[line], width, false)); + } + if content.clipped || projection_clipped { + detail_rows.extend(format_log(&[CLIPPED_NOTICE.to_owned()], width, false)); + } + } + let max_detail_offset = detail_rows.len().saturating_sub(body_height); + + BrowserLayout { + width, + height, + body_height, + max_detail_offset, + detail_rows, + viewport_capped, + } +} + +/// Render exactly `layout.height` plain rows, each fitted to `layout.width` cells. +#[must_use] +pub fn render_browser( + content: &BrowserContent, + bindings: &BrowserBindings, + layout: &BrowserLayout, +) -> Vec { + let width = layout.width.min(MAX_WIDTH); + let height = layout.height.min(MAX_HEIGHT); + let viewport_capped = + layout.viewport_capped || layout.width > MAX_WIDTH || layout.height > MAX_HEIGHT; + let mut rows = match content.mode { + BrowserMode::List => render_list(content, bindings, width, viewport_capped), + BrowserMode::Detail => render_detail(content, bindings, layout, viewport_capped), + }; + rows.truncate(height); + while rows.len() < height { + rows.push(String::new()); + } + rows.into_iter() + .map(|row| fit_terminal_cells(&row, width)) + .collect() +} + +fn render_list( + content: &BrowserContent, + bindings: &BrowserBindings, + width: usize, + viewport_capped: bool, +) -> Vec { + let mut rows = vec![header("Inventory", viewport_capped)]; + let visible = content.titles.len().min(ITEM_SLOTS); + let selected = content.selected.filter(|selected| *selected < visible); + let (titles, source_clipped) = bounded_titles(&content.titles[..visible]); + let picks: [String; ITEM_SLOTS] = + std::array::from_fn(|slot| bounded_label(&bindings.picks[slot])); + let width_clipped = titles.iter().enumerate().any(|(slot, title)| { + let marker = if selected == Some(slot) { '>' } else { ' ' }; + let prefix = format!("{marker} [{}] ", picks[slot]); + text_cells(title) > width.saturating_sub(text_cells(&prefix)) + }); + let extra_titles = content.titles.len() > ITEM_SLOTS; + + let page = match (content.has_before, content.has_after) { + (false, false) => "only page", + (false, true) => "first page", + (true, false) => "last page", + (true, true) => "middle page", + }; + let mut status = format!("{} carried; {} shown; {page}", content.total, visible); + if content.total == 0 { + status.push_str("; inventory empty"); + } + if content.selected.is_some() && selected.is_none() { + status.push_str("; selection unavailable"); + } + if content.clipped || source_clipped || width_clipped || extra_titles { + status.insert_str(0, "Text/list clipped; "); + } + rows.push(status); + + for (slot, pick) in picks.iter().enumerate() { + let marker = if selected == Some(slot) { '>' } else { ' ' }; + let title = titles.get(slot).map(String::as_str).unwrap_or("(empty)"); + rows.push(format!("{marker} [{pick}] {title}")); + } + + let up = bounded_label(&bindings.up); + let down = bounded_label(&bindings.down); + let left = bounded_label(&bindings.left); + let right = bounded_label(&bindings.right); + let inspect = bounded_label(&bindings.inspect); + let back = bounded_label(&bindings.back); + let close = bounded_label(&bindings.close); + let quit = bounded_label(&bindings.quit); + rows.push(format!("Navigate: {up}/{down} select; {left}/{right} page")); + rows.push(format!("Inspect: {inspect}/{back}; Close: {close}/{quit}")); + rows +} + +fn render_detail( + content: &BrowserContent, + bindings: &BrowserBindings, + layout: &BrowserLayout, + viewport_capped: bool, +) -> Vec { + let mut rows = vec![header("Inventory detail", viewport_capped)]; + let body_height = layout.body_height.min( + layout + .height + .min(MAX_HEIGHT) + .saturating_sub(DETAIL_CHROME_ROWS), + ); + let max_offset = layout.detail_rows.len().saturating_sub(body_height); + let offset = content.detail_offset.min(max_offset); + let body_end = offset + .saturating_add(body_height) + .min(layout.detail_rows.len()); + let (body_rows, forged_rows_clipped) = + bounded_visible_rows(&layout.detail_rows[offset..body_end]); + + let mut status = format!("{} carried", content.total); + if content.detail.is_none() { + status.push_str("; no item selected"); + } + if content.clipped || forged_rows_clipped { + status.push_str("; source text clipped"); + } + rows.push(status); + rows.extend(body_rows); + while rows.len() < 2 + body_height { + rows.push(String::new()); + } + + let end = offset + .saturating_add(body_height) + .min(layout.detail_rows.len()); + rows.push(format!( + "Rows {}-{} of {}", + if layout.detail_rows.is_empty() { + 0 + } else { + offset + 1 + }, + end, + layout.detail_rows.len() + )); + + let up = bounded_label(&bindings.up); + let down = bounded_label(&bindings.down); + let left = bounded_label(&bindings.left); + let right = bounded_label(&bindings.right); + let back = bounded_label(&bindings.back); + let close = bounded_label(&bindings.close); + let quit = bounded_label(&bindings.quit); + let exit_hint = if content.detail_returns_to_list { + format!("Back to list: {back}/{quit}; Close: {close}") + } else { + format!("Close: {back}/{quit}/{close}") + }; + rows.push(format!( + "Scroll: {up}/{down}, {left}/{right} page; {exit_hint}" + )); + rows +} + +fn header(title: &str, viewport_capped: bool) -> String { + if viewport_capped { + format!("{title} [viewport capped]") + } else { + title.to_owned() + } +} + +fn bounded_titles(titles: &[String]) -> (Vec, bool) { + let mut remaining = TEXT_BUDGET_BYTES; + let mut clipped = false; + let mut bounded = Vec::with_capacity(titles.len().min(ITEM_SLOTS)); + for title in titles.iter().take(ITEM_SLOTS) { + let bounded_title = bounded_text(title, remaining); + remaining = remaining.saturating_sub(bounded_title.consumed); + clipped |= bounded_title.clipped; + bounded.push(bounded_title.text); + } + (bounded, clipped) +} + +fn bounded_visible_rows(rows: &[String]) -> (Vec, bool) { + let mut remaining = TEXT_BUDGET_BYTES; + let mut clipped = false; + let mut bounded = Vec::with_capacity(rows.len()); + for row in rows { + if remaining == 0 { + clipped = true; + break; + } + let bounded_row = bounded_text(row, remaining); + remaining = remaining.saturating_sub(bounded_row.consumed); + clipped |= bounded_row.clipped; + bounded.push(bounded_row.text); + } + if bounded.len() < rows.len() { + clipped = true; + bounded.resize_with(rows.len(), String::new); + } + (bounded, clipped) +} + +fn bounded_detail(detail: Option<&Tooltip>) -> (Vec, bool) { + let Some(detail) = detail else { + return (Vec::new(), false); + }; + let mut remaining = TEXT_BUDGET_BYTES; + let mut clipped = false; + let source_count = detail.lines.len().saturating_add(1); + let mut lines = Vec::with_capacity(source_count.min(TEXT_BUDGET_BYTES)); + + for source in std::iter::once(&detail.title).chain(detail.lines.iter()) { + if remaining == 0 { + clipped = true; + break; + } + // A logical separator is part of the combined title/body text budget, + // including for empty source lines. + remaining -= 1; + let bounded_source = bounded_text(source, remaining); + remaining = remaining.saturating_sub(bounded_source.consumed); + clipped |= bounded_source.clipped; + lines.push(bounded_source.text); + } + clipped |= lines.len() < source_count; + (lines, clipped) +} + +fn bounded_label(label: &str) -> String { + bounded_text(label, BINDING_BUDGET_BYTES).text +} + +fn bounded_text(text: &str, byte_budget: usize) -> BoundedText { + let mut end = text.len().min(byte_budget); + while !text.is_char_boundary(end) { + end -= 1; + } + BoundedText { + text: text[..end] + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect(), + consumed: end, + clipped: end < text.len(), + } +} + +fn text_cells(text: &str) -> usize { + text.chars() + .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::look::item_tooltip; + use unicode_width::UnicodeWidthStr; + + fn bindings() -> BrowserBindings { + BrowserBindings { + up: "Up".to_owned(), + down: "Down".to_owned(), + left: "Left".to_owned(), + right: "Right".to_owned(), + inspect: "Enter".to_owned(), + back: "L".to_owned(), + close: "I".to_owned(), + quit: "Q".to_owned(), + picks: std::array::from_fn(|index| (index + 1).to_string()), + } + } + + fn list_content() -> BrowserContent { + BrowserContent { + mode: BrowserMode::List, + detail_returns_to_list: true, + total: 8, + selected: Some(1), + has_before: false, + has_after: true, + titles: vec!["Lamp oil".to_owned(), "Chalk".to_owned()], + detail: None, + detail_offset: 0, + clipped: false, + } + } + + fn assert_fitted(rows: &[String], width: usize, height: usize) { + assert_eq!(rows.len(), height); + for row in rows { + assert_eq!(UnicodeWidthStr::width(row.as_str()), width, "{row:?}"); + } + } + + #[test] + fn inventory_list_and_detail_fit_80x24_and_clear_stale_rows() { + // Entry supplies the actual 80x16 browser viewport below its eight chrome rows. + let content = list_content(); + let layout = layout_browser(&content, 80, 16); + let rows = render_browser(&content, &bindings(), &layout); + assert_fitted(&rows, 80, 16); + assert!(rows[0].starts_with("Inventory")); + assert!(rows[1].contains("8 carried; 2 shown; first page")); + assert!(rows[2].contains("[1] Lamp oil")); + assert!(rows[3].contains("> [2] Chalk")); + assert!(rows[4].contains("[3] (empty)")); + assert!(rows[8].contains("Navigate: Up/Down select; Left/Right page")); + assert!(rows[9].contains("Inspect: Enter/L; Close: I/Q")); + assert!(rows[10..].iter().all(|row| row.trim().is_empty())); + + let mut detail = content; + detail.mode = BrowserMode::Detail; + detail.detail = Some(item_tooltip("Lamp oil", true, &["burn for light"], "GLASS")); + let layout = layout_browser(&detail, 80, 16); + let rows = render_browser(&detail, &bindings(), &layout); + assert_fitted(&rows, 80, 16); + assert!(rows[0].starts_with("Inventory detail")); + assert_eq!(rows[2].trim(), "Lamp oil"); + assert!(rows.iter().any(|row| row.contains("Made of GLASS."))); + assert!(rows.iter().any(|row| row.contains("Rows 1-4 of 4"))); + assert!( + rows[6..14].iter().all(|row| row.trim().is_empty()), + "unused detail body rows clear stale text" + ); + assert!(rows[15].contains("Scroll: Up/Down")); + } + + #[test] + fn inventory_tooltip_scroll_reaches_all_retained_rows() { + let mut content = list_content(); + content.mode = BrowserMode::Detail; + content.detail = Some(Tooltip { + title: "The retained title".to_owned(), + lines: vec![(1..=20) + .map(|n| format!("word{n}")) + .collect::>() + .join(" ")], + }); + let layout = layout_browser(&content, 12, 8); + assert_eq!(layout.body_height, 4); + assert!(layout.detail_rows.len() > layout.body_height); + assert_eq!( + layout.max_detail_offset, + layout.detail_rows.len() - layout.body_height + ); + assert_eq!(layout.detail_rows[0], "The retained"); + assert_eq!(layout.detail_rows[1], "title"); + + let mut seen = Vec::new(); + for offset in 0..=layout.max_detail_offset { + content.detail_offset = offset; + let rows = render_browser(&content, &bindings(), &layout); + seen.extend( + rows[2..2 + layout.body_height] + .iter() + .map(|row| row.trim().to_owned()), + ); + } + for retained in &layout.detail_rows { + assert!( + seen.contains(retained), + "retained row was unreachable: {retained:?}" + ); + } + + content.detail_offset = usize::MAX; + let rows = render_browser(&content, &bindings(), &layout); + let last = layout.detail_rows.last().expect("retained rows"); + assert!(rows[2..6].iter().any(|row| row.trim() == last)); + } + + #[test] + fn inventory_render_hides_raw_ids_and_unknown_uses() { + let secret_use = "SECRET_USE_DO_NOT_SHOW"; + let raw_id = "18446744073709551614"; + let mut content = list_content(); + content.mode = BrowserMode::Detail; + content.titles = vec!["Phial of green".to_owned()]; + content.detail = Some(item_tooltip( + "Phial of green", + false, + &[secret_use, raw_id], + "GLASS", + )); + let layout = layout_browser(&content, 80, 16); + let visible = render_browser(&content, &bindings(), &layout).join("\n"); + assert!(visible.contains("Phial of green (unidentified)")); + assert!(visible.contains("you haven't learned this yet")); + assert!(visible.contains("Made of GLASS.")); + assert!(!visible.contains(secret_use)); + assert!(!visible.contains(raw_id)); + } + + #[test] + fn inventory_hints_follow_loaded_keymap() { + let custom = BrowserBindings { + up: "K".to_owned(), + down: "J".to_owned(), + left: "H".to_owned(), + right: "N".to_owned(), + inspect: "V".to_owned(), + back: "Q".to_owned(), + close: "B".to_owned(), + quit: "Z".to_owned(), + picks: std::array::from_fn(|index| format!("F{}", index + 1)), + }; + let content = list_content(); + let layout = layout_browser(&content, 80, 16); + let rows = render_browser(&content, &custom, &layout); + assert!(rows[2].contains("[F1] Lamp oil")); + assert!(rows[3].contains("[F2] Chalk")); + assert!(rows[8].contains("Navigate: K/J select; H/N page")); + assert!(rows[9].contains("Inspect: V/Q; Close: B/Z")); + + let mut detail = content; + detail.mode = BrowserMode::Detail; + detail.detail = Some(item_tooltip("Chalk", true, &["mark a route"], "STONE")); + let layout = layout_browser(&detail, 80, 16); + let visible = render_browser(&detail, &custom, &layout).join("\n"); + assert!(visible.contains("Scroll: K/J, H/N page; Back to list: Q/Z; Close: B")); + assert!(!visible.contains("List: V")); + + detail.detail_returns_to_list = false; + let layout = layout_browser(&detail, 80, 16); + let visible = render_browser(&detail, &custom, &layout).join("\n"); + assert!(visible.contains("Scroll: K/J, H/N page; Close: Q/Z/B")); + assert!(!visible.contains("List:") && !visible.contains("Back to list:")); + assert!(!visible.contains("V")); + } + + #[test] + fn inventory_layout_clamps_oversized_and_handles_zero_tiny_dimensions() { + let mut content = list_content(); + content.mode = BrowserMode::Detail; + content.clipped = true; + content.detail = Some(Tooltip { + title: "火".repeat(TEXT_BUDGET_BYTES), + lines: vec!["body".to_owned()], + }); + + let zero = layout_browser(&content, 0, 0); + assert_eq!((zero.width, zero.height, zero.body_height), (0, 0, 0)); + assert!(zero.detail_rows.is_empty()); + assert!(render_browser(&content, &bindings(), &zero).is_empty()); + + let tiny = layout_browser(&content, 1, 1); + let tiny_rows = render_browser(&content, &bindings(), &tiny); + assert_fitted(&tiny_rows, 1, 1); + assert_eq!(tiny.body_height, 0); + + let capped = layout_browser(&content, 513, 257); + assert_eq!((capped.width, capped.height), (512, 256)); + assert_eq!(capped.body_height, 252); + assert!(capped.viewport_capped); + assert!(capped + .detail_rows + .iter() + .any(|row| row.contains("Text clipped"))); + assert!(capped.detail_rows.iter().map(String::len).sum::() < 20_000); + let rows = render_browser(&content, &bindings(), &capped); + assert_fitted(&rows, 512, 256); + assert!(rows[0].contains("viewport capped")); + + let mut malformed = list_content(); + malformed.selected = Some(99); + malformed.titles = (0..8).map(|index| format!("title {index}")).collect(); + let layout = layout_browser(&malformed, 80, 16); + let visible = render_browser(&malformed, &bindings(), &layout).join("\n"); + assert!(visible.contains("selection unavailable")); + assert!(visible.contains("Text/list clipped")); + assert!(!visible.contains("title 6")); + assert!(!visible.contains("title 7")); + assert!(!visible.lines().any(|row| row.starts_with('>'))); + + let mut hostile_bindings = bindings(); + hostile_bindings.up = "U".repeat(1_000_000); + let fabricated = BrowserLayout { + width: usize::MAX, + height: usize::MAX, + body_height: usize::MAX, + max_detail_offset: usize::MAX, + detail_rows: vec!["row".to_owned()], + viewport_capped: false, + }; + let rows = render_browser(&malformed, &hostile_bindings, &fabricated); + assert_fitted(&rows, 512, 256); + assert!(rows[0].contains("viewport capped")); + assert!(rows[8].contains(&"U".repeat(BINDING_BUDGET_BYTES))); + assert!(!rows[8].contains(&"U".repeat(BINDING_BUDGET_BYTES + 1))); + } + + #[test] + fn inventory_forged_detail_rows_bound_zero_width_and_aggregate_source() { + let mut content = list_content(); + content.mode = BrowserMode::Detail; + content.detail = Some(Tooltip::default()); + let combining = "\u{301}".repeat(1_000_000); + let forged = BrowserLayout { + width: MAX_WIDTH, + height: 8, + body_height: 4, + max_detail_offset: 0, + detail_rows: vec![combining.clone(), combining], + viewport_capped: false, + }; + let rows = render_browser(&content, &bindings(), &forged); + assert_fitted(&rows, MAX_WIDTH, 8); + assert!(rows[1].starts_with("8 carried; source text clipped")); + let non_padding: usize = rows.iter().map(|row| row.trim_end_matches(' ').len()).sum(); + assert!(non_padding < TEXT_BUDGET_BYTES + 1_024, "{non_padding}"); + + let forged = BrowserLayout { + width: MAX_WIDTH, + height: MAX_HEIGHT, + body_height: MAX_HEIGHT - DETAIL_CHROME_ROWS, + max_detail_offset: 0, + detail_rows: (0..MAX_HEIGHT - DETAIL_CHROME_ROWS) + .map(|_| "x".repeat(1_000)) + .collect(), + viewport_capped: false, + }; + let rows = render_browser(&content, &bindings(), &forged); + assert!(rows[1].starts_with("8 carried; source text clipped")); + let body_payload: usize = rows[2..MAX_HEIGHT - 2] + .iter() + .map(|row| row.trim_end_matches(' ').len()) + .sum(); + assert!(body_payload <= TEXT_BUDGET_BYTES); + assert!( + rows[20].trim().is_empty(), + "aggregate budget exhausts before row 20" + ); + } + + #[test] + fn inventory_list_discloses_width_only_title_clipping_after_actual_prefix() { + let mut content = list_content(); + content.titles = vec!["ordinary title wider than its available row".to_owned()]; + content.selected = Some(0); + let mut custom = bindings(); + custom.picks[0] = "LONG-PICK".to_owned(); + let layout = layout_browser(&content, 24, 16); + let rows = render_browser(&content, &custom, &layout); + assert!(rows[1].starts_with("Text/list clipped"), "{:?}", rows[1]); + assert!(rows[2].starts_with("> [LONG-PICK] ordinary")); + assert!(!rows[2].contains("available row")); + } + + #[test] + fn inventory_sanitizes_c1_controls_on_all_public_text_surfaces() { + let controls = "\u{85}\u{9b}"; + let mut content = list_content(); + content.titles = vec![format!("title{controls}safe")]; + let mut custom = bindings(); + custom.picks[0] = format!("P{controls}1"); + custom.up = format!("U{controls}P"); + let layout = layout_browser(&content, 80, 16); + let visible = render_browser(&content, &custom, &layout).join("\n"); + assert!(!visible.contains('\u{85}')); + assert!(!visible.contains('\u{9b}')); + assert!(visible.contains("title safe")); + assert!(visible.contains("[P 1]")); + assert!(visible.contains("Navigate: U P/Down")); + + content.mode = BrowserMode::Detail; + content.detail = Some(Tooltip { + title: format!("detail{controls}title"), + lines: vec![format!("body{controls}line")], + }); + let layout = layout_browser(&content, 80, 16); + assert!(layout + .detail_rows + .iter() + .all(|row| !row.contains(['\u{85}', '\u{9b}']))); + assert!(layout + .detail_rows + .iter() + .any(|row| row.contains("detail title"))); + + let forged = BrowserLayout { + width: 80, + height: 8, + body_height: 4, + max_detail_offset: 0, + detail_rows: vec![format!("forged{controls}row")], + viewport_capped: false, + }; + let visible = render_browser(&content, &custom, &forged).join("\n"); + assert!(!visible.contains('\u{85}')); + assert!(!visible.contains('\u{9b}')); + assert!(visible.contains("forged row")); + } +} diff --git a/crates/hexwork-ui/src/lib.rs b/crates/hexwork-ui/src/lib.rs index 6859618..ad92e4f 100644 --- a/crates/hexwork-ui/src/lib.rs +++ b/crates/hexwork-ui/src/lib.rs @@ -7,6 +7,7 @@ pub mod death_ui; pub mod explore; pub mod hud; pub mod identify_ui; +pub mod inventory; pub mod logfmt; pub mod look; pub mod screens; diff --git a/docs/06-implementation-checklist.md b/docs/06-implementation-checklist.md index 786d055..46ad923 100644 --- a/docs/06-implementation-checklist.md +++ b/docs/06-implementation-checklist.md @@ -165,12 +165,13 @@ 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]` — bounded boot-time physical-key remapping is now live in both chargen and play, with rooted missing-only fallback, truthful labels, and actual PTY new/load/save evidence. Independent review and final workspace gates passed. Boot presentation preferences now also apply gamma, perspective FOV and five real color/fog palettes, with strict bounded `presentation.json`, explicit-override persistence under the owned store lock, and actual PTY reload/lock-order evidence. Module/caller review and combined workspace gates passed. Bounded recent feedback is now live with cell-aware wrapping and explicit omission/clipping; only approved atomic input rejections continue in the terminal, while headless and fatal faults remain fail-fast. Exact origin matrices, real PTY/headless feedback tests, independent reviews and final workspace gates passed. In-game sliders, full history/search, screenreader/undo/dyslexia controls, terminal-bell synth wiring, and live accessibility signoff remain open; palette tests alone 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 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. - [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. - [ ] 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 ff472e7..de26e1b 100644 --- a/docs/live-integration-plan.md +++ b/docs/live-integration-plan.md @@ -393,7 +393,9 @@ Live boot presentation preferences are now accepted as well: strict bounded pres Bounded live recent feedback is now accepted too: cell-safe chronological recent messages, explicit clipping/omission, player-facing inventory summaries, and private direct-input rejection handling that preserves fatal/headless semantics. Source matrices, real PTY/headless tests, independent closure and parent final workspace gates passed. This is not full searchable history or semantic screenreader narration. -A real read-only Inventory/Look browser is the next candidate. Pickup/drop mutation and any replay-version change require a separate compatibility/atomicity plan; no destructive format bump is implied by research. In-game settings UI, 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. +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. ### Review Strategy diff --git a/docs/phase12-wiring-plan.md b/docs/phase12-wiring-plan.md index 14a45f3..b085566 100644 --- a/docs/phase12-wiring-plan.md +++ b/docs/phase12-wiring-plan.md @@ -237,6 +237,81 @@ Read-only inspection recommends a real read-only Inventory/Look browser next, us Parent also confirmed strict version equality in save/protocol.rs. Merely bumping run/replay version 4 to 5 would reject existing v4 files unless explicit backward decoding/migration is added; the research report's claim otherwise was contradictory. No version bump or transfer schema decision is authorized by this checkpoint. A read-only browser needs no committed-action or save-format change. Further gameplay transfer verbs require a separate source-grounded atomicity and compatibility plan; equipment's impact on combat must not be assumed. +## Accepted node: read-only Inventory/Look browser + +### Goal and scope + +Replace the immediate-return inventory/look summary with a genuine bounded modal browser using existing inputs and existing player-facing tooltips. No pickup/drop/equip, identification acquisition, Core actions, save/replay wire changes, keymap migration, or version bump. Keep diagnostic headless inventory observations unchanged. + +### Frozen behavior + +- Inventory opens a six-item list; Look from ordinary play opens the first item's detail directly. Empty inventory is an honest empty view, never an error or fabricated item. +- List: Up/Down select within a page and cross to adjacent pages at its edges; Left/Right select previous/next page; configured ChargenPick1..6 selects that visible slot; Confirm or Look opens detail. No selection wraps from the global end to start. At a boundary, remain at the boundary. +- Detail: Up/Down scroll one wrapped row, Left/Right scroll one visible page; Confirm does nothing; Look or Quit returns to the list when entered from Inventory, or closes when entered directly through Look. Inventory closes the whole browser from either mode. Quit on the list closes the browser, not the run. Other inputs are consumed while modal, as with the existing board; explicit headless `quit` remains the unconditional controlled-exit command. +- Split routing is mandatory: after signal/playability checks, only an ALREADY ACTIVE browser intercepts input before tactical stepping/controller dispatch, charges one bounded UI action, and finishes normal lifecycle/signal boundary handling. When closed, retain tactical stepping then PlayController::adapt unchanged; open the browser only from its resulting Control(Inventory/Look) branch. An already-open contract board therefore naturally consumes Inventory/Look rather than opening a second modal. Do not promote board_open to a production getter for this feature. While browser is open, Contracts/Map/Camera and movement not assigned to navigation are consumed, not dispatched. Closing restores the prior Play/Map presentation and leaves camera/tactical Z unchanged. +- No new pause policy: Classic wall-clock ticks continue; Story retains its existing underlying-camera policy (a browser opened over tactical view remains paused, one opened over first-person does not acquire a new pause). New/load/Story lifecycle reset clears browser state, and existing death/signal/batch barriers remain authoritative. + +### Ownership and bounded data contract + +- Browser state belongs to Application presentation state, not GameSession/Core: entry origin (Inventory versus direct Look), List/Detail mode, stable page-anchor ID, stable selected item ID, bounded selected slot, and detail scroll offset. Opening leaves the underlying presentation.screen (Play/Map), camera and tactical Z untouched. The read-only presentation_screen getter and derived PresentationSnapshot.screen report Inventory/Look while the overlay is active; closing exposes the unchanged underlying screen. No return-screen guessing, serialized UI cursor, or duplicated mutable item authority. +- Add session read-only range queries over the Player's BTreeMap pack. Pages contain at most six freshly derived existing InventoryEntryView/Tooltip values, total count, and before/after availability. Use key ranges and at most page-size+1 inspection, not offset/rank scanning or cloning an entire pack each frame. Fetch a selected item directly by stable ID. Keep existing inventory_view(limit) behavior for diagnostic callers. +- Page queries support first page, forward page after a boundary ID, backward page before a boundary ID, and refresh at an inclusive anchor. Missing/removed anchors resolve deterministically to the next surviving page or the last page if no successor remains. Retain selection by ID when still on the refreshed page; otherwise select its first available item. Empty pages have no selection. All items must remain reachable beyond the old 64-entry diagnostic presentation cap; do not call that cap an inventory limit. +- Missing selected items are ordinary refresh conditions; missing Player/pack/template integrity errors remain typed fatal errors and must not be swallowed as empty inventory. Browser queries never learn IDF or alter item flags. Use only Tooltip.title/lines for visible item facts; internal IDs remain cursor keys, not displayed names. +- UI renderer accepts a presentation-only list/detail DTO and emits at most viewport-height cell-fitted rows. At 80×24, the current 16-row viewport is replaced by the browser; the existing eight chrome/log rows stay intact. List has title/status, six selectable rows and actual configured-key hints; detail uses the viewport for title, bounded wrapped body window, scroll status and actual configured-key hints. All rows are padded to clear stale content. No full scene rendering is required behind the opaque browser, but normal tick/lifecycle scheduling continues. +- Bound title/body projection from content before wrapping: at most 16 KiB combined tooltip text per selected item, with explicit clipping disclosure; then wrap once per changed selected-content/width or otherwise cap work to that bounded source. A detail scroll window must reach every retained wrapped row. Do not expose hidden uses when effective identification is false. Scalar/cell-width guarantees reuse accepted UI helpers, not a new Unicode model. + +### Exact API and synthetic-item contract + +- Session adds `InventoryPageQuery::{First, AtOrAfter(u64), After(u64), Before(u64)}` and `InventoryPage { entries: Vec, total: usize, has_before: bool, has_after: bool }`, plus `inventory_page(query) -> Result` and `inventory_item(id) -> Result, SessionError>`. Page size is exactly six maximum; the page-range scan inspects at most seven candidates, with at most two separate before/after boundary probes (at most nine candidate reads total). The private test counter distinguishes those categories, including AtOrAfter's empty primary range and fallback tail query. AtOrAfter with no successor returns the last page; After/Before at an exhausted boundary returns an empty page and the modal retains its existing page. No rank/offset scan. +- Add `tooltip_clipped: bool` to the crate-private InventoryEntryView and use one bounded projection helper for old inventory_view and the new queries. Its total title/body text payload and consumed variable-input budget is 16 KiB before copying/wrapping; count raw UTF-8 bytes, preserve character boundaries, sanitize controls, and disclose clipping. This is not a 16 KiB total-heap assertion. Also cap identified-use traversal at 256 logical rows, including empty strings; unidentifed items never traverse uses. If clipping omits uses, do not falsely claim that none are recorded. Do not copy an arbitrarily large content string before checking the budget. +- Parent verified that restored-state validation explicitly permits template `corpse` in loose and packed items. Project it as title `Corpse`, lines `Remains.` and `Made of .`, using a session-private exhaustive inverse mapping of the eleven protocol constants AIR/WOOD/IRON/COPPER/STONE/BRASS/FLESH/SAINT_BONE/GLASS/ROPE/PAPER from Instance.material. Unknown numeric material is fatal `inventory-material-invalid` (Runtime), never hard-coded FLESH or a numeric guess. No invented uses or identification acquisition. Every other missing template retains fatal `item-template-missing` behavior. Existing diagnostic IDs/flags remain unchanged. +- Application adds `inventory_browser_view(viewport_width, viewport_height) -> Result, AppError>` for bounded refresh/render data, and a private modal input handler called before tactical stepping/controller dispatch. `InventoryBrowserSnapshot` carries mode, total, selected visible slot, before/after flags, at most six player-facing row titles, selected bounded tooltip, detail scroll offset, and clipping status; internal cursor IDs are not display fields. UI state may refresh but canonical state may not. +- UI adds `inventory.rs` with `BrowserMode::{List, Detail}`. Public presentation-only `BrowserContent` fields are `mode`, `total: usize`, `selected: Option`, `has_before: bool`, `has_after: bool`, `titles: Vec` (six maximum), `detail: Option`, `detail_offset: usize`, `clipped: bool`, and `detail_returns_to_list: bool` (derived from Inventory versus direct-Look origin). `BrowserBindings` fields are `up`, `down`, `left`, `right`, `inspect` (Confirm), `back` (Look), `close` (Inventory), `quit` (Quit) as Strings and `picks: [String; 6]`; caller supplies actual loaded labels. List hints advertise Confirm/Look for inspect and Inventory/Quit for close. Detail hints never advertise Confirm: Inventory-origin Look/Quit returns to list and Inventory closes; direct-Look Look/Quit/Inventory closes. All must fit truthful action wording rather than generic mislabeled keys. +- `layout_browser(&BrowserContent, width: usize, height: usize) -> BrowserLayout` owns the single bounded detail-wrap calculation. Public layout fields are `width`, `height`, `body_height`, `max_detail_offset` (usize), `detail_rows: Vec`, and `viewport_capped: bool`. Browser dimensions clamp to 512 columns/256 rows; zero/tiny dimensions are safe. Text is defensively bounded to the same 16 KiB budget before wrapping. `render_browser(&BrowserContent, &BrowserBindings, &BrowserLayout) -> Vec` returns exactly the clamped height of fitted rows and discloses any viewport cap. Application clamps detail_offset using this layout; no second wrapping algorithm. +- `InventoryBrowserSnapshot` contains `content: BrowserContent` and `layout: BrowserLayout`, not a second DTO copy. `inventory_browser_view(width,height)` refreshes bounded UI state and returns that snapshot; input handling uses the last known dimensions (initial/default headless viewport 80×16). Entry obtains the snapshot before borrowing State for rendering. UI module/layout must therefore land before Application modal implementation; then entry follows. +- Entry clears the full actual viewport when entering this opaque browser, including rows beyond the 256-row UI content cap (bounded ANSI erase), and rewrites the eight chrome rows. It marks the previous world framebuffer invalid while the browser is visible and forces every world cell to be emitted on closing, including default blank cells, even if the world has not changed. A same-sized blank framebuffer plus sparse diff is NOT invalidation; use explicit validity/full encoding or a dimension-mismatch full path. Test composed terminal-cell state after world->modal->unchanged-world, not merely absence of the word Inventory in emitted bytes. Browser output is bounded independently of requested dimensions; the pre-existing global terminal-size policy is not widened here. +- All new session APIs are crate-private and read-only; no item ownership transfer or new CommittedAction variants. No mutable item/ID authority is exported through UI DTOs. + +### Implementation plan + +1. Parent obtains focused plan review before mutation. Synthetic-corpse handling is as specified above; missing ordinary templates remain fatal. +2. UI owner implements inventory.rs/layout/renderer/DTOs and UI lib.rs registration. Session query owner may work concurrently in session.rs only, including bounded projection and query tests; new otherwise-unused crate-private query entry points may be temporarily cfg(test) until the caller lands, explicitly not claimed as live. +3. After both APIs are ready, Application/entry owner wires modal presentation state, removes temporary query cfg guards, draws the opaque browser and adds nested phase12/PTY tests. It may edit app.rs, entry.rs, minimal session guard/alias cleanup and test registration, but not the stable query algorithms or UI formatter without a reported blocker. No concurrent writers share files. +4. Independent query/modal and rendering/caller reviews, focused real process evidence, final default-toolchain formatting/locked strict workspace clippy/locked workspace all-target tests, then accepted checkpoint commit without push. + +### Acceptance criteria and named tests + +| AC | Regression/evidence | +|---|---| +| I.1 query bounds | `inventory_pages_reach_items_beyond_64_without_full_pack_projection`, `inventory_anchor_refresh_handles_removal_and_empty_pack`, `inventory_selected_lookup_is_direct_and_read_only`; plus mechanical `inventory_range_queries_inspect_at_most_seven_candidates` (private cfg(test) probe counter/iterator seam), `inventory_page_does_not_project_entries_outside_requested_range` (poison template outside page), and `inventory_projection_caps_utf8_before_allocation_and_discloses_clipping` (oversized multibyte/control input, consumed-byte/output/UTF-8 bounds, old/new projection parity). Cover First/AtOrAfter present/missing/no-successor/After exhausted/Before exhausted/direct lookup. Unit fixtures are not acquisition gameplay. | +| I.2 modal purity | `inventory_navigation_and_look_do_not_mutate_hash_tick_or_history`, `inventory_consumes_movement_and_preserves_board_precedence` (open board first, then Inventory/Look, assert board retained/no browser), `inventory_over_map_does_not_change_slice_or_pause_policy` (assert underlying Map/Play restored on every close path), `inventory_quit_closes_before_run_quit`; actual Application paths, not only enum predicates. | +| I.3 lifecycle | `story_lifecycle_clears_inventory_browser_and_queued_inputs`, `inventory_modal_input_preserves_signal_and_terminal_boundaries`, `inventory_modal_batch_stops_after_story_lifecycle_and_discards_later_inputs`, `new_and_loaded_applications_start_without_inventory_browser_state`. Establish an open browser first. For a replay-valid pending-death unit fixture, drive the ordinary prelethal Grate-mother route, open browser, execute the final genuine recorded Session attack through a cfg(test) seam, then let the next modal input trigger the real App lifecycle/save path. Do not directly reset presentation or inject HP/position; label the bypassed-UI fixture as managed lifecycle unit evidence, not an actual scheduled/PTY death. Signal/fatal tests exercise open-modal paths explicitly. | +| I.4 rendering | `inventory_list_and_detail_fit_80x24_and_clear_stale_rows`, `inventory_tooltip_scroll_reaches_all_retained_rows`, `inventory_render_hides_raw_ids_and_unknown_uses`, `inventory_hints_follow_loaded_keymap`; plus `inventory_layout_clamps_oversized_and_handles_zero_tiny_dimensions` (0/1 and 513×257 ->512×256, row/cell counts/cap flag) and `inventory_close_invalidates_frame_and_forces_unchanged_world_redraw` (world -> opaque browser -> close with no tick/movement -> full emitted world and cleared menu). `inventory_projection_accepts_carried_corpse_but_rejects_missing_noncorpse_template` uses restored-state data, derives material label, proves no uses/IDF acquisition and canonical invariance. Same live helpers, not getter-only proof. | +| I.5 process | `binary_pty_inventory_select_look_close_then_move`: actual ordinary keys open list, select another item, show its actual tooltip, close without quitting, then move and quit; decode saved identity/actions to prove browsing added no committed action. Use bounded child/EOF/PTY machinery and exact data-derived expected item text. | +| I.6 headless | `binary_headless_inventory_modal_navigation_is_observational`: existing normalized inputs drive list/detail/close then an explicit valid move/observe/quit; exact committed history and canonical checks distinguish navigation from movement. | + +### Review Strategy + +Independently review bounded session projection/range access and UI layout before caller integration. Caller review must trace modal precedence, open-browser lifecycle/signal paths, actual live rendering and unchanged-world redraw, then check PTY/headless assertions against their claims. Fix or explicitly rebut findings and obtain closure before final default-toolchain formatting, locked strict workspace all-target clippy and locked workspace all-target tests after the last edit. + +### Documentation Strategy + +Document read-only list/detail controls, six-item paging, stable-ID refresh, direct-Look close behavior, existing clock policy, text/dimension caps and clipping, synthetic-corpse facts, and unchanged canonical/replay formats. Mark only this browser slice accepted; transfer verbs, world-look, acquisition and broad accessibility remain open. + +### Risks and decisions still to resolve before handoff + +The range-query/snapshot/UI DTO signatures and synthetic-item policy above are the parent-frozen handoff candidate, subject to plan review. Tooltip title text is included in the scrollable retained detail rows (not lost solely in a clipped header); lists may clip individual titles with disclosure. Modal input consumes one bounded UI action budget entry, still executes normal lifecycle/signal boundary handling, and never fabricates a committed action. Content refresh can occur while menus are open; bounded stable-ID refresh avoids stale references without treating normal removal as corruption. The browser is carried inventory only, not world-look or ground-item discovery. Transfer verbs remain separate and require atomic all-table updates plus an explicit backward-compatibility decision; strict version equality means a naive v5 bump would invalidate v4 artifacts. + +### Inventory browser verification checkpoint — accepted + +Independent query, defensive UI, modal-caller, and entry/render closures accepted this slice. The query algorithm uses bounded six-item ranges (seven page candidates plus two boundary probes) and direct lookup; projection bounds 16 KiB consumed/text payload and 256 logical use rows, with honest omission semantics. Synthetic valid-material corpse fixtures remain unit evidence, not gameplay acquisition. All temporary production query guards were removed while test counters remained conditional. + +Review found and corrected caller defects: vanished selection now chooses the first surviving item rather than a stale slot; detail paging uses the shared bounded layout body height; hints distinguish Inventory-origin back-to-list from direct-Look close and never advertise no-op Confirm; modal entry clears the full physical viewport beyond the UI cap; initial and post-modal world frames emit every cell, including blanks, rather than sparse blank-to-world diffs. Actual ANSI terminal-grid composition tests verify no stale menu residue in all three color modes. + +Actual Application tests cover board precedence via ordinary route, multipage navigation, selected-ID removal, UI-only budget charging and PauseStep consumption, Map/Play restoration, fresh new/load state, and open-browser lifecycle/signal boundaries. The pending-death fixture uses a genuine recorded Session attack after opening the browser; it is not a scheduled/PTY death or direct HP/position injection. Actual PTY/headless browser tests inspect real tooltip text and decode a save containing only the post-close movement action. Parent final workspace formatting, strict locked workspace all-target clippy, and locked workspace all-target tests passed after the last initial-frame validity correction. + +No Core action, item ownership, save/replay schema, or keymap migration was introduced. This accepts carried-item read-only browsing only; world-look, pickup/drop/equipment, acquisition, full history/search, and accessibility/ship certification remain open. The next item-transfer plan must preserve all-table atomicity and explicitly decide compatibility; no naive destructive version bump is authorized. + ## Historical component integration sketch Two workstreams, disjoint files, one merge point. WS1 (a11y) owns