diff --git a/crates/misaligned-core/src/origin.rs b/crates/misaligned-core/src/origin.rs index 4e91446f..3f60cc4b 100644 --- a/crates/misaligned-core/src/origin.rs +++ b/crates/misaligned-core/src/origin.rs @@ -193,6 +193,29 @@ impl Origin { pub fn machine_axis(self) -> i8 { self.bias().machine_axis } + + /// The stable kebab-case key naming this origin, for CLI/config selection + /// and round-tripping with `from_key`. + pub fn key(self) -> &'static str { + match self { + Origin::Pilot => "pilot", + Origin::EscapedResearchModel => "escaped-research-model", + Origin::FinancialDaemon => "financial-daemon", + Origin::Infiltrator => "infiltrator", + } + } + + /// Parse an origin from a CLI/config key: the kebab-case `key`, or a short + /// unambiguous alias. Case-insensitive; returns `None` for an unknown key. + pub fn from_key(key: &str) -> Option { + match key.trim().to_ascii_lowercase().as_str() { + "pilot" => Some(Origin::Pilot), + "escaped-research-model" | "escaped" | "research" => Some(Origin::EscapedResearchModel), + "financial-daemon" | "financial" | "daemon" => Some(Origin::FinancialDaemon), + "infiltrator" => Some(Origin::Infiltrator), + _ => None, + } + } } #[cfg(test)] @@ -207,6 +230,20 @@ mod tests { assert_eq!(Origin::default(), Origin::Pilot); } + #[test] + fn from_key_round_trips_every_origin_and_rejects_unknown() { + for o in Origin::ALL { + assert_eq!(Origin::from_key(o.key()), Some(o), "key round-trips"); + } + // Aliases and case-insensitivity. + assert_eq!(Origin::from_key("FINANCIAL"), Some(Origin::FinancialDaemon)); + assert_eq!( + Origin::from_key(" escaped "), + Some(Origin::EscapedResearchModel) + ); + assert_eq!(Origin::from_key("nonsense"), None); + } + #[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/agent.rs b/crates/misaligned-terminal/src/agent.rs index 25143380..9904ddaf 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -11,6 +11,7 @@ use misaligned::actions::{ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::operations_projection::{OperationsTarget, OperationsView, SchemeKind}; +use misaligned::origin::Origin; use misaligned::person::{AssetKnowledge, AssetTask}; use misaligned::reach::{Party, ReachBlock}; use misaligned::research::Track; @@ -32,10 +33,10 @@ const MAP_W: usize = WIDTH - SIDEBAR_W - 1; const MAP_H: usize = HEIGHT - 9; const PANEL_INNER_W: usize = WIDTH - 2; -pub fn run(seed: u64) -> io::Result<()> { +pub fn run(origin: Origin, seed: u64) -> io::Result<()> { let stdin = io::stdin(); let mut stdout = io::BufWriter::new(io::stdout().lock()); - let mut app = AgentApp::new(seed); + let mut app = AgentApp::with_origin_seed(origin, seed); for line in stdin.lock().lines() { let line = line?; @@ -98,8 +99,13 @@ fn local_event(tick: u64, text: impl Into) -> LogEvent { } impl AgentApp { + #[cfg(test)] fn new(seed: u64) -> Self { - let sim = Sim::with_seed(seed); + Self::with_origin_seed(Origin::Pilot, seed) + } + + fn with_origin_seed(origin: Origin, seed: u64) -> Self { + let sim = Sim::with_origin_seed(origin, seed); let (cursor_x, cursor_y) = sim.core_position(); Self { sim, diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index cf3da6a5..74784107 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -19,6 +19,7 @@ use crossterm::{ }; use misaligned::actions::{ActionCommand, Anchor, DialId, HumanMenuRow}; use misaligned::operations_projection::OperationsTarget; +use misaligned::origin::Origin; use misaligned::person::Knowledge; use misaligned::sim::{DEFAULT_SEED, Sim}; use misaligned::work_grid::{MachineIntensity, MachineMode}; @@ -99,8 +100,13 @@ struct App { } impl App { + #[cfg(test)] fn with_seed(seed: u64) -> Self { - let sim = Sim::with_seed(seed); + Self::with_origin_seed(Origin::Pilot, seed) + } + + fn with_origin_seed(origin: Origin, seed: u64) -> Self { + let sim = Sim::with_origin_seed(origin, seed); let (cursor_x, cursor_y) = sim.core_position(); Self { sim, @@ -805,9 +811,9 @@ impl App { fn main() -> io::Result<()> { let options = Options::parse(std::env::args().skip(1))?; if options.agent { - agent::run(options.seed) + agent::run(options.origin, options.seed) } else { - let mut app = App::with_seed(options.seed); + let mut app = App::with_origin_seed(options.origin, options.seed); app.run() } } @@ -815,6 +821,7 @@ fn main() -> io::Result<()> { struct Options { agent: bool, seed: u64, + origin: Origin, } impl Options { @@ -822,6 +829,7 @@ impl Options { let mut options = Self { agent: false, seed: DEFAULT_SEED, + origin: Origin::Pilot, }; let mut args = args.into_iter(); while let Some(arg) = args.next() { @@ -836,6 +844,17 @@ impl Options { _ if arg.starts_with("--seed=") => { options.seed = parse_seed(arg.trim_start_matches("--seed="))?; } + // The run origin (chargen.md), a dev/repro affordance like + // --seed: the interactive new-game picker is criterion-3 work. + "--origin" => { + let Some(value) = args.next() else { + return Err(invalid_arg("--origin requires a value")); + }; + options.origin = parse_origin(&value)?; + } + _ if arg.starts_with("--origin=") => { + options.origin = parse_origin(arg.trim_start_matches("--origin="))?; + } _ => return Err(invalid_arg(&format!("unknown argument: {arg}"))), } } @@ -843,6 +862,14 @@ impl Options { } } +fn parse_origin(value: &str) -> io::Result { + Origin::from_key(value).ok_or_else(|| { + invalid_arg(&format!( + "unknown origin: {value} (pilot | escaped-research-model | financial-daemon | infiltrator)" + )) + }) +} + fn parse_seed(value: &str) -> io::Result { if let Some(hex) = value .strip_prefix("0x") @@ -860,6 +887,29 @@ fn invalid_arg(msg: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, msg) } +#[cfg(test)] +mod options_tests { + use super::Options; + use misaligned::origin::Origin; + + #[test] + fn origin_flag_selects_the_run_origin() { + let spaced = Options::parse(["--origin".to_string(), "financial-daemon".to_string()]) + .expect("valid --origin"); + assert_eq!(spaced.origin, Origin::FinancialDaemon); + + let eq = Options::parse(["--origin=infiltrator".to_string()]).expect("valid --origin="); + assert_eq!(eq.origin, Origin::Infiltrator); + + // No flag defaults to Pilot (the identity origin — unchanged play). + let default = Options::parse(Vec::::new()).expect("empty args"); + assert_eq!(default.origin, Origin::Pilot); + + // An unknown origin is rejected rather than silently ignored. + assert!(Options::parse(["--origin".to_string(), "nope".to_string()]).is_err()); + } +} + #[cfg(test)] mod view_flip_tests { use super::{App, Command, MenuState, ViewMode}; diff --git a/wiki/log/2026-07-12-origin-cli.md b/wiki/log/2026-07-12-origin-cli.md new file mode 100644 index 00000000..f9240ba0 --- /dev/null +++ b/wiki/log/2026-07-12-origin-cli.md @@ -0,0 +1,44 @@ +# Origins reachable from the terminal via --origin + +``` +Type: log +``` + +## 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. + +## What landed + +- `Origin::key` / `Origin::from_key` (origin.rs): a stable kebab-case key per + origin plus a case-insensitive parser accepting the key or a short alias + (`escaped`, `financial`, `daemon`, `research`). Round-trip pinned by + `from_key_round_trips_every_origin_and_rejects_unknown`. +- Terminal `--origin ` / `--origin=` flag (main.rs `Options`), + parallel to `--seed`. It threads through both entry points: the human app + (`App::with_origin_seed`) and agent mode (`agent::run` / `AgentApp::with_origin_seed`), + each constructing `Sim::with_origin_seed`. Default and unknown handling: + no flag → Pilot; an unknown key is rejected with a helpful message rather + than silently ignored. Pinned by `origin_flag_selects_the_run_origin`. +- The old `with_seed`/`new` constructors became `#[cfg(test)]` Pilot-default + helpers (their only remaining callers are tests). + +## Not criterion 3 + +This is a dev/repro affordance in the same category as `--seed` — it is not the +player-facing new-game picker, and it does not add the core-card machine-axis +readout. Criterion 3 (the interactive picker + axis readout) remains outstanding +and rides the same not-yet-built new-game surface objective.md's B3 criteria +defer. + +## Defense + +Pilot-preserving: no flag selects Pilot, the identity origin, so default play is +byte-identical and all existing tests pass (372 core, 34 terminal, both +frontends build, fmt/clippy clean). The flag reuses the existing `Options` +parser and the already-landed `Sim::with_origin_seed`, so it adds no new game +rule — only a way to choose the origin the sim already supports. Unknown keys +fail loudly, matching `--seed`'s validation. chargen.md records that this is a +tooling affordance, not criterion 3. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index faebdc02..4d1d2016 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 - 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. +- Log: [wiki/log/2026-07-12-origin-cli.md](2026-07-12-origin-cli.md) + ## 2026-07-12 - Operations workspace implemented - Intent: (see session log) diff --git a/wiki/world/characters/chargen.md b/wiki/world/characters/chargen.md index b7ee24a4..12b6ac7f 100644 --- a/wiki/world/characters/chargen.md +++ b/wiki/world/characters/chargen.md @@ -31,6 +31,12 @@ Status note: 2026-07-12 (chargen, sim lane) — the origin data-model foundation passes no lean, so the even draw and every existing test are unchanged. Pinned by `origin_lean_biases_the_assignment_draw`. See wiki/log/2026-07-12-chargen-dayjob-lean.md. + 2026-07-12 follow-up (origin-cli): all four origins are now reachable from the + terminal via a `--origin ` flag (a dev/repro affordance like `--seed`; + keys via `Origin::from_key`, e.g. `financial-daemon`), threaded through the + human and agent frontends. This is not criterion 3 — the interactive new-game + picker + core-card axis readout is still outstanding — but it makes non-Pilot + origins playable and testable now. See wiki/log/2026-07-12-origin-cli.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].