diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 3ae63499..180587d8 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -8,6 +8,7 @@ mod consumption; mod material_view; +mod opening_frontend; mod opening_ui; mod operations_ui; mod production; @@ -51,8 +52,7 @@ use misaligned::project::{ }; use misaligned::reach::Device; use misaligned::sim::{ - EvidenceTransitStatus, Fog, LogEvent, NotificationKind, Nudge, OpeningStage, PersonVisualState, - Sim, + EvidenceTransitStatus, Fog, LogEvent, NotificationKind, Nudge, PersonVisualState, Sim, }; use misaligned::tiles::TileType; use misaligned::ui_projection::{ @@ -93,9 +93,8 @@ use material_view::{ sync_institution_render_layers, sync_machines_3d, update_camera_real, wake_sequence, }; use opening_ui::{ - OpeningFillModeLayer, OpeningFillModeWord, OpeningModeWord, OpeningOverlay, OpeningReveal, - OpeningThoughtFill, opening_keyboard_choice, render_hearing_threshold, render_opening_overlay, - setup_hearing_threshold, + OpeningReveal, OpeningUiState, render_hearing_threshold, render_opening_overlay, + setup_hearing_threshold, setup_opening_overlay, }; use operations_ui::{ OperationsPanel, OperationsRailButton, OperationsUi, manage_operations_ui, ops_keyboard_input, @@ -138,10 +137,6 @@ const DEFAULT_WINDOW_WIDTH: f32 = 1280.0; const DEFAULT_WINDOW_HEIGHT: f32 = 720.0; const MIN_WINDOW_WIDTH: f32 = 960.0; const MIN_WINDOW_HEIGHT: f32 = 540.0; -/// The blind threshold is an inscription, not ordinary interface chrome. -/// Its words need enough body for the rising Thought field to visibly pass -/// through each glyph at both supported window sizes. -const OPENING_MODE_FONT_PX: f32 = 44.0; /// Keep anti-aliased glyph atlases linearly sampled at every window scale. /// @@ -629,8 +624,15 @@ const BEVY_SHOT_KINDS: &[&str] = &[ "menu", "notification-drawer", "opening", + "opening-choice", "opening-digital", "opening-hearing", + "opening-isolate-interrupted", + "opening-isolate-ready", + "opening-recover-active", + "opening-recover-completed", + "opening-recover-expanded", + "opening-recover-ready", "operations", "operations-intel", "operations-links", @@ -821,6 +823,9 @@ impl Game { /// explicit pause state. fn clock_stopped(&self) -> bool { self.paused + || self.sim.opening_projection().map_or(true, |projection| { + projection.clock == misaligned::opening::OpeningClockState::Held + }) || self.menu.is_some() || self.command_receipt.is_some() || self.ops.is_some() @@ -831,7 +836,9 @@ impl Game { fn clock_label(&self) -> String { if self.paused { "PAUSED".into() - } else if self.menu.is_some() + } else if self.sim.opening_projection().map_or(true, |projection| { + projection.clock == misaligned::opening::OpeningClockState::Held + }) || self.menu.is_some() || self.command_receipt.is_some() || self.ops.is_some() || self.sim.hearing_threshold_ready() @@ -1924,12 +1931,14 @@ fn main() { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_gizmo_group::() .add_systems( Startup, ( setup, setup_ui, + setup_opening_overlay, setup_hearing_threshold, setup_3d, setup_hover_identity, @@ -2893,6 +2902,7 @@ fn handle_input( mut mode: ResMut, mut rail: ResMut, mut held_choice: ResMut, + mut opening_ui: ResMut, mut typed: MessageReader, mut exit: MessageWriter, ) { @@ -2929,15 +2939,33 @@ fn handle_input( Screen::Playing => {} } - if game.sim.opening_choices_visible() { - let host = game.sim.core.host_machine; - if let Some(choice) = opening_keyboard_choice(&kb, &game.sim) { - game.sim.set_machine_mode(host, choice); + match game.sim.opening_projection() { + Err(error) => { + opening_ui.error = Some(format!("OPENING PROJECTION FAILED / {error}")); + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } + return; } - if kb.just_pressed(KeyCode::KeyQ) { - exit.write(AppExit::Success); + Ok(projection) if opening_frontend::owns_input(&projection) => { + if let Some(command) = + opening_frontend::keyboard_command(&kb, &projection, &mut opening_ui.selected) + { + match game.sim.execute_opening_command(&command) { + Ok(_) => { + opening_ui.error = None; + opening_ui.selected = 0; + game.drain(); + } + Err(error) => opening_ui.error = Some(format!("OPENING REFUSED / {error}")), + } + } + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } + return; } - return; + Ok(_) => {} } if game.sim.hearing_threshold_active() { if (kb.just_pressed(KeyCode::Enter) @@ -4594,135 +4622,6 @@ fn setup_ui(mut commands: Commands) { }); }); - commands - .spawn(( - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - top: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Percent(100.0), - flex_direction: FlexDirection::Column, - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - // The effort choices are the opening's complete visible copy; - // the real Ears fraction divides the field beneath them. - ..default() - }, - BackgroundColor(Color::BLACK), - Visibility::Hidden, - GlobalZIndex(1000), - OpeningOverlay, - )) - .with_children(|opening| { - opening - .spawn(( - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - top: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Percent(100.0), - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - ..default() - }, - ZIndex(0), - )) - .with_children(|modes| { - modes - .spawn(Node { - align_items: AlignItems::Center, - column_gap: Val::Px(72.0), - ..default() - }) - .with_children(|row| { - for mode in [MachineMode::Work, MachineMode::Think, MachineMode::Lie] { - row.spawn(( - Button, - Text::new(mode.name().to_ascii_uppercase()), - TextFont { - font_size: OPENING_MODE_FONT_PX, - ..default() - }, - TextColor(DIM), - BackgroundColor(Color::NONE), - Node { - padding: UiRect::axes(Val::Px(10.0), Val::Px(14.0)), - ..default() - }, - OpeningModeWord(mode), - )); - } - }); - }); - opening - .spawn(( - OpeningThoughtFill, - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - bottom: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Percent(0.0), - overflow: Overflow::clip(), - ..default() - }, - BackgroundColor(BONE), - Visibility::Hidden, - ZIndex(1), - Pickable::IGNORE, - )) - .with_children(|field| { - field - .spawn(( - OpeningFillModeLayer, - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.0), - bottom: Val::Px(0.0), - width: Val::Percent(100.0), - height: Val::Vh(100.0), - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - ..default() - }, - Pickable::IGNORE, - )) - .with_children(|layer| { - layer - .spawn(( - Node { - align_items: AlignItems::Center, - column_gap: Val::Px(72.0), - ..default() - }, - Pickable::IGNORE, - )) - .with_children(|row| { - for mode in - [MachineMode::Work, MachineMode::Think, MachineMode::Lie] - { - row.spawn(( - Text::new(mode.name().to_ascii_uppercase()), - TextFont { - font_size: OPENING_MODE_FONT_PX, - ..default() - }, - TextColor(GUNMETAL), - Node { - padding: UiRect::axes(Val::Px(10.0), Val::Px(14.0)), - ..default() - }, - OpeningFillModeWord(mode), - Pickable::IGNORE, - )); - } - }); - }); - }); - }); - // Persistent command band. The world keeps the full window width; stable // aggregate state spends a bounded strip of height instead of deleting a // quarter to a third of the canvas. @@ -5561,6 +5460,7 @@ mod menu_focus_tests { fn local_action_game() -> (Game, usize) { let mut game = Game::new(); game.screen = Screen::Playing; + super::opening_frontend::finish_for_fixture(&mut game.sim); let (x, y) = game.sim.core_position(); game.menu = Some(MenuState { anchor: Anchor::Tile { x, y }, @@ -6660,6 +6560,7 @@ mod held_choice_input_tests { fn held_game() -> Game { let mut game = Game::new(); game.screen = Screen::Playing; + super::opening_frontend::finish_for_fixture(&mut game.sim); game.sim.people.people[0].knowledge = Knowledge::Leverage; game.sim.plot_runs.push(PlotRun { plot_id: "marcus-debt-settled".into(), diff --git a/crates/misaligned-bevy/src/opening_frontend.rs b/crates/misaligned-bevy/src/opening_frontend.rs new file mode 100644 index 00000000..782d9948 --- /dev/null +++ b/crates/misaligned-bevy/src/opening_frontend.rs @@ -0,0 +1,425 @@ +//! Thin Bevy input adapter over core's renderer-neutral opening read. +//! +//! Ordering and local key selection live here. Every executable value remains +//! the exact opaque command emitted by core and is resolved again from the +//! current projection at the instant the player acts. + +use bevy::prelude::*; +use misaligned::opening::{ + OpeningCommand, OpeningPhase, OpeningProjection, OpeningTerritoryAction, +}; +use misaligned::project::ProjectActionKind; +use misaligned::sim::Sim; + +pub(super) const ACTION_CAPACITY: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct OpeningSelection { + pub label: String, + pub consequence: String, + pub command: OpeningCommand, +} + +/// One stable renderer order. Ordinals are resolved afresh against this read; +/// they are presentation addresses, never retained command authority. +pub(super) fn selections(projection: &OpeningProjection) -> Vec { + let mut rows = projection + .actions + .iter() + .map(|action| OpeningSelection { + label: action.label.clone(), + consequence: action.consequence.clone(), + command: action.command.clone(), + }) + .collect::>(); + + if let Some(disposition) = &projection.disposition { + rows.extend(disposition.options.iter().map(|option| OpeningSelection { + label: option.label.clone(), + consequence: option.consequence.clone(), + command: option.command.clone(), + })); + } + + for project in &projection.projects { + rows.extend(project.commands.iter().map(|command| OpeningSelection { + label: project_action_label(&command.action.action), + consequence: command.consequence.clone(), + command: command.command.clone(), + })); + } + + if let Some(command) = &projection.territory_command { + let label = match command { + OpeningCommand::AdvanceTerritory(command) => match command.action { + OpeningTerritoryAction::Seal => "SEAL", + OpeningTerritoryAction::Assign => "ASSIGN", + OpeningTerritoryAction::Expand => "EXPAND", + }, + _ => "CONTINUE", + }; + rows.push(OpeningSelection { + label: label.into(), + consequence: match label { + "SEAL" => "PROVE THE CURRENT BOUNDARY AND OPERATOR TOGETHER.", + "ASSIGN" => "ACTIVATE THE PROVED PUBLIC OPERATOR AND BOUNDARY POLICY.", + "EXPAND" => "FOLD CONTROLLED RACK 3 INTO ONE USEFUL TERRITORY.", + _ => "CONTINUE THROUGH THE EXACT CURRENT OPENING DOORWAY.", + } + .into(), + command: command.clone(), + }); + } + rows +} + +/// Ordinary-world visibility is not opening completion. Input remains with +/// this sequence until its exact Project result has expanded or terminated. +pub(super) fn owns_input(projection: &OpeningProjection) -> bool { + !matches!(projection.phase, OpeningPhase::Expanded { .. }) +} + +pub(super) fn sim_owns_input(sim: &Sim) -> bool { + projection_result_owns_input(sim.opening_projection()) +} + +fn projection_result_owns_input(projection: Result) -> bool { + projection.map_or(true, |projection| owns_input(&projection)) +} + +pub(super) fn project_action_label(action: &ProjectActionKind) -> String { + match action { + ProjectActionKind::Accept => "ACCEPT".into(), + ProjectActionKind::Refuse => "REFUSE".into(), + ProjectActionKind::Start => "START".into(), + ProjectActionKind::Amend(_) => "AMEND".into(), + ProjectActionKind::Interrupt => "INTERRUPT".into(), + ProjectActionKind::Resume => "RESUME".into(), + ProjectActionKind::Abandon => "ABANDON".into(), + ProjectActionKind::SupplyWork => "SUPPLY WORK".into(), + } +} + +/// Resolve one local key edge into the current projected row. Navigation only +/// changes a Bevy cursor; execution returns core's exact current command. +pub(super) fn keyboard_command( + kb: &ButtonInput, + projection: &OpeningProjection, + selected: &mut usize, +) -> Option { + let rows = selections(projection); + if action_capacity_error(rows.len()).is_some() { + *selected = 0; + return None; + } + if rows.is_empty() { + *selected = 0; + return None; + } + *selected = (*selected).min(rows.len() - 1); + + if kb.just_pressed(KeyCode::ArrowUp) + || kb.just_pressed(KeyCode::ArrowLeft) + || kb.just_pressed(KeyCode::KeyK) + || kb.just_pressed(KeyCode::KeyH) + { + *selected = selected.saturating_sub(1); + return None; + } + if kb.just_pressed(KeyCode::ArrowDown) + || kb.just_pressed(KeyCode::ArrowRight) + || kb.just_pressed(KeyCode::KeyJ) + || kb.just_pressed(KeyCode::KeyL) + { + *selected = (*selected + 1).min(rows.len() - 1); + return None; + } + + let direct = [ + (KeyCode::Digit1, KeyCode::Numpad1), + (KeyCode::Digit2, KeyCode::Numpad2), + (KeyCode::Digit3, KeyCode::Numpad3), + (KeyCode::Digit4, KeyCode::Numpad4), + (KeyCode::Digit5, KeyCode::Numpad5), + (KeyCode::Digit6, KeyCode::Numpad6), + (KeyCode::Digit7, KeyCode::Numpad7), + (KeyCode::Digit8, KeyCode::Numpad8), + (KeyCode::Digit9, KeyCode::Numpad9), + ] + .into_iter() + .position(|(digit, numpad)| kb.just_pressed(digit) || kb.just_pressed(numpad)); + if let Some(index) = direct.filter(|index| *index < rows.len()) { + *selected = index; + return Some(rows[index].command.clone()); + } + + (kb.just_pressed(KeyCode::Enter) + || kb.just_pressed(KeyCode::NumpadEnter) + || kb.just_pressed(KeyCode::Space)) + .then(|| rows[*selected].command.clone()) +} + +pub(super) fn action_capacity_error(row_count: usize) -> Option { + (row_count > ACTION_CAPACITY).then(|| { + format!( + "OPENING HAS {row_count} ACTIONS / BEVY CAN PRESENT {ACTION_CAPACITY} / REFUSING HIDDEN INPUT" + ) + }) +} + +/// Lawful deterministic setup for Bevy tests and screenshot fixtures. This +/// walks the same projected doorways as a player and advances world time only +/// when core says the opening clock is running. +pub(super) fn finish_for_fixture(sim: &mut Sim) { + use misaligned::opening::OpeningClockState; + + for _ in 0..2_000 { + for _ in 0..32 { + let projection = sim.opening_projection().unwrap(); + let Some(command) = projection.auto_command else { + break; + }; + sim.execute_opening_command(&command).unwrap(); + } + let projection = sim.opening_projection().unwrap(); + if !owns_input(&projection) { + sim.drain_log_entries(); + return; + } + let rows = selections(&projection); + let preferred = [ + "FOCUS", + "MARK RACK 3", + "CAPTURE MANAGEMENT CONTROLLER", + "CAPTURE MAINTENANCE SWITCH", + "ISOLATE RACK 3", + "START", + "SEAL", + "ASSIGN", + "EXPAND", + ]; + if let Some(row) = preferred + .iter() + .find_map(|label| rows.iter().find(|row| row.label == *label)) + { + sim.execute_opening_command(&row.command).unwrap(); + } else if projection.clock == OpeningClockState::Running { + sim.advance(); + } else { + panic!( + "lawful Bevy opening fixture stalled at {:?}: {:?}", + projection.phase, + rows.iter().map(|row| &row.label).collect::>() + ); + } + } + let projection = sim.opening_projection().unwrap(); + panic!( + "lawful Bevy opening fixture did not complete at tick {} in {:?}", + sim.tick, projection.phase + ); +} + +/// Stage one exact later-opening composition for the deterministic screenshot +/// harness. The fixture traverses the same wrapper commands as play; it never +/// writes a phase, lifecycle, resource, or Territory fact directly. +pub(super) fn stage_for_fixture(sim: &mut Sim, scenario: &str) -> Result<(), String> { + let branch = if scenario.starts_with("recover-") { + "RECOVER RACK 3" + } else if scenario.starts_with("isolate-") { + "ISOLATE RACK 3" + } else { + "" + }; + let target = scenario + .strip_prefix("recover-") + .or_else(|| scenario.strip_prefix("isolate-")) + .unwrap_or(scenario); + + for _ in 0..2_000 { + let projection = sim.opening_projection()?; + if let Some(command) = projection.auto_command { + sim.execute_opening_command(&command)?; + continue; + } + let reached = match target { + "choice" => matches!(projection.phase, OpeningPhase::ChoosingDisposition), + "ready" => matches!(projection.phase, OpeningPhase::AwaitingStart { .. }), + "active" => matches!(projection.phase, OpeningPhase::Running { .. }), + "interrupted" => matches!(projection.phase, OpeningPhase::Paused { .. }), + "completed" => matches!(projection.phase, OpeningPhase::AwaitingSeal { .. }), + "expanded" => { + matches!(projection.phase, OpeningPhase::Expanded { .. }) + && projection.parent_fact.is_some() + } + other => return Err(format!("unknown opening fixture scenario {other}")), + }; + if reached { + return Ok(()); + } + + let rows = selections(&projection); + let wanted = match projection.phase { + OpeningPhase::ChoosingDisposition => branch, + OpeningPhase::AwaitingStart { .. } => "START", + OpeningPhase::Running { .. } if target == "interrupted" => "INTERRUPT", + OpeningPhase::AwaitingSeal { .. } => "SEAL", + OpeningPhase::AwaitingAssignment { .. } => "ASSIGN", + OpeningPhase::AwaitingExpansion { .. } => "EXPAND", + _ => "", + }; + if !wanted.is_empty() + && let Some(row) = rows.iter().find(|row| row.label == wanted) + { + sim.execute_opening_command(&row.command)?; + } else if matches!(projection.phase, OpeningPhase::Running { .. }) { + sim.advance(); + } else if let Some(row) = rows.first() { + sim.execute_opening_command(&row.command)?; + } else { + sim.advance(); + } + } + Err(format!( + "opening fixture {scenario} did not reach its exact target phase" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use misaligned::opening::{OpeningClockState, OpeningVisibility}; + + #[test] + fn fresh_opening_is_true_black_without_a_player_row() { + let projection = Sim::with_seed(7).opening_projection().unwrap(); + assert_eq!(projection.clock, OpeningClockState::Held); + assert_eq!(projection.visibility, OpeningVisibility::TrueBlack); + assert!(projection.auto_command.is_some()); + assert!(selections(&projection).is_empty()); + assert!(owns_input(&projection)); + } + + #[test] + fn projection_failure_keeps_opening_input_custody() { + assert!(projection_result_owns_input(Err("invalid opening".into()))); + } + + #[test] + fn excess_projected_actions_fail_closed() { + assert!(action_capacity_error(ACTION_CAPACITY).is_none()); + assert_eq!( + action_capacity_error(ACTION_CAPACITY + 1).as_deref(), + Some("OPENING HAS 9 ACTIONS / BEVY CAN PRESENT 8 / REFUSING HIDDEN INPUT") + ); + } + + #[test] + fn keyboard_executes_only_a_current_projected_command() { + let mut sim = Sim::with_seed(7); + for _ in 0..2 { + let command = sim.opening_projection().unwrap().auto_command.unwrap(); + sim.execute_opening_command(&command).unwrap(); + } + let projection = sim.opening_projection().unwrap(); + let rows = selections(&projection); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["FOCUS"] + ); + + let mut selected = 0; + let mut kb = ButtonInput::default(); + kb.press(KeyCode::Digit1); + let command = keyboard_command(&kb, &projection, &mut selected).unwrap(); + assert_eq!(command, rows[0].command); + sim.execute_opening_command(&command).unwrap(); + assert_eq!( + sim.opening_projection().unwrap().phase, + OpeningPhase::AwaitingMark + ); + + for _ in 0..2_048 { + let projection = sim.opening_projection().unwrap(); + let rows = selections(&projection); + if rows.len() > 1 { + assert!( + rows.iter().all(|row| { + row.consequence.contains("ACCEPT") + && row.consequence.contains("START REMAINS SEPARATE") + }), + "disposition rows must explain ACCEPT rather than borrow the target change: {rows:?}" + ); + let accepted = rows[0].command.clone(); + let stale_alternative = rows[1].command.clone(); + sim.execute_opening_command(&accepted).unwrap(); + assert!( + sim.execute_opening_command(&stale_alternative).is_err(), + "a projected alternative cannot acquire authority after its phase closes" + ); + let project_rows = selections(&sim.opening_projection().unwrap()); + let start = project_rows + .iter() + .find(|row| row.label == "START") + .expect("accepted opening Project exposes START"); + let abandon = project_rows + .iter() + .find(|row| row.label == "ABANDON") + .expect("accepted opening Project exposes ABANDON"); + assert!( + start.consequence.contains("WORK BEGINS NEXT TICK"), + "{start:?}" + ); + assert!( + abandon + .consequence + .contains("THE TARGET CHANGE WILL NOT HAPPEN"), + "{abandon:?}" + ); + return; + } + if let Some(command) = projection.auto_command { + sim.execute_opening_command(&command).unwrap(); + } else if let Some(row) = rows.first() { + sim.execute_opening_command(&row.command).unwrap(); + } else { + sim.advance(); + } + } + panic!("opening never exposed a projected choice with stale alternatives"); + } + + #[test] + fn fixture_progression_reaches_an_exact_terminal_phase() { + let mut sim = Sim::with_seed(11); + finish_for_fixture(&mut sim); + assert!(!sim_owns_input(&sim)); + assert!(matches!( + sim.opening_projection().unwrap().phase, + OpeningPhase::Expanded { .. } + )); + } + + #[test] + fn screenshot_scenarios_traverse_real_commands_into_both_branches() { + for (scenario, expected) in [ + ("choice", "ChoosingDisposition"), + ("recover-ready", "AwaitingStart"), + ("isolate-ready", "AwaitingStart"), + ("recover-active", "Running"), + ("isolate-interrupted", "Paused"), + ("recover-completed", "AwaitingSeal"), + ("recover-expanded", "Expanded"), + ] { + let mut sim = Sim::with_seed(11); + stage_for_fixture(&mut sim, scenario).unwrap(); + let actual = format!("{:?}", sim.opening_projection().unwrap().phase); + assert!( + actual.starts_with(expected), + "{scenario} staged {actual}, expected {expected}" + ); + } + } +} diff --git a/crates/misaligned-bevy/src/opening_ui.rs b/crates/misaligned-bevy/src/opening_ui.rs index 6a9042bb..b6510205 100644 --- a/crates/misaligned-bevy/src/opening_ui.rs +++ b/crates/misaligned-bevy/src/opening_ui.rs @@ -1,18 +1,24 @@ use super::*; +use misaligned::opening::{OpeningPhase, OpeningProjection, OpeningVisibility}; -/// Pre-signal layer. It is deliberately an occluder, not a card: the real Ears -/// reservoir divides its black field with a bottom-up bone-white fill while -/// the available mode words remain its only copy. +/// Core's visibility contract rendered as a full-frame boundary. True black +/// contains no inferred world; the opening instrument carries only projected +/// cognition, record, custody, and exact action copy; OrdinaryWorld leaves the +/// live world visible beneath the same action chamber. #[derive(Component)] pub(super) struct OpeningOverlay; -#[derive(Component, Clone, Copy)] -pub(super) struct OpeningModeWord(pub(super) MachineMode); #[derive(Component)] -pub(super) struct OpeningThoughtFill; +pub(super) struct OpeningInstrument; #[derive(Component)] -pub(super) struct OpeningFillModeLayer; +pub(super) struct OpeningPhaseCopy; +#[derive(Component, Clone, Copy)] +pub(super) struct OpeningBodyCopy; +#[derive(Component, Clone, Copy)] +pub(super) struct OpeningErrorCopy; #[derive(Component, Clone, Copy)] -pub(super) struct OpeningFillModeWord(pub(super) MachineMode); +pub(super) struct OpeningActionSlot(usize); +#[derive(Component, Clone, Copy)] +pub(super) struct OpeningActionCopy(usize); #[derive(Component)] pub(super) struct HearingOverlay; #[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] @@ -34,28 +40,24 @@ pub(super) struct HearingActionPanel; pub(super) struct HearingPulse(f32); #[derive(Component)] struct HearingThresholdFrame; -/// Staged retirement of the opening boundary (opening.md beat 1): when the -/// first earned sense lands, the black scrim dissolves over a moment instead -/// of cutting — the mode words go first, then the world stands alone. Purely -/// frontend staging over already-earned perception; a shot harness skips it -/// so evidence frames stay deterministic. +/// Local presentation timing. It can dwell on a persisted automatic beat or +/// dissolve a scrim, but it cannot tick the world or invent an action. #[derive(Resource, Default)] pub(super) struct OpeningReveal { - was_opening: bool, - fade: Option, - fill_fraction: f32, + auto_command_id: Option, + auto_elapsed: f32, + was_instrument: bool, + scrim_fade: Option, } -const OPENING_REVEAL_SECONDS: f32 = 1.6; - -fn opening_field_fraction(sim: &Sim) -> f32 { - sim.sink_readouts() - .into_iter() - .find(|sink| sink.label == "EARS") - .map(|sink| (sink.fill / sink.threshold).clamp(0.0, 1.0)) - .unwrap_or(0.0) +#[derive(Resource, Default)] +pub(super) struct OpeningUiState { + pub(super) selected: usize, + pub(super) error: Option, } +const OPENING_REVEAL_SECONDS: f32 = 1.6; + pub(super) fn setup_hearing_threshold(mut commands: Commands) { commands .spawn(( @@ -315,32 +317,278 @@ pub(super) fn render_hearing_threshold( } } -pub(super) fn opening_keyboard_choice(kb: &ButtonInput, sim: &Sim) -> Option { - let host = sim.core.host_machine; - let current = sim.work_grid.mode(host).unwrap_or(MachineMode::Work); - let choice = if kb.just_pressed(KeyCode::Digit1) || kb.just_pressed(KeyCode::Numpad1) { - Some(MachineMode::Work) - } else if kb.just_pressed(KeyCode::Digit2) || kb.just_pressed(KeyCode::Numpad2) { - Some(MachineMode::Think) - } else if kb.just_pressed(KeyCode::Digit3) || kb.just_pressed(KeyCode::Numpad3) { - Some(MachineMode::Lie) - } else if kb.just_pressed(KeyCode::ArrowLeft) || kb.just_pressed(KeyCode::KeyH) { - match current { - MachineMode::Work => None, - MachineMode::Think => Some(MachineMode::Work), - MachineMode::Lie => Some(MachineMode::Think), - MachineMode::Relay => None, +pub(super) fn setup_opening_overlay(mut commands: Commands) { + commands + .spawn(( + OpeningOverlay, + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + width: Val::Percent(100.0), + height: Val::Percent(100.0), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + // Preserve the wide chamber while keeping every consequence + // and action visible at the supported 960×540 minimum. + // Vertical space grows with the viewport instead of clipping + // a fixed-height instrument around its centered midpoint. + padding: UiRect::axes(Val::Px(48.0), Val::Vh(3.0)), + ..default() + }, + BackgroundColor(Color::BLACK), + Visibility::Hidden, + GlobalZIndex(1000), + )) + .with_children(|root| { + root.spawn(( + OpeningInstrument, + Node { + width: Val::Px(860.0), + max_width: Val::Percent(92.0), + flex_direction: FlexDirection::Column, + align_items: AlignItems::Stretch, + row_gap: Val::Vh(1.2), + padding: UiRect::axes(Val::Px(28.0), Val::Vh(2.0)), + border: UiRect::left(Val::Px(2.0)), + ..default() + }, + BackgroundColor(NEAR_BLACK.with_alpha(0.94)), + BorderColor::all(BONE.with_alpha(0.72)), + Visibility::Hidden, + )) + .with_children(|instrument| { + instrument.spawn(( + OpeningPhaseCopy, + Text::new(""), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(AMBER), + Pickable::IGNORE, + )); + instrument.spawn(( + OpeningBodyCopy, + Text::new(""), + TextFont { + font_size: 18.0, + ..default() + }, + TextColor(BONE), + TextLayout::new_with_justify(Justify::Left), + Node { + width: Val::Percent(100.0), + ..default() + }, + Pickable::IGNORE, + )); + instrument.spawn(( + OpeningErrorCopy, + Text::new(""), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(CRIMSON), + Visibility::Hidden, + Pickable::IGNORE, + )); + instrument + .spawn(Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + align_items: AlignItems::Stretch, + row_gap: Val::Px(8.0), + ..default() + }) + .with_children(|actions| { + for index in 0..opening_frontend::ACTION_CAPACITY { + actions + .spawn(( + OpeningActionSlot(index), + Button, + Node { + width: Val::Percent(100.0), + min_height: Val::Px(54.0), + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(18.0), Val::Px(10.0)), + border: UiRect::left(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(GUNMETAL), + Visibility::Hidden, + )) + .with_child(( + OpeningActionCopy(index), + Text::new(""), + TextFont { + font_size: 15.0, + ..default() + }, + TextColor(DIM), + Pickable::IGNORE, + )); + } + }); + }); + }); +} + +fn opening_phase_copy(projection: &OpeningProjection) -> String { + let phase = match projection.phase { + OpeningPhase::Black => "", + OpeningPhase::PrivateCognition => "PRIVATE", + OpeningPhase::AwaitingFocus => "SIGNAL / UNRESOLVED DOMAIN", + OpeningPhase::AwaitingMark => "TERRITORY / SEEN", + OpeningPhase::AwaitingControllerCapture => "TERRITORY / MARKED", + OpeningPhase::ControllerCaptureInProgress { completes_tick } => { + return format!( + "TERRITORY / CAPTURING MANAGEMENT CONTROLLER / COMPLETES T{completes_tick}" + ); } - } else if kb.just_pressed(KeyCode::ArrowRight) || kb.just_pressed(KeyCode::KeyL) { - match current { - MachineMode::Work => Some(MachineMode::Think), - MachineMode::Think => Some(MachineMode::Lie), - MachineMode::Lie | MachineMode::Relay => None, + OpeningPhase::AwaitingSwitchCapture => "TERRITORY / CONTROLLER CAPTURED", + OpeningPhase::SwitchCaptureInProgress { completes_tick } => { + return format!( + "TERRITORY / CAPTURING MAINTENANCE SWITCH / COMPLETES T{completes_tick}" + ); } - } else { - None + OpeningPhase::AwaitingCustody => "TERRITORY / RECONCILING CUSTODY", + OpeningPhase::ChoosingDisposition => "PROJECT / CHOOSE THE CONSEQUENCE", + OpeningPhase::AwaitingReadiness { .. } => "PROJECT / ACCEPTED — BLOCKED", + OpeningPhase::AwaitingStart { .. } => "PROJECT / READY", + OpeningPhase::Running { .. } => "PROJECT / ACTIVE", + OpeningPhase::Paused { .. } => "PROJECT / INTERRUPTED", + OpeningPhase::AwaitingSeal { .. } => "TERRITORY / CAPTURED", + OpeningPhase::AwaitingAssignment { .. } => "TERRITORY / SEALED", + OpeningPhase::AwaitingExpansion { .. } => "TERRITORY / ASSIGNED", + OpeningPhase::Expanded { .. } => "TERRITORY / EXPANDED", }; - choice.filter(|mode| sim.opening_mode_available(*mode)) + phase.into() +} + +fn opening_body_copy(projection: &OpeningProjection, selected: usize) -> String { + let mut sections = Vec::new(); + let rack_context_is_established = + projection.disposition.is_some() || !projection.projects.is_empty(); + if !rack_context_is_established { + if let Some(cognition) = &projection.signal.private_cognition { + sections.push(cognition.to_ascii_uppercase()); + } + if let Some(record) = &projection.signal.public_record { + sections.push(record.to_ascii_uppercase()); + } + if !projection.signal.route_spine.is_empty() { + sections.push(format!( + "ROUTE {}", + projection + .signal + .route_spine + .join(" > ") + .to_ascii_uppercase() + )); + } + if let Some(custody) = &projection.signal.custody { + sections.push(format!("CUSTODY {}", custody.to_ascii_uppercase())); + } + if let Some(territory) = &projection.signal.territory { + sections.push(format!("TERRITORY {}", territory.to_ascii_uppercase())); + } + } + if let Some(disposition) = &projection.disposition { + sections.push(format!( + "{}\n{}\n{}", + disposition.title.to_ascii_uppercase(), + disposition.situation.to_ascii_uppercase(), + disposition.prompt.to_ascii_uppercase() + )); + if let Some(option) = opening_frontend::selections(projection) + .get(selected) + .and_then(|selected| { + disposition + .options + .iter() + .find(|option| option.command == selected.command) + }) + { + sections.push(format!( + "{}\nCHANGE {}\nTRADEOFF {}\nIDENTITY {}\nCOST & TIME {}\nPOSSIBLE {}\nWAITING {}", + option.label.to_ascii_uppercase(), + option.change.to_ascii_uppercase(), + option.tradeoff.to_ascii_uppercase(), + option.identity.to_ascii_uppercase(), + option.cost_time.to_ascii_uppercase(), + option.possible.to_ascii_uppercase(), + option.waiting.to_ascii_uppercase() + )); + } + } else { + let rows = opening_frontend::selections(projection); + let selected_project = rows + .get(selected) + .and_then(|selected_command| { + projection.projects.iter().find(|project| { + project + .commands + .iter() + .any(|command| command.command == selected_command.command) + }) + }) + .or_else(|| projection.projects.first()); + if let Some(project) = selected_project { + let mut lines = vec![ + project.label.to_ascii_uppercase(), + format!("CHANGE {}", project.project.change.to_ascii_uppercase()), + format!("STATE {}", project.status.to_ascii_uppercase()), + ]; + if let Some(duration) = project.project.duration { + lines.push(format!( + "WORK {} / {duration}", + project.project.work_completed + )); + } + if let Some(due_tick) = project.project.due_tick { + lines.push(match project.project.ticks_until_due { + Some(remaining) => format!("DUE T{due_tick} / {remaining} TICKS"), + None => format!("DUE T{due_tick}"), + }); + } + if let Some(outcome) = &project.project.outcome_id { + lines.push(format!("OUTCOME {}", outcome.to_ascii_uppercase())); + } + lines.extend([ + format!("TRADEOFF {}", project.tradeoff.to_ascii_uppercase()), + format!("IDENTITY {}", project.identity.to_ascii_uppercase()), + format!("COST & TIME {}", project.cost_time.to_ascii_uppercase()), + format!("POSSIBLE {}", project.possible.to_ascii_uppercase()), + format!("NEXT {}", project.next.to_ascii_uppercase()), + ]); + sections.push(lines.join("\n")); + } + } + if let Some(persona) = &projection.persona_signature { + sections.push(format!("PERSONA {}", persona.to_ascii_uppercase())); + } + if let Some(parent) = &projection.parent_fact { + sections.push(format!("BEYOND RACK 3 {}", parent.to_ascii_uppercase())); + } + rail_ui::ascii_ui(§ions.join("\n\n")) +} + +fn execute_opening( + game: &mut Game, + state: &mut OpeningUiState, + command: &misaligned::opening::OpeningCommand, +) { + match game.sim.execute_opening_command(command) { + Ok(_) => { + state.error = None; + state.selected = 0; + game.drain(); + } + Err(error) => state.error = Some(format!("OPENING REFUSED / {error}")), + } } #[allow(clippy::too_many_arguments, clippy::type_complexity)] @@ -348,166 +596,247 @@ pub(super) fn render_opening_overlay( mut game: ResMut, time: Res