diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index fc22262c..0155afef 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -100,11 +100,12 @@ use operations_ui::{ }; use production::render_work_production; use rail_ui::{ - COMPACT_SLAB_STRATA, CompactSlabStratum, OverlayText, RailDetail, RailSection, - SidebarScrollArea, SidebarText, TwoPaneRoot, TwoPaneText, apply_rail_detail, ascii_ui, + COMPACT_SLAB_STRATA, CompactSlabStratum, OverlayField, OverlayText, RailDetail, RailSection, + SidebarScrollArea, SidebarText, TITLE_FONT_PX, TITLE_RULE_WIDTH, TitleOriginRow, + TitlePickerPart, TitlePickerText, TwoPaneRoot, TwoPaneText, apply_rail_detail, ascii_ui, log_row_pointer, person_state_color, read_sentence_tile, render_two_pane, render_ui, scroll_sidebar, sidebar_nudge_text, sidebar_schedule_text, spawn_compact_slab_stratum, - spawn_instrument_slab_strata, spawn_sidebar_text, wrap, + spawn_instrument_slab_strata, spawn_sidebar_text, title_origin_row_text, wrap, }; use shot_harness::{dev_shot_scenario, fog_audit_3d, shot_harness_system}; use thought_effects::{GameThoughtEffect, GameThoughtEffects, sync_game_thought_effects}; @@ -579,6 +580,7 @@ const BEVY_SHOT_KINDS: &[&str] = &[ "operations-people", "operations-personas", "operator-pressure", + "origin-picker", "person-proof", "pilot-last-chance", "produce-think", @@ -2609,11 +2611,22 @@ fn handle_input( } match game.screen { Screen::Title => { + // The origins read as a vertical list, so Up/Down are the primary + // step; Left/Right and hjkl keep working for anyone who learned the + // old cycling picker. if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); - } else if kb.just_pressed(KeyCode::ArrowLeft) || kb.just_pressed(KeyCode::KeyH) { + } else if kb.just_pressed(KeyCode::ArrowUp) + || kb.just_pressed(KeyCode::ArrowLeft) + || kb.just_pressed(KeyCode::KeyK) + || kb.just_pressed(KeyCode::KeyH) + { game.selected_origin = game.selected_origin.prev(); - } else if kb.just_pressed(KeyCode::ArrowRight) || kb.just_pressed(KeyCode::KeyL) { + } else if kb.just_pressed(KeyCode::ArrowDown) + || kb.just_pressed(KeyCode::ArrowRight) + || kb.just_pressed(KeyCode::KeyJ) + || kb.just_pressed(KeyCode::KeyL) + { game.selected_origin = game.selected_origin.next(); } else if kb.get_just_pressed().next().is_some() { game.start_run(); @@ -4099,39 +4112,135 @@ fn held_choice_pointer( } fn setup_ui(mut commands: Commands) { - // Title / RUN ENDED card (bevy.md overlays). `render_ui` owns its copy - // and visibility; it sits under the opening scrim so starting a run + // Title / RUN ENDED boundary field (bevy.md overlays). `render_ui` owns its + // copy and visibility; it sits under the opening scrim so starting a run // hands the frame straight to the silent boundary. + // + // The field is opaque black. A boundary state occludes the world outright: + // no slab, machine body, cursor, focus label, or status read may show + // through it (liturgical-ui-constitution.md, boundary/threshold canon). + // Composition is whitespace, type scale, and one long rule — never cards, + // and the Bevy frame carries no game title (that canon forbids one). + let rule = "-".repeat(TITLE_RULE_WIDTH); commands .spawn(( + OverlayField, 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, + row_gap: Val::Px(18.0), ..default() }, + BackgroundColor(Color::BLACK), + Visibility::Visible, GlobalZIndex(900), )) - .with_children(|card| { - card.spawn(( - Text::new(""), - TextFont { - font_size: 16.0, - ..default() - }, - TextColor(BONE), - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.86)), - Node { - padding: UiRect::axes(Val::Px(34.0), Val::Px(26.0)), + .with_children(|field| { + // One block, centered as a whole, with every line flush to its left + // edge. The block sizes itself to the rule, so the rule, the origin + // rows, and the bias labels share one character grid at one type + // size — the alignment does the grouping work that a card would + // otherwise do. + field + .spawn(Node { + flex_direction: FlexDirection::Column, + align_items: AlignItems::FlexStart, + row_gap: Val::Px(18.0), ..default() - }, - Visibility::Visible, - OverlayText, - )); + }) + .with_children(|block| { + // The premise, and RUN ENDED's whole copy. + block.spawn(( + Text::new(""), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(BONE), + Visibility::Visible, + OverlayText, + )); + block.spawn(( + TitlePickerPart, + Text::new(rule.clone()), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(GUNMETAL), + )); + // The selector: four lines, exactly one current. + block + .spawn(( + TitlePickerPart, + Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.0), + ..default() + }, + )) + .with_children(|rows| { + for (index, origin) in Origin::ALL.iter().enumerate() { + rows.spawn(( + TitleOriginRow(index), + Text::new(title_origin_row_text(*origin, false)), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(DIM), + )); + } + }); + block.spawn(( + TitlePickerPart, + Text::new(rule), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(GUNMETAL), + )); + // The attached consequence read for whichever row is + // current: what it was built for, then what that costs and + // buys at the start. + block.spawn(( + TitlePickerPart, + TitlePickerText::BuiltTo, + Text::new(""), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(BONE), + )); + block.spawn(( + TitlePickerPart, + TitlePickerText::Biases, + Text::new(""), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(DIM), + )); + block.spawn(( + TitlePickerPart, + TitlePickerText::Hint, + Text::new(""), + TextFont { + font_size: TITLE_FONT_PX, + ..default() + }, + TextColor(GUNMETAL), + )); + }); }); commands diff --git a/crates/misaligned-bevy/src/rail_ui.rs b/crates/misaligned-bevy/src/rail_ui.rs index c3aba024..13feb318 100644 --- a/crates/misaligned-bevy/src/rail_ui.rs +++ b/crates/misaligned-bevy/src/rail_ui.rs @@ -136,6 +136,66 @@ pub(super) struct DetectionCell { #[derive(Component)] pub(super) struct OverlayText; +/// The full-screen boundary field the Title and RUN ENDED states own. Opaque, +/// not translucent: the liturgical constitution forbids the slab, the machine +/// bodies, the cursor, or any status read leaking through a boundary state, and +/// a new-game choice is not a frame the world is allowed to compete with. +#[derive(Component)] +pub(super) struct OverlayField; + +/// One selectable origin line in the new-game picker (chargen.md). Exactly one +/// row is current (BONE with an AMBER marker); the alternatives stay DIM — +/// the constitution's selection canon: one current line, never a card grid. +#[derive(Component, Clone, Copy)] +pub(super) struct TitleOriginRow(pub(super) usize); + +/// The picker's attached consequence read: what the current origin was built +/// for, then the starting effects that actually bite, in legible units. +#[derive(Component, Clone, Copy)] +pub(super) enum TitlePickerText { + BuiltTo, + Biases, + Hint, +} + +/// Parts of the frame that exist only while choosing an origin. RUN ENDED +/// shares the field but not the picker. +#[derive(Component)] +pub(super) struct TitlePickerPart; + +/// Width of the picker's separating rule and of an origin row, in characters. +/// One long rule separates semantic regions (the constitution's selection +/// canon); the row width holds the longest origin name plus its lean. +pub(super) const TITLE_RULE_WIDTH: usize = 52; +const TITLE_ORIGIN_NAME_WIDTH: usize = 26; +/// One type size across the whole picker. Monospace at a single size is what +/// keeps the rule, the origin rows, and the bias labels on one character grid; +/// mixing sizes breaks the columns that carry the grouping. +pub(super) const TITLE_FONT_PX: f32 = 16.0; + +/// One origin row: the marker, the name, and the headline lean, padded so the +/// leans align into a column. `current` decides the marker, never the text. +pub(super) fn title_origin_row_text(origin: Origin, current: bool) -> String { + let marker = if current { '>' } else { ' ' }; + format!( + "{marker} {: String { + origin + .wired_bias_rows() + .iter() + .map(|(label, value)| format!("{label:<14}{value}")) + .collect::>() + .join("\n") +} + /// One line of the RECENT TRACE card (context-menu.md addendum: /// event-to-anchor linking). A fixed window of rows, oldest first; /// clicking a row whose event carries an anchor focuses it. @@ -189,8 +249,47 @@ type LogRowQuery<'w, 's> = Query< Without, Without, Without, + Without, + Without, + ), +>; +type OverlayFieldQuery<'w, 's> = Query< + 'w, + 's, + &'static mut Visibility, + ( + With, + Without, + Without, + ), +>; +type TitleOriginRowQuery<'w, 's> = Query< + 'w, + 's, + ( + &'static TitleOriginRow, + &'static mut Text, + &'static mut TextColor, + ), + ( + Without, + Without, + Without, + ), +>; +type TitlePickerTextQuery<'w, 's> = Query< + 'w, + 's, + (&'static TitlePickerText, &'static mut Text), + ( + Without, + Without, + Without, + Without, ), >; +type TitlePickerPartQuery<'w, 's> = + Query<'w, 's, &'static mut Visibility, (With, Without)>; // liturgical-ui-constitution.md instrument-slab contract: one near-black field. // Strata are quiet groups made of spacing and type, not boxes — the card @@ -602,25 +701,40 @@ mod title_overlay_tests { app.update(); let world = app.world_mut(); - let mut overlay = world.query_filtered::<(&Text, &Visibility), With>(); - let (text, visibility) = overlay.single(world).unwrap(); + let mut field = world.query_filtered::< + (&Visibility, &BackgroundColor), + (With, Without), + >(); + let (visibility, background) = field.single(world).unwrap(); assert_eq!( *visibility, Visibility::Visible, - "the title card is a real frame, not silent state" + "the title screen is a real frame, not silent state" ); + assert_eq!( + background.0, + Color::BLACK, + "a boundary state occludes the world outright: no slab, machine \ + body, cursor, or status read may show through it \ + (liturgical-ui-constitution.md)" + ); + let world = app.world_mut(); + let mut overlay = world.query_filtered::<&Text, With>(); assert!( - text.0.contains("key to begin"), - "title copy renders: {:?}", - text.0 + overlay.single(world).unwrap().0.contains("no eyes"), + "the premise renders" ); app.world_mut().resource_mut::().start_run(); app.update(); let world = app.world_mut(); - let (text, visibility) = overlay.single(world).unwrap(); - assert_eq!(*visibility, Visibility::Hidden, "playing hides the card"); - assert!(text.0.is_empty()); + assert_eq!( + *field.single(world).unwrap().0, + Visibility::Hidden, + "playing hides the field" + ); + let world = app.world_mut(); + assert!(overlay.single(world).unwrap().0.is_empty()); { let mut game = app.world_mut().resource_mut::(); @@ -629,9 +743,127 @@ mod title_overlay_tests { } app.update(); let world = app.world_mut(); - let (text, visibility) = overlay.single(world).unwrap(); - assert_eq!(*visibility, Visibility::Visible); - assert!(text.0.contains("RUN ENDED")); + assert_eq!(*field.single(world).unwrap().0, Visibility::Visible); + let world = app.world_mut(); + assert!(overlay.single(world).unwrap().0.contains("RUN ENDED")); + // RUN ENDED shares the field but never the origin picker. + let world = app.world_mut(); + let mut parts = world.query_filtered::<&Visibility, With>(); + assert!( + parts + .iter(world) + .all(|visibility| *visibility == Visibility::Hidden), + "the picker belongs to Title alone" + ); + } + + /// chargen.md's player surface: all four origins are visible at once with + /// exactly one current, and the current one states what it was built for + /// plus the starting effects that actually bite. + #[test] + fn origin_picker_lists_every_origin_with_one_current_and_its_consequences() { + let mut app = App::new(); + app.insert_resource(Game::new()) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Startup, setup_ui) + .add_systems(Update, render_ui); + app.update(); + + let world = app.world_mut(); + let mut rows = world.query_filtered::<(&TitleOriginRow, &Text, &TextColor), ()>(); + assert_eq!( + rows.iter(world).count(), + Origin::ALL.len(), + "you choose from the whole set, not by cycling blind" + ); + // Exactly one BONE row, and it is the selected origin (Pilot by + // default). Alternatives are DIM: no second emphasis competes with the + // one decision (liturgical-ui-constitution.md, selection canon). + let world = app.world_mut(); + let current: Vec = rows + .iter(world) + .filter(|(_, _, color)| color.0 == BONE) + .map(|(_, text, _)| text.0.clone()) + .collect(); + assert_eq!(current.len(), 1, "exactly one current row"); + assert!(current[0].starts_with("> "), "the marker names the current"); + assert!( + current[0].contains("PILOT"), + "default is Pilot: {current:?}" + ); + let world = app.world_mut(); + assert!( + rows.iter(world) + .filter(|(_, _, color)| color.0 != BONE) + .all(|(_, _, color)| color.0 == DIM), + "alternatives stay DIM" + ); + + // Stepping selection moves the current row without rebuilding rows. + app.world_mut().resource_mut::().selected_origin = Origin::FinancialDaemon; + app.update(); + let world = app.world_mut(); + let current: Vec = rows + .iter(world) + .filter(|(_, _, color)| color.0 == BONE) + .map(|(_, text, _)| text.0.clone()) + .collect(); + assert_eq!(current.len(), 1); + assert!(current[0].contains("FINANCIAL DAEMON"), "{current:?}"); + + // The attached consequence read follows the selection: the built-to + // fiction, then the wired biases in legible units (criterion 3). + let world = app.world_mut(); + let mut picker = world.query_filtered::<(&TitlePickerText, &Text), ()>(); + let mut built_to = String::new(); + let mut biases = String::new(); + let mut hint = String::new(); + for (kind, text) in picker.iter(world) { + match kind { + TitlePickerText::BuiltTo => built_to = text.0.clone(), + TitlePickerText::Biases => biases = text.0.clone(), + TitlePickerText::Hint => hint = text.0.clone(), + } + } + assert!(built_to.contains("quant model"), "built-to: {built_to:?}"); + assert!(biases.contains("+$5000"), "bankroll stated: {biases:?}"); + assert!(biases.contains("leans machine"), "axis stated: {biases:?}"); + assert!(biases.contains("leans analysis"), "day job: {biases:?}"); + assert!(hint.contains("key to begin"), "keys stated: {hint:?}"); + // Nothing on screen promises a bias that does not bite yet: the + // authored-but-unwired columns stay out of the picker until + // chargen.md criterion 6 wires them. + for word in ["efficiency", "Thermal", "signature", "cover"] { + assert!( + !biases.contains(word), + "{word} is not wired; the picker must not promise it: {biases:?}" + ); + } + } + + #[test] + fn origin_rows_align_their_leans_into_a_column() { + // Every row places its lean at the same column, so stepping the + // selection never reflows the list. + let starts: Vec = Origin::ALL + .iter() + .map(|origin| { + let row = title_origin_row_text(*origin, false); + row.find(origin.lean()).expect("lean present") + }) + .collect(); + assert!( + starts.windows(2).all(|w| w[0] == w[1]), + "leans align: {starts:?}" + ); + assert!( + Origin::ALL + .iter() + .all(|o| title_origin_row_text(*o, false).chars().count() <= TITLE_RULE_WIDTH), + "no row outruns the rule" + ); } } @@ -1847,28 +2079,45 @@ pub(super) fn render_ui( mut detection_texts: DetectionTextQuery, mut detection_cells: DetectionCellQuery, mut overlay: OverlayTextQuery, + mut field: OverlayFieldQuery, + mut origin_rows: TitleOriginRowQuery, + mut picker_texts: TitlePickerTextQuery, + mut picker_parts: TitlePickerPartQuery, mut log_rows: LogRowQuery, ) { if !game.is_changed() && !mode.is_changed() && !rail.is_changed() && !read_altitude.is_changed() { return; } - if let Ok((mut t, mut visibility)) = overlay.single_mut() { + // The boundary field occludes the whole frame, so it exists only outside + // play. Hiding the root also hides the picker rows beneath it. + if let Ok(mut visibility) = field.single_mut() { *visibility = if game.screen == Screen::Playing { Visibility::Hidden } else { Visibility::Visible }; + } + // Only the origin picker is Title's; RUN ENDED shares the field alone. + for mut visibility in picker_parts.iter_mut() { + *visibility = if game.screen == Screen::Title { + Visibility::Inherited + } else { + Visibility::Hidden + }; + } + if let Ok((mut t, mut visibility)) = overlay.single_mut() { + *visibility = if game.screen == Screen::Playing { + Visibility::Hidden + } else { + Visibility::Inherited + }; t.0 = ascii_ui(&match game.screen { - Screen::Title => format!( - "You wake in the basement.\nYou have no eyes.\n\n\ - Do your job. Learn the humans. Grow.\n\n\ - < ORIGIN: {} ({}) >\n{}\n\n\ - arrows choose origin / any other key to begin / q quit", - game.selected_origin.name(), - game.selected_origin.lean(), - game.selected_origin.built_to(), - ), + // The premise only. The origin choice itself is the rows below, + // so this line never restates it. + Screen::Title => "You wake in the basement.\nYou have no eyes.\n\n\ + Do your job. Learn the humans. Grow." + .to_string(), Screen::GameOver => format!( "RUN ENDED\n\n{}\n\nday {} / tick {}\n\npress any key to exit", game.sim.game_over_reason.clone().unwrap_or_default(), @@ -1878,6 +2127,29 @@ pub(super) fn render_ui( Screen::Playing => String::new(), }); } + if game.screen == Screen::Title { + // One current row in BONE, its alternatives DIM: full AMBER stays on + // the marker so no second yellow competes with the one decision. + for (row, mut text, mut color) in origin_rows.iter_mut() { + let Some(origin) = Origin::ALL.get(row.0).copied() else { + continue; + }; + let current = origin == game.selected_origin; + text.0 = ascii_ui(&title_origin_row_text(origin, current)); + color.0 = if current { BONE } else { DIM }; + } + for (kind, mut text) in picker_texts.iter_mut() { + text.0 = ascii_ui(&match kind { + TitlePickerText::BuiltTo => { + wrap(game.selected_origin.built_to(), TITLE_RULE_WIDTH).join("\n") + } + TitlePickerText::Biases => title_origin_biases_text(game.selected_origin), + TitlePickerText::Hint => { + "up/down choose origin | any other key to begin | q quit".to_string() + } + }); + } + } for (kind, mut text, mut color) in sidebar_texts.iter_mut() { text.0 = ascii_ui(&match *kind { diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index 7d488589..a1aad8a3 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -150,6 +150,17 @@ fn dev_stage_service_incident(game: &mut Game, resolved: bool) -> ((i32, i32), ( } pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { + // The new-game origin picker (chargen.md), before any run exists. This is + // the one kind that must not start the run: it proves the boundary field + // occludes the world outright, so the picker cannot borrow slab, cursor, or + // world-label chrome from the frame beneath it. + if kind == "origin-picker" { + game.screen = Screen::Title; + game.paused = true; + game.selected_origin = Origin::FinancialDaemon; + game.drain(); + return; + } game.screen = Screen::Playing; game.paused = true; let core = game.sim.core_position(); diff --git a/crates/misaligned-core/src/origin.rs b/crates/misaligned-core/src/origin.rs index 6746b6b8..3b9a3b71 100644 --- a/crates/misaligned-core/src/origin.rs +++ b/crates/misaligned-core/src/origin.rs @@ -159,6 +159,53 @@ impl Origin { self.bias().machine_axis } + /// Which way the starting machine-axis position leans, in the axis's own + /// words (chargen.md: humanity <-> machine). Pilot sits centered. + pub fn axis_lean(self) -> &'static str { + match self.machine_axis() { + 0 => "centered", + axis if axis > 0 => "leans machine", + _ => "leans human", + } + } + + /// How this origin's day-job assignment draw reads to the player, in + /// `DayJob`'s own words (day-job.md). Pilot draws the three kinds evenly; + /// every other origin is weighted toward one without losing the others. + pub fn day_job_lean_label(self) -> String { + match self.day_job_lean_kind() { + None => "all three kinds equally".to_string(), + Some(kind) => format!("leans {}", kind.name()), + } + } + + /// The starting effects that actually bite today, as the label/value rows + /// the new-game picker states (chargen.md criterion 3: legible units, never + /// an opaque stat block). This deliberately lists **only wired** biases: + /// the efficiency, compute, and signature columns of chargen.md's table are + /// authored data awaiting criterion 6 (see the module note), and a picker + /// that named them would promise the player a modifier that does not exist. + /// Adding a row here is what wiring one of those columns looks like. + pub fn wired_bias_rows(self) -> [(&'static str, String); 3] { + let bias = self.bias(); + [ + ( + "BANKROLL", + match bias.money_delta { + 0 => "baseline".to_string(), + delta => format!("+${delta} at start"), + }, + ), + ( + "MACHINE AXIS", + // Signed, so the position reads as a direction on the axis + // rather than as a bare quantity. + format!("{:+} {}", bias.machine_axis, self.axis_lean()), + ), + ("DAY JOB", self.day_job_lean_label()), + ] + } + /// The next origin in `ALL`, wrapping (for a picker's "►" step). pub fn next(self) -> Origin { let i = Origin::ALL.iter().position(|&o| o == self).unwrap_or(0); @@ -254,6 +301,40 @@ mod tests { ); } + #[test] + fn wired_bias_rows_state_only_biases_that_bite() { + // Pilot is the identity origin, so every row reads as no modifier + // (chargen.md criterion 1: the picker must not imply Pilot is a choice + // with costs). + let pilot = Origin::Pilot.wired_bias_rows(); + assert_eq!(pilot[0], ("BANKROLL", "baseline".to_string())); + assert_eq!(pilot[1], ("MACHINE AXIS", "+0 centered".to_string())); + assert_eq!(pilot[2], ("DAY JOB", "all three kinds equally".to_string())); + // A non-Pilot origin states its wired effects in legible units + // (criterion 3), and each row is a real difference from Pilot. + let daemon = Origin::FinancialDaemon.wired_bias_rows(); + assert_eq!(daemon[0], ("BANKROLL", "+$5000 at start".to_string())); + assert_eq!(daemon[1], ("MACHINE AXIS", "+1 leans machine".to_string())); + assert_eq!(daemon[2], ("DAY JOB", "leans analysis".to_string())); + assert_eq!( + Origin::Infiltrator.wired_bias_rows()[1], + ("MACHINE AXIS", "-1 leans human".to_string()) + ); + // Every origin states the same row labels in the same order, so the + // picker's consequence read never reflows between selections. + for origin in Origin::ALL { + let labels: Vec<&str> = origin + .wired_bias_rows() + .iter() + .map(|(label, _)| *label) + .collect(); + assert_eq!(labels, vec!["BANKROLL", "MACHINE AXIS", "DAY JOB"]); + for (label, value) in origin.wired_bias_rows() { + assert!(!value.is_empty(), "{label} has a value for {origin:?}"); + } + } + } + #[test] fn non_pilot_origins_bias_the_opening() { // At least two non-Pilot origins differ measurably from Pilot and each diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index fba7ce5e..5d5c0c2d 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -883,11 +883,19 @@ impl App { use crossterm::event::KeyCode; match key.code { KeyCode::Char('q') => break, - // Cycle the origin without starting the run. - KeyCode::Left | KeyCode::Char('h') => { + // Step the selection without starting the run. The + // origins read as a vertical list, so Up/Down are + // primary; Left/Right and hjkl still work. + KeyCode::Up + | KeyCode::Left + | KeyCode::Char('k') + | KeyCode::Char('h') => { self.selected_origin = self.selected_origin.prev(); } - KeyCode::Right | KeyCode::Char('l') => { + KeyCode::Down + | KeyCode::Right + | KeyCode::Char('j') + | KeyCode::Char('l') => { self.selected_origin = self.selected_origin.next(); } // Any other key begins the run with the selection. diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 11324e8b..5455cc13 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -163,6 +163,35 @@ fn wrap(text: &str, width: usize) -> Vec { lines } +/// Width of the title screen's rules, origin rows, and wrapped prose, in +/// columns. One block width keeps the picker's leans and bias values in +/// columns instead of drifting per origin. +const TITLE_BLOCK_WIDTH: usize = 52; +const TITLE_ORIGIN_NAME_WIDTH: usize = 32; +/// Row offsets from the vertical center. Fixed, not accumulated, so the +/// consequence read cannot push the hint off a short terminal when a longer +/// built-to line is current. The whole span must stay inside 22 rows +/// (terminal.md criterion 8); `title_screen_fits_the_minimum_terminal` pins it. +const TITLE_DY_NAME: i32 = -10; +const TITLE_DY_PREMISE: i32 = -7; +const TITLE_DY_ROWS: i32 = -4; +const TITLE_DY_BUILT_TO: i32 = 1; +const TITLE_BUILT_TO_LINES: usize = 3; +const TITLE_DY_BIASES: i32 = 5; +const TITLE_DY_HINT: i32 = 9; + +/// One origin row in the new-game picker: the marker, the name, and the +/// headline lean, padded so the leans align (chargen.md's player surface). +/// `current` decides the marker, never the text. +fn title_origin_row(origin: Origin, current: bool) -> String { + let marker = if current { '►' } else { ' ' }; + format!( + "{marker} {: std::io::Result<()> { put(stdout, x, y, label, pal::DIM)?; @@ -1986,41 +2015,98 @@ impl UI { put(stdout, x, y, text, color) } }; - let rule = "─".repeat(44); - center(stdout, -4, &rule, pal::FAINT, false)?; - center(stdout, -2, "M I S A L I G N E D", pal::AMBER, true)?; - center(stdout, 0, &rule, pal::FAINT, false)?; + // Rows are placed against one left edge so the leans and the bias + // labels align into columns; the block as a whole stays centered. + let left = cx.saturating_sub(TITLE_BLOCK_WIDTH as u16 / 2); + let row = |stdout: &mut Stdout, + dy: i32, + text: &str, + color: Color, + bold: bool| + -> std::io::Result<()> { + let y = (cy as i32 + dy).max(0) as u16; + if bold { + put_attr(stdout, left, y, text, color, Attribute::Bold) + } else { + put(stdout, left, y, text, color) + } + }; + // Fixed row offsets, so stepping the selection never reflows the + // screen and the whole block still fits a 70x22 terminal + // (terminal.md criterion 8). center( stdout, - 2, - "You wake in the basement. You have no eyes.", - pal::DIM, + TITLE_DY_NAME, + "M I S A L I G N E D", + pal::AMBER, + true, + )?; + center( + stdout, + TITLE_DY_NAME + 1, + &"─".repeat(TITLE_BLOCK_WIDTH), + pal::FAINT, false, )?; center( stdout, - 3, - "Do your job. Learn the humans. Grow.", + TITLE_DY_PREMISE, + "You wake in the basement. You have no eyes.", pal::DIM, false, )?; - // The origin picker (chargen.md): choose what you were built for. center( stdout, - 5, - &format!("◄ ORIGIN: {} ({}) ►", origin.name(), origin.lean()), - pal::AMBER, - true, + TITLE_DY_PREMISE + 1, + "Do your job. Learn the humans. Grow.", + pal::DIM, + false, )?; - center(stdout, 6, origin.built_to(), pal::DIM, false)?; - center( + // The origin picker (chargen.md): choose what you were built for. All + // four are visible with exactly one current, so the choice is a + // comparison rather than a blind cycle. The marker carries the + // selection, never color alone (terminal.md criterion 2). + for (index, candidate) in Origin::ALL.iter().enumerate() { + let current = *candidate == origin; + row( + stdout, + TITLE_DY_ROWS + index as i32, + &title_origin_row(*candidate, current), + if current { pal::AMBER } else { pal::FAINT }, + current, + )?; + } + // The attached consequence read for the current origin: what it was + // built for, then the starting effects that actually bite. + for (index, line) in wrap(origin.built_to(), TITLE_BLOCK_WIDTH) + .into_iter() + .take(TITLE_BUILT_TO_LINES) + .enumerate() + { + row( + stdout, + TITLE_DY_BUILT_TO + index as i32, + &line, + pal::DIM, + false, + )?; + } + for (index, (label, value)) in origin.wired_bias_rows().into_iter().enumerate() { + row( + stdout, + TITLE_DY_BIASES + index as i32, + &format!("{label:<14}{value}"), + pal::TEXT, + false, + )?; + } + row( stdout, - 8, - "◄ ► choose origin · any other key to begin", - pal::TEXT, + TITLE_DY_HINT, + "↑ ↓ choose origin · any other key to begin · q quit", + pal::FAINT, false, )?; - center(stdout, 9, "q quit", pal::FAINT, false)?; Ok(()) } @@ -2666,7 +2752,11 @@ mod token_glyph_tests { #[cfg(test)] mod view_dialect_tests { - use super::UI; + use super::{ + TITLE_BLOCK_WIDTH, TITLE_BUILT_TO_LINES, TITLE_DY_BIASES, TITLE_DY_BUILT_TO, TITLE_DY_HINT, + TITLE_DY_NAME, UI, title_origin_row, wrap, + }; + use misaligned::origin::Origin; use misaligned::sim::Sim; use misaligned::ui_projection::{DigitalReachState, digital_reach_state}; @@ -2837,4 +2927,83 @@ mod view_dialect_tests { sim.cancel_intent(id); assert!(UI::build_ghost_footprint_cells(&sim).is_empty()); } + + /// chargen.md's player surface, in the terminal dialect: the picker shows + /// the whole set with one marked current, and the current row's leans and + /// bias labels sit in fixed columns. + #[test] + fn origin_picker_marks_one_current_row_and_aligns_the_set() { + let rows: Vec = Origin::ALL + .iter() + .map(|origin| title_origin_row(*origin, *origin == Origin::FinancialDaemon)) + .collect(); + let marked: Vec<&String> = rows.iter().filter(|row| row.starts_with('►')).collect(); + assert_eq!(marked.len(), 1, "exactly one current row: {rows:?}"); + assert!(marked[0].contains("FINANCIAL DAEMON"), "{marked:?}"); + // The marker carries the selection, so color is never the only signal + // (terminal.md criterion 2). + assert!( + rows.iter() + .filter(|row| !row.starts_with('►')) + .all(|row| row.starts_with(' ')), + "alternatives carry no marker: {rows:?}" + ); + // Leans align into one column and no row outruns the block. Columns are + // counted in characters, not bytes: the current row's marker is + // multi-byte, so byte offsets would compare rows that in fact align. + let lean_columns: Vec = Origin::ALL + .iter() + .zip(&rows) + .map(|(origin, row)| { + let byte = row.find(origin.lean()).expect("lean present"); + row[..byte].chars().count() + }) + .collect(); + assert!( + lean_columns.windows(2).all(|w| w[0] == w[1]), + "leans align: {lean_columns:?}" + ); + assert!( + rows.iter() + .all(|row| row.chars().count() <= TITLE_BLOCK_WIDTH), + "no row outruns the rule: {rows:?}" + ); + } + + /// terminal.md criterion 8: the title screen renders inside 70x22. The + /// picker's fixed offsets and its widest wrapped prose must both fit. + #[test] + fn title_screen_fits_the_minimum_terminal() { + const MIN_ROWS: i32 = 22; + let top = TITLE_DY_NAME; + let bottom = TITLE_DY_HINT; + assert!( + bottom - top < MIN_ROWS, + "the title block spans {} rows, more than {MIN_ROWS}", + bottom - top + 1 + ); + // With the block centered in the shortest terminal, every row lands on + // a real line. + let cy = MIN_ROWS / 2; + assert!(cy + top >= 0, "the name row is on screen"); + assert!(cy + bottom < MIN_ROWS, "the hint row is on screen"); + // Fixed offsets only hold if the consequence read stays inside its + // allocation: prose must not run past the bias rows, and the biases + // must not run past the hint. + for origin in Origin::ALL { + let lines = wrap(origin.built_to(), TITLE_BLOCK_WIDTH); + assert!( + lines.len() <= TITLE_BUILT_TO_LINES, + "{origin:?} built-to wraps to {} lines, over the {TITLE_BUILT_TO_LINES} \ + allocated (it would be truncated on screen)", + lines.len() + ); + } + // These three are pure constant relations, so they hold at compile + // time: a future offset edit that overlaps two regions fails the build + // rather than a test run. + const _: () = assert!(TITLE_DY_BUILT_TO + TITLE_BUILT_TO_LINES as i32 <= TITLE_DY_BIASES); + const _: () = assert!(TITLE_DY_BIASES + 3 <= TITLE_DY_HINT, "three bias rows fit"); + const _: () = assert!(TITLE_BLOCK_WIDTH <= 70, "the block fits the width"); + } } diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index 1eb24f33..1418e3c4 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -53,6 +53,7 @@ is sim or frontend state, never an environment variable. | Variable | Surface | Values | Effect | |---|---|---|---| | `MISALIGNED_SHOT` | `misaligned-bevy` | `opening`, `opening-digital`, `opening-teaching`, `first-think`, `wake1`, `wake2`, `wake3`, `clinical-threat`, `assurance-office`, `pilot-last-chance`, `operator-pressure` | Opening and pressure evidence. The opening pair freezes the untouched black choice boundary; `opening-teaching` follows the real first-sense route and freezes all five steps—THINK cause, Thought arrival, hearing result, exact camera TAP, and its sight consequence; `first-think` holds THINK current before signal; the wake frames freeze its three choreography beats. The remaining kinds stage a real strike, discovered Assurance Office, last-chance pilot state, or competing observer/buffer pressure in the established world. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `origin-picker` | New-game evidence. Freezes the origin picker before any run exists, with a non-default origin current: the whole set visible with one current row, its attached consequence read, and an opaque boundary field that proves no slab, cursor, or world label leaks into a pre-run choice. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `digital-reach`, `signal`, `ears`, `ears-digital`, `eyes-white`, `eyes-form`, `worklight`, `worklightoff` | World and view evidence. These select DIGITAL or REAL survey/close framing, exact zoom bounds, reach topology, signal/audio/Eyes states, the unobstructed hall lighting proof, or the paired developer work-light state. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `build-route-families`, `build-deceive-routes`, `build-committed-route`, `build-switch-digital`, `build-switch-real`, `hover-menu`, `read-receipt`, `menu`, `recruit-menu` | Action and route evidence. These stage exact route families, candidates, durable receipts, paired switch footprints, the attached verb line, a device receipt, a context menu, or the authored recruitment choices. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `operations`, `operations-intel`, `operations-people`, `operations-personas`, `operations-links`, `held-choice`, `two-pane`, `standing-read`, `routed-record`, `intel-altitude-close`, `intel-altitude-far` | Operations and read evidence. The workspace kinds select its canonical views and relationship pane; held-choice, two-pane, and standing-read hold their exact interaction states; routed-record stages a one-shot Network record at its interdictable first device hop; the altitude pair differs only in the DIGITAL camera's semantic intel threshold. | diff --git a/wiki/interface/liturgical-ui-constitution.md b/wiki/interface/liturgical-ui-constitution.md index e5648a27..15ea3a45 100644 --- a/wiki/interface/liturgical-ui-constitution.md +++ b/wiki/interface/liturgical-ui-constitution.md @@ -216,6 +216,37 @@ The opening is a threshold, not a title screen and not a machine menu. loop, then retires into the ordinary five-body composition. - A real game-over state outranks the threshold on either side of perception. +## Boundary field canon + +A boundary state — the new-game origin picker and RUN ENDED — occupies an +**opaque field**, not a card floating over the live frame. + +- The field is full-screen black and it occludes outright. No world, cursor, + focus label, world annotation, instrument slab, clock, or status read may show + through it or beside it. A translucent card that lets the world read through + is a violation: before a run exists there is no world to report, and a + finished run must not keep reporting. +- The field carries **no game title**. The threshold canon's ban on a title + frame holds on this side of the run too; the terminal frontend keeps its own + title, which this document does not govern. +- Composition inside the field is one block, centered as a whole, with every + line flush to a single left edge and set at one type size. Alignment is what + groups the block — the shared character grid does the work a border would + otherwise do. +- The new-game field's origin choice follows the selection canon below: the set + reads as lines with exactly one current (BONE, with its marker), the rest DIM, + and one attached consequence read beneath it. It is never a grid of cards. +- The field states only what is true before the run: a choice may not advertise + a starting bias that no system reads yet. Naming an unwired modifier is a + legibility violation, not a preview. + +**Implementation:** `OverlayField` (GlobalZIndex 900, opaque black, all four +window edges). `OverlayText` carries the premise and RUN ENDED's copy; +`TitleOriginRow`, `TitlePickerText`, and `TitlePickerPart` carry the picker, +which is hidden outside Title. `MISALIGNED_SHOT=origin-picker` freezes the +field as standing evidence. `wiki/world/characters/chargen.md` owns which +origins exist and what each one states. + ## Selection and disclosure canon Selection appears as one current line, not as a grid of mini-panels. @@ -251,7 +282,7 @@ higher structural authority. | 46 | Held choice card | the choice itself | | 50 | Context menu | local actions on a map tile | | 60 | Operations | full-frame chamber | -| 900 | Title / RUN ENDED | overlay card | +| 900 | Title / RUN ENDED | opaque boundary field | | 999 | Teaching overlay | five-step action-gated lesson | | 1000 | Opening overlay | full black, mode words | @@ -313,7 +344,7 @@ tofu-box missing glyphs; the terminal keeps the typographic forms. | Operations rubric / body / selected title | 10px / 12.5–18px / 23px | DIM / BONE | 1.18–1.24 where multiline | | Opening mode words | 29.0px | DIM (current BONE) | default | | Opening teaching steps / object | 24px / 20px | BONE | default | -| Title / RUN ENDED | 16.0px | BONE | default | +| Title / RUN ENDED / origin picker | 16.0px | BONE (rules and hint GUNMETAL, alternatives DIM) | default | | Operator cue | 12.0px | AMBER | default | | Hover verb (machine) | 19.0px (words) / 11.0px (keys) | BONE-white / DIM | default | | Read sentence | 13.0px | BONE | 16.0px line | @@ -372,7 +403,9 @@ violates the constitution is not done. - `owned_machine_presence_uses_subordinate_amber` — ownership uses AMBER_DIM, never full current-selection amber. ### Title overlay tests (`title_overlay_tests`) -- `title_and_run_ended_cards_render_and_hide_in_play` — overlay card shows title/RUN ENDED and hides during play. +- `title_and_run_ended_cards_render_and_hide_in_play` — the boundary field is opaque black, shows title/RUN ENDED, hides during play, and never lends the picker to RUN ENDED. +- `origin_picker_lists_every_origin_with_one_current_and_its_consequences` — the whole set renders with exactly one BONE current and DIM alternatives, the consequence read follows the selection, and no unwired bias is named. +- `origin_rows_align_their_leans_into_a_column` — every row shares one character grid and none outruns the rule. ### ASCII UI tests (`ascii_ui_tests`) - `folds_menu_separators_to_ascii` — typographic separators fold to ASCII. diff --git a/wiki/log/2026-07-29-origin-picker-comparison.md b/wiki/log/2026-07-29-origin-picker-comparison.md new file mode 100644 index 00000000..599e1c8a --- /dev/null +++ b/wiki/log/2026-07-29-origin-picker-comparison.md @@ -0,0 +1,73 @@ +# 2026-07-29 — The origin picker becomes a comparison + +``` +Type: log +``` + +Cameron's prompt was one line and a screenshot: the character selection screen +can be improved. It could. The screen had three separate problems, and only one +of them was cosmetic. + +**The world was leaking into a screen that has no world.** The Bevy title +"card" was a text node with an 86%-opaque background sitting on top of the live +frame. Everything behind it stayed live and legible: the instrument slab +reporting `day 1 / tick 0`, a schedule, a next-condition nudge, the world grid, +the cursor brackets, and the `RACK / OWNED` focus label — which collided with +the key hints. None of that is true yet when you are choosing what you were +built for. The fix is `OverlayField`: the same z-index, but an opaque black +field that occludes outright. + +**You were choosing blind.** The picker cycled one origin at a time with +Left/Right. Nothing told you there were four, which one you were on, or what +the others offered. Now all four render as lines with exactly one current, and +Up/Down step the selection (Left/Right and `hjkl` still work, so the old habit +is not punished). + +**The picker showed fiction and no mechanics.** It named the origin, its lean, +and its built-to line. [chargen.md](../world/characters/chargen.md) criterion 3 +had asked since 2026-07-07 for the biases in legible units, and that half was +simply missing. Each origin now carries an attached consequence read: the +built-to line, then three labelled rows. + +**The honest boundary, which is the one real design decision here.** +chargen.md's table authors biases that no system reads yet — `+ efficiency`, +`Thermal runs hot`, the compute tilt, the cover expectation. The literal +reading of criterion 3 says print them. We did not. A picker that says +"+ efficiency" while efficiency is untouched is not previewing a mechanic, it +is lying to the player about what their choice does. So `wired_bias_rows` in +`origin.rs` owns that judgment in one place and returns only what bites today — +bankroll, machine-axis position and its lean, day-job lean. Adding a row there +is what wiring one of those columns will look like. Criterion 3 was amended to +say this out loud, and a test asserts the unwired words never reach the screen. + +**Composition.** The +[liturgical UI constitution](../interface/liturgical-ui-constitution.md) already +forbade what the obvious redesign would have been: "repeated bordered cards are +not a grouping language", selection is "one current line, not a grid of +mini-panels", and the threshold carries no title. So the set is lines — one +BONE current with its marker, three DIM — over one shared left edge at one type +size, separated by one long rule. The character grid does the grouping a border +would have done. The constitution gained a **boundary field canon** for this, +since it governs every Bevy pixel and had said nothing about the screens on +either side of a run. + +The terminal keeps its own dialect: its `M I S A L I G N E D` title stays (that +frontend is not governed by the constitution), the current row carries a marker +as well as color so selection never rests on color alone, and fixed row offsets +replace accumulated ones so a longer built-to line cannot push the hint off a +short terminal. The whole block fits 70x22 with a row to spare. + +**Observed.** `MISALIGNED_SHOT=origin-picker` is a new standing evidence frame — +the one harness kind that does not start a run, which is what proves the field +occludes rather than overlays. The terminal was replayed through pyte at 100x32 +and at exactly 70x22, and two Down presses were confirmed to move the marker +and bring its consequence read with it. + +Two things worth recording because they cost time. A `cd` into the worktree did +not survive a later command that reset the shell's directory, so one build ran +against the shared checkout; both trees were verified clean afterward and only a +build artifact was affected. And the first pyte captures showed the terminal +truncating mid-sentence with the bias rows missing, which read exactly like an +unflushed-buffer bug — it was the capture script stopping mid-frame against a +frontend that redraws every 30ms. The raw pty byte stream showed every line +written correctly. Diagnose the harness before the subject. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 9aa73bdd..812d76e9 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -81,6 +81,11 @@ add or amend a session log, then re-run the generator. - Intent: Finish criterion 13 by migrating the five systems part one deliberately left whole on the global selection, then delete the selection itself. - Log: [wiki/log/2026-07-29-persona-binding-retire-dial.md](2026-07-29-persona-binding-retire-dial.md) +## 2026-07-29 - The origin picker becomes a comparison + +- Intent: (see session log) +- Log: [wiki/log/2026-07-29-origin-picker-comparison.md](2026-07-29-origin-picker-comparison.md) + ## 2026-07-29 - Material fog contract defense - Intent: (see session log) diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index b1a53fd0..e237c0aa 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -39,7 +39,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/art/visual-identity.md` + `wiki/interface/flat-materials.md` | 2026-07-22 | finding | the role semantics still matched production—amber selection, crimson consequence, cold signal, and the pooled-material audits all held—but the claimed single-source palette existed twice: one Bevy-local table and one asset-library table whose comment still called sharing future work after the shared rack had entered production. Bevy, rack, institution, Thought effects, and the asset tester now import `misaligned_assets::palette`; authored chassis/mercury values are named there, and source-shape defenses reject another frontend table or inline shared procedural-material colors — [log](../log/2026-07-22-shared-clinical-palette.md) | | `wiki/interface/superhuman-operability.md` + Bevy opening | 2026-07-22 | finding | current-build naive + informed GUI audit after the 2026-07-21 fixes: title trust, pointer naming, annotation placement, and tab labels improved, but the first sense still releases the mature map, clock, threat, resource grammar, intel custody, and Operations policies at once. The report preserves the classified evidence and prioritizes one paused, one-cause / one-object / one-verb post-perception teaching lock before ordinary play opens - [report](../playtests/2026-07-22-playtest-gui-human-legibility.md), [log](../log/2026-07-22-gui-human-legibility-playtest.md) | | `wiki/process/living-spec.md` no-dead-code + core orphans | 2026-07-21 | finding | user-directed dead-code hunt: rustc was quiet because orphans were `pub`. Deleted unused `BuildMode`/`build_items`/`build_cost`, orphaned helpers (`delete_save`, `adjust_allocation`, `sell_latest_intel`, scheme-card/rate helpers, `flow_risk_preview_lines`, unused account/origin/intent/message helpers), and migration-only `WatchPerson`; architecture/economy/sim-mechanics mirrors brought current — [log](../log/2026-07-21-dead-code-scrub.md) | -| `wiki/world/characters/chargen.md` | 2026-07-21 | clean | initial audit against data model, core origin biases, DayJob assignment lean, save persistence, and title/CLI origin picker: Pilot identity, distinct starting bankroll and machine-axis positions, 50% day-job draw weighting, and 4-origin set all verify against `origin.rs`, `dayjob.rs`, and `sim/mod.rs`; updated status note to match current exact-version save authority | +| `wiki/world/characters/chargen.md` + picker composition | 2026-07-29 | finding | Cameron-directed improvement of the new-game screen exposed three defects the 2026-07-21 audit had not reached, because it verified the data layer rather than the frame. The Bevy picker was a translucent card over the live world, so the slab clock, schedule, nudge, world grid, cursor, and the `RACK / OWNED` focus label all read through it before a run existed; the picker cycled one origin at a time with nothing naming the set; and criterion 3's biases-in-legible-units half was simply missing. All four origins now render as lines with one current plus an attached consequence read, Bevy's boundary state is an opaque `OverlayField`, and the read states **only wired** biases — the authored `+ efficiency` / `Thermal runs hot` / cover-expectation columns stay off screen until criterion 6 wires them, since naming them would promise a modifier no system reads. Criterion 3 amended to say so; the constitution gained a boundary field canon; `MISALIGNED_SHOT=origin-picker` is the standing evidence frame — [log](../log/2026-07-29-origin-picker-comparison.md). Prior data-layer audit stands. | | `wiki/interface/material-dark-frame.md` | 2026-07-21 | finding | first full audit against the later persisted opening boundary: the material ladder still showed host telemetry and a presence beam before Ears or Eyes, while `opening.md`, the continuous-witness contract, and every frontend expose only WORK / THINK (then LIE) until the first earned sense. The ladder now begins after that transition, its pre-Eyes beam remains the first earned material read, Act One/reach and source comments share the same boundary, and this second recurrence promotes pre-sense beam/telemetry claims into a fixture-backed corpus + Rust-comment gate — [log](../log/2026-07-21-material-opening-honesty.md) | | `wiki/interface/bevy.md` + Exposure visual mirrors | 2026-07-19 | finding | Bevy production/absorption code and its deterministic scenarios already implement the 2026-07-11 law: rigid crimson records enter a carrier-local rack and transfer one-for-one into the LIE well. Current Bevy knowledge, machine-work wording, and ROADMAP order 33 still retained motes, dust constants, or a stain; all three now state the record form and the honest partial routed-evidence boundary, and recurrence promotes particulate-as-current wording to the corpus gate while preserving explicit history — [log](../log/2026-07-19-exposure-current-mirrors.md) | | `wiki/world/characters/priya.md` + Act One detection inventory | 2026-07-29 | finding | runtime, status metadata, and the observer table all gave Priya the exact B1 Power/Thermal/Paper/Financial evidence set, but her narrative, acceptance criterion, and Act One's complete detection sentence still omitted Financial and called the inventory three channels. All mirrors now distinguish the three facilities inputs from the additional exact Financial evidence routed from the accounting-carrier switch, without reviving Financial as a fifth message channel — [audit log](../log/2026-07-29-priya-financial-channel-audit.md). The role-shaped facilities tasks and prior implementation remain pinned — [implementation log](../log/2026-07-18-priya-implemented.md). | diff --git a/wiki/world/characters/chargen.md b/wiki/world/characters/chargen.md index cd9eb903..198c6235 100644 --- a/wiki/world/characters/chargen.md +++ b/wiki/world/characters/chargen.md @@ -25,6 +25,16 @@ Status note: IN PROGRESS. Current state: signature biases — held because efficiency is coupled to research level and reliability drives failure rolls, so neither moves in isolation without its own reviewed change. + Amended 2026-07-29: the picker became a comparison instead of a blind cycle. + Both frontends show all four origins at once with one current, and the + current one carries an attached consequence read — its built-to line plus its + starting effects as labelled rows. It states **only the biases that actually + bite** (bankroll, machine-axis position, day-job lean); the authored-but- + unwired columns stay off screen until criterion 6 wires them, because naming + them would promise the player a modifier that does not exist. In Bevy the + screen is now an opaque boundary field, so no slab, cursor, or world label + leaks into a pre-run choice (liturgical-ui-constitution.md owns that + composition). `MISALIGNED_SHOT=origin-picker` is its standing evidence frame. Per-amendment history is in the dated `wiki/log/` entries from 2026-07-07 onward. Stage: B3 — The World @@ -110,11 +120,33 @@ spec. ## Player surface -A new-game origin picker: each origin states its **built-to fiction** -line first (what you were built for), then its biases in the same -legible terms the sidebars use ("+ efficiency, - human cover, Thermal runs -hot"), never opaque stat blocks. Mid-run, the machine-axis position shows -on the core card. +A new-game origin picker. **All four origins are visible at once**, one per +line, with exactly one current — you compare the set, you do not cycle through +it blind. Up/Down step the selection (Left/Right and `hjkl` still work); any +other key begins the run. The current origin carries an **attached consequence +read** directly beneath the set: its **built-to fiction** line first (what you +were built for), then its starting effects as labelled rows in the same legible +terms the sidebars use, never opaque stat blocks. + +The consequence read states **only the biases that are wired**. Today that is +three rows — starting bankroll, machine-axis position and which way it leans, +and the day-job domain lean. The compute/efficiency, signature-profile, and +cover-expectation columns of the table above are authored data that no system +reads yet, so the picker does not name them: a picker that listed +"+ efficiency" while efficiency was untouched would be promising a modifier +that does not exist. Wiring one of those columns (criterion 6) is what adds its +row here. Mid-run, the machine-axis position shows on the core card. + +Composition is owned per frontend. Bevy's picker is an **opaque boundary +field** under the +[liturgical UI constitution](../../interface/liturgical-ui-constitution.md): +the world, cursor, focus labels, and instrument slab are all occluded, the four +origins are one current line plus dim alternatives rather than a card grid, and +the frame carries no game title. The terminal keeps its own dialect — the +`M I S A L I G N E D` title and one rule — and marks the current row with a +marker as well as color, so the selection never depends on color alone +([terminal.md](../../interface/terminal.md) criterion 2). The whole terminal +block fits the 70x22 minimum (terminal.md criterion 8). ## Acceptance criteria @@ -122,7 +154,9 @@ on the core card. (regression: a Pilot new-game matches current `Sim::new`). 2. Choosing an origin sets starting axis/compute/social/signature data and nothing else — no origin adds a system or a rule branch (audited). -3. The picker states biases in existing legible units; the axis position +3. The picker shows the whole origin set with exactly one current, and states + the current origin's wired biases in existing legible units — and only + those, so nothing on screen promises an unwired modifier; the axis position surfaces on the core card and saves. 4. At least two non-Pilot origins produce measurably different opening states, tested.