//! AC9/10: current binary, completed PTY grids, and real durable run evidence. //! Headless route inputs are separate commands, not physical-batch equivalence. #![cfg(any(target_os = "linux", target_os = "macos"))] pub mod support; use hexwork_core::action::{CommittedAction, Direction}; use hexwork_core::character::{Ancestry, Calling}; use hexwork_core::modes::Mode; use hexwork_core::runtime::{ActorRole, RunLocation}; use hexwork_save::{DecodedRun, InitialLineage, SessionEventKind}; use hexwork_term::Input; use std::fs; use std::path::Path; use std::process::{Command, Output}; use support::Harness; const SEED: u64 = 223; const NAME: &str = "Tactical orientation"; fn success(output: &Output) { assert!( output.status.success(), "stdout={} stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } fn pty(harness: &Harness, scenario: &str, run_id: &str) -> serde_json::Value { let output = Harness::run({ let mut command = Command::new("python3"); command .current_dir(&harness.cwd) .env("HOME", &harness.home) .env("XDG_DATA_HOME", harness.home.join("xdg")) .env("PYTHONDONTWRITEBYTECODE", "1") .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/pty/tactical_orientation.py")) .arg(env!("CARGO_BIN_EXE_hexwork")) .arg(&harness.data) .arg(&harness.content) .arg(scenario) .arg(run_id); command }); success(&output); let evidence: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(evidence["size"], serde_json::json!([80, 24])); assert_eq!(evidence["exit"], 0); assert_eq!(evidence["scenario"], scenario); assert_eq!( evidence["screens"].as_object().unwrap().len(), if scenario == "deep" { 3 } else { 5 } ); evidence } fn saved(harness: &Harness, mode: Mode) -> DecodedRun { let runs: Vec<_> = fs::read_dir(harness.data.join("runs")) .unwrap() .map(Result::unwrap) .filter(|entry| entry.file_type().unwrap().is_dir()) .collect(); assert_eq!(runs.len(), 1, "exactly one ordinary allocated run"); let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); let bytes = hexwork_save::read_file_bounded( runs[0].path().join("run.save"), hexwork_save::MAX_COMPRESSED_BYTES, ) .unwrap(); let run = hexwork_save::decode_run(&bytes, pack.hash()).unwrap(); // Every identity field is checked against explicit inputs or the actual vault // allocation. Neither wall-clock state nor immutable fixture bytes are used. assert_eq!(run.identity.seed, SEED); assert_eq!(run.identity.mode, mode); assert_eq!(run.identity.ancestry, Ancestry::Mudborn); assert_eq!(run.identity.calling, Calling::Ratcatcher); assert_eq!(run.identity.display_name, NAME); assert_eq!(run.identity.content_hash, pack.hash()); assert_eq!( run.identity.run_id.to_string(), runs[0].file_name().to_string_lossy() ); let vault = hexwork_save::decode_vault( &hexwork_save::read_file_bounded( harness.data.join("lineage-vault.json"), hexwork_save::MAX_VAULT_BYTES, ) .unwrap(), ) .unwrap(); assert_eq!(run.identity.lineage_id, vault.lineage_id); assert_eq!(run.identity.initial_lineage, InitialLineage::default()); run } fn player_position(run: &DecodedRun) -> [f32; 3] { let players: Vec<_> = run .current .actors .iter() .filter(|actor| { run.current .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Player) }) .collect(); assert_eq!(players.len(), 1); assert!(players[0].alive && players[0].hp > 0); players[0].position.presentation_metres() } fn starter(scenario: &str, mode: Mode) { let harness = Harness::new(&format!("tactical-{scenario}")); let evidence = pty(&harness, scenario, "-"); let run = saved(&harness, mode); assert_eq!( run.current.run_location(), &RunLocation::Town("milthollow".into()) ); assert_eq!( player_position(&run), hexwork_worldgen::milthollow(SEED).start.map(|v| v as f32) ); assert!( run.events.is_empty(), "presentation navigation must not record actions" ); let mut ticks = Vec::new(); for screen in ["play", "map", "up", "down", "return"] { let row = evidence["screens"][screen][19].as_str().unwrap(); let tick = row .split(" ยท t") .nth(1) .unwrap() .split(' ') .next() .unwrap() .parse::() .unwrap(); assert!(tick <= run.current.tick); ticks.push(tick); } assert!(ticks.windows(2).all(|pair| pair[0] <= pair[1])); if mode == Mode::Story { assert!(ticks.iter().all(|tick| *tick == 0)); assert_eq!(run.current.tick, 0); } // Classic may accrue unattended time: never compare entire saves from // different wall-clock moments or require an exact live tick count. } #[test] fn binary_pty_tactical_story_orientation() { starter("story", Mode::Story); } #[test] fn binary_pty_tactical_no_color_classic_orientation() { starter("classic", Mode::Classic); } #[test] fn binary_pty_loaded_deep_two_opens_player_slice() { let harness = Harness::new("tactical-loaded-deep-two"); let mut confirmations = 0; let mut route = Vec::new(); // Reuse the exported real-action script; its route module remains read-only // and is loaded only once by support. Stop before every lethal command. for line in support::grate_mother_death_script(SEED).lines() { let input = match line { "observe state" => continue, "input north" => Input::Up, "input south" => Input::Down, "input west" => Input::Left, "input east" => Input::Right, "input confirm" => Input::Confirm, _ => panic!("unexpected ordinary route line before Deep2: {line}"), }; route.push(input); if input == Input::Confirm { confirmations += 1; if confirmations == 2 { break; } } } assert_eq!(confirmations, 2); assert_eq!(route.last(), Some(&Input::Confirm)); let mut body = String::new(); for input in &route { let command = match input { Input::Up => "north", Input::Down => "south", Input::Left => "west", Input::Right => "east", Input::Confirm => "confirm", _ => panic!("unexpected ordinary route input: {input:?}"), }; body.push_str(&format!("input {command}\n")); } body.push_str("observe state\nsave\nquit\n"); let script = harness.script("deep-two.commands", &body); let output = Harness::run({ let mut command = harness.managed(&script); command.args([ "--story", "--seed", "223", "--name", NAME, "--ancestry", "1", "--calling", "1", ]); command }); success(&output); let run = saved(&harness, Mode::Story); let allocation = Harness::records(&output) .into_iter() .find(|row| row["code"] == "allocation-durable") .unwrap(); assert_eq!(allocation["run_id"], run.identity.run_id.to_string()); assert_eq!(run.current.run_location(), &RunLocation::Deep(2)); let arrival = support::first_two_deep_arrivals(SEED)[1]; assert_eq!(arrival[2], -19); assert_eq!(player_position(&run), arrival.map(|v| v as f32)); // Derive exact connector IDs from the same generated catalogs as the // existing ordinary route, rather than accepting any two transitions. let town = hexwork_worldgen::milthollow(SEED); let floors = hexwork_worldgen::playable_deep(SEED); let transition_ids = [ town.connectors .iter() .find(|c| c.destination == hexwork_worldgen::PlanDestination::Deep { depth: 1 }) .unwrap() .transition_id, floors[0] .connectors .iter() .find(|c| c.destination == hexwork_worldgen::PlanDestination::Deep { depth: 2 }) .unwrap() .transition_id, ]; let mut connector = 0; let expected: Vec<_> = route .iter() .map(|input| match input { Input::Up => CommittedAction::Move(Direction::North), Input::Down => CommittedAction::Move(Direction::South), Input::Left => CommittedAction::Move(Direction::West), Input::Right => CommittedAction::Move(Direction::East), Input::Confirm => { let transition_id = transition_ids[connector]; connector += 1; CommittedAction::UseTransition { transition_id } } _ => unreachable!(), }) .collect(); assert_eq!(connector, 2); let actual: Vec<_> = run .events .iter() .map(|event| match &event.kind { SessionEventKind::Action(action) => action.clone(), _ => panic!("no resurrection belongs in a nonlethal prefix"), }) .collect(); assert_eq!( actual, expected, "exact recorded ordinary route, no lethal suffix" ); pty(&harness, "deep", &run.identity.run_id.to_string()); let after = saved(&harness, Mode::Story); assert_eq!(after.identity, run.identity); assert_eq!(after.events, run.events); assert_eq!(after.current.run_location(), &RunLocation::Deep(2)); assert_eq!(player_position(&after), arrival.map(|v| v as f32)); assert_eq!(after.current.tick, run.current.tick); assert_eq!( after.current.try_hash_state().unwrap(), run.current.try_hash_state().unwrap() ); }