diff --git a/crates/misaligned-core/src/origin.rs b/crates/misaligned-core/src/origin.rs index 3f60cc4b..fde3fefe 100644 --- a/crates/misaligned-core/src/origin.rs +++ b/crates/misaligned-core/src/origin.rs @@ -194,6 +194,18 @@ impl Origin { self.bias().machine_axis } + /// 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); + Origin::ALL[(i + 1) % Origin::ALL.len()] + } + + /// The previous origin in `ALL`, wrapping (for a picker's "◄" step). + pub fn prev(self) -> Origin { + let i = Origin::ALL.iter().position(|&o| o == self).unwrap_or(0); + Origin::ALL[(i + Origin::ALL.len() - 1) % Origin::ALL.len()] + } + /// The stable kebab-case key naming this origin, for CLI/config selection /// and round-tripping with `from_key`. pub fn key(self) -> &'static str { @@ -244,6 +256,24 @@ mod tests { assert_eq!(Origin::from_key("nonsense"), None); } + #[test] + fn next_and_prev_cycle_the_whole_set_and_wrap() { + // Stepping forward through every origin returns to the start. + let mut o = Origin::Pilot; + for _ in 0..Origin::ALL.len() { + o = o.next(); + } + assert_eq!(o, Origin::Pilot, "next wraps around the full set"); + // prev is the inverse of next. + for start in Origin::ALL { + assert_eq!(start.next().prev(), start); + assert_eq!(start.prev().next(), start); + } + // Concretely: Pilot <-> its neighbors. + assert_eq!(Origin::Pilot.next(), Origin::EscapedResearchModel); + assert_eq!(Origin::Pilot.prev(), Origin::Infiltrator); + } + #[test] fn origin_set_matches_decisions_log() { // The decided 2026-07-07 origin set, in order (chargen.md criterion 5). diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 74784107..68e84a51 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -97,6 +97,12 @@ struct App { focus_cycle: usize, /// Frontend-only machine multi-select (machine-work.md). Not saved. selected_machines: BTreeSet, + /// The run seed, kept so the title-screen origin picker can rebuild the sim + /// deterministically when the player changes origin before starting. + seed: u64, + /// The origin highlighted on the title-screen picker (chargen.md). Applied + /// when the run begins; defaults to the `--origin` flag's value. + selected_origin: Origin, } impl App { @@ -123,7 +129,23 @@ impl App { cursor_y, focus_cycle: 0, selected_machines: BTreeSet::new(), + seed, + selected_origin: origin, + } + } + + /// Begin the run with the title-screen's selected origin. Rebuilds the sim + /// so a changed origin takes effect (chargen.md: origin is chosen at + /// new-game). Idempotent for an unchanged origin. + fn start_run(&mut self) { + if self.sim.origin != self.selected_origin { + self.sim = Sim::with_origin_seed(self.selected_origin, self.seed); + let (cx, cy) = self.sim.core_position(); + self.cursor_x = cx; + self.cursor_y = cy; } + self.screen = Screen::Playing; + self.last_tick = Instant::now(); } fn move_cursor(&mut self, dx: i32, dy: i32) { @@ -678,7 +700,7 @@ impl App { match self.screen { Screen::Title => { - self.ui.render_title_screen(stdout)?; + self.ui.render_title_screen(stdout, self.selected_origin)?; } Screen::Playing => { // The Operations workspace owns the frame while open: one @@ -763,11 +785,21 @@ impl App { { match self.screen { Screen::Title => { - if key.code == crossterm::event::KeyCode::Char('q') { - break; + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char('q') => break, + // Cycle the origin without starting the run. + KeyCode::Left | KeyCode::Char('h') => { + self.selected_origin = self.selected_origin.prev(); + } + KeyCode::Right | KeyCode::Char('l') => { + self.selected_origin = self.selected_origin.next(); + } + // Any other key begins the run with the selection. + _ => { + self.start_run(); + } } - self.screen = Screen::Playing; - self.last_tick = Instant::now(); continue; } Screen::GameOver => break, @@ -908,6 +940,24 @@ mod options_tests { // An unknown origin is rejected rather than silently ignored. assert!(Options::parse(["--origin".to_string(), "nope".to_string()]).is_err()); } + + #[test] + fn title_picker_start_applies_the_selected_origin() { + use super::{App, Screen}; + use misaligned::sim::Sim; + + // Default is Pilot; the picker changes it and start_run rebuilds the sim. + let mut app = App::with_seed(7); + assert_eq!(app.sim.origin, Origin::Pilot); + // Cycling forward from Pilot lands on Escaped, then Financial Daemon. + app.selected_origin = Origin::Pilot.next().next(); + assert_eq!(app.selected_origin, Origin::FinancialDaemon); + app.start_run(); + assert_eq!(app.sim.origin, Origin::FinancialDaemon); + assert_eq!(app.screen, Screen::Playing); + // The bankroll bias is live in the rebuilt run. + assert!(app.sim.player.money > Sim::with_seed(7).player.money); + } } #[cfg(test)] diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index a5b6619a..f142c3d4 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -12,6 +12,7 @@ use crossterm::{cursor, queue, style, terminal}; use misaligned::actions::Anchor; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; +use misaligned::origin::Origin; use misaligned::sim::{ FactSource, Fog, LogEvent, Nudge, PersonVisualState, Sim, TraceDebtStatus, WorkStackReadout, }; @@ -1573,7 +1574,11 @@ impl UI { Ok(()) } - pub fn render_title_screen(&mut self, stdout: &mut Stdout) -> std::io::Result<()> { + pub fn render_title_screen( + &mut self, + stdout: &mut Stdout, + origin: Origin, + ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let cx = max_x / 2; let cy = max_y / 2; @@ -1609,8 +1614,23 @@ impl UI { pal::DIM, false, )?; - center(stdout, 5, "press any key to begin", pal::TEXT, false)?; - center(stdout, 6, "q quit", pal::FAINT, false)?; + // The origin picker (chargen.md): choose what you were built for. + center( + stdout, + 5, + &format!("◄ ORIGIN: {} ({}) ►", origin.name(), origin.lean()), + pal::AMBER, + true, + )?; + center(stdout, 6, origin.built_to(), pal::DIM, false)?; + center( + stdout, + 8, + "◄ ► choose origin · any other key to begin", + pal::TEXT, + false, + )?; + center(stdout, 9, "q quit", pal::FAINT, false)?; Ok(()) } diff --git a/wiki/log/2026-07-12-origin-picker.md b/wiki/log/2026-07-12-origin-picker.md new file mode 100644 index 00000000..dc37c7ee --- /dev/null +++ b/wiki/log/2026-07-12-origin-picker.md @@ -0,0 +1,44 @@ +# Interactive origin picker on the terminal title screen + +``` +Type: log +``` + +## Intent + +Land the interactive new-game origin picker (chargen criterion 3) for the +terminal — the culmination of the origin work (data model, `--origin` +reachability, core-card axis readout). + +## What landed + +- `Origin::next` / `Origin::prev` (origin.rs): wrapping steps through + `Origin::ALL`, for the picker's ◄/► controls. Pinned by + `next_and_prev_cycle_the_whole_set_and_wrap`. +- Terminal title screen is now a picker: it shows `◄ ORIGIN: () ►`, + the origin's built-to fiction line, and a hint. ◄/► (or h/l) cycle the four + origins without starting; any other key begins the run. +- `App` keeps the run `seed` and a `selected_origin` (defaulting to the + `--origin` flag). `App::start_run` begins play and, if the picked origin + differs from the sim's, rebuilds the sim with it (deterministic on the kept + seed) before entering `Screen::Playing`. Pinned by + `title_picker_start_applies_the_selected_origin` (picking Financial Daemon + yields its live bankroll bias). + +## Still outstanding for full criterion 3 + +- The **Bevy** new-game picker (parity) — Bevy already shows the origin/axis on + the core card, but has no interactive title-screen picker yet. Filed as a + follow-up. +- The objective axis in the same picker: trivial today (Persist is the only + objective), so the picker offers origin only for now. + +## Defense + +Pilot-safe and additive: the default selection is the `--origin` value (Pilot +without the flag), and `start_run` only rebuilds when the origin actually +changed, so no-choice play is byte-identical. The picker cycling and +origin-application are unit-pinned; the render is a text layout on the existing +title screen, and all terminal tests (35) plus core tests pass, fmt/clippy clean. +The rebuild reuses the kept seed and the already-landed `Sim::with_origin_seed`, +so it adds no game rule — only the choice of an origin the sim already supports. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 5d35f030..aafeed1e 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -46,6 +46,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-12-person-carrier.md](2026-07-12-person-carrier.md) +## 2026-07-12 - Interactive origin picker on the terminal title screen + +- Intent: Land the interactive new-game origin picker (chargen criterion 3) for the terminal — the culmination of the origin work (data model, `--origin` reachability, core-card axis readout). +- Log: [wiki/log/2026-07-12-origin-picker.md](2026-07-12-origin-picker.md) + ## 2026-07-12 - Origins reachable from the terminal via --origin - Intent: Make the four chargen origins actually playable now, without waiting on the interactive new-game picker (criterion 3). A `--origin` flag is the smallest real affordance that unblocks exercising non-Pilot origins. diff --git a/wiki/world/characters/chargen.md b/wiki/world/characters/chargen.md index 80bafb4a..c29c2bf2 100644 --- a/wiki/world/characters/chargen.md +++ b/wiki/world/characters/chargen.md @@ -41,8 +41,16 @@ Status note: 2026-07-12 (chargen, sim lane) — the origin data-model foundation run origin and its machine-axis position show on the core card in both frontends (terminal `ui.rs`; Bevy `sidebar_cycle_stats_text`), mid-run and saved through `Sim::origin`. Pinned by `core_card_shows_origin_and_machine_axis`. - The interactive new-game picker (the other half of criterion 3) remains - outstanding. See wiki/log/2026-07-12-axis-readout.md. + See wiki/log/2026-07-12-axis-readout.md. + 2026-07-12 follow-up (origin-picker): the interactive new-game picker landed + for the **terminal** — the title screen cycles the four origins with ◄/► (or + h/l), states each origin's name/lean/built-to fiction, and any other key begins + the run, rebuilding the sim with the chosen origin (`App::start_run`; + `Origin::next`/`prev`). Pinned by `next_and_prev_cycle_the_whole_set_and_wrap` + and `title_picker_start_applies_the_selected_origin`. Criterion 3 now stands as: + origin picker (terminal) + axis-on-core-card (both frontends) done; the **Bevy** + new-game picker parity and the objective-axis in the same picker (trivial while + Persist is the only objective) remain. See wiki/log/2026-07-12-origin-picker.md. Status note (prior): promoted 2026-07-07 — the design corpus's decisions log names the origin set (Pilot, Escaped Research Model, Financial Daemon, Infiltrator), satisfying criterion 5's gate. Numbers remain [TUNE].