//! AC12 actual-process Story/Classic transfer lifecycle evidence. //! //! Every gameplay mutation below comes from normal commands parsed by the //! production binary. Decoding is used only to inspect durable results and to //! label a replay from that durable history; no State, HP, position, item, or //! event field is injected into a positive gameplay path. #![cfg(any(target_os = "linux", target_os = "macos"))] pub mod support; use hexwork_core::action::CommittedAction; use hexwork_core::runtime::ActorRole; use hexwork_save::artifact::MAX_EPITAPH_BYTES; use hexwork_save::{ decode_replay, decode_run, decode_vault, encode_replay, read_file_bounded, ProductionReplay, RunId, SessionEvent, SessionEventKind, MAX_COMPRESSED_BYTES, MAX_MARKER_BYTES, MAX_VAULT_BYTES, }; use hexwork_worldgen::{Cell, ConnectorPlan, FloorPlan, PlanDestination, VoxelWrite, WorldPlan}; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use support::{grate_mother_death_script, Harness}; const SEED: u64 = 223; const SMALL_EVIDENCE_CAP: usize = 64 * 1024; const CONFLICTING_REPLAY: &[u8] = b"ac12 deterministic regular replay collision\n"; const TRANSFER_CYCLE: &str = "input inventory\ninput drop\ninput confirm\ninput inventory\ninput pickup\ninput confirm\n"; const POST_CHECKPOINT_DROP: &str = "input inventory\ninput drop\ninput confirm\ninput inventory\nsave\nquit\n"; fn field<'a>(record: &'a serde_json::Value, name: &str) -> &'a serde_json::Value { record .get(name) .unwrap_or_else(|| panic!("missing {name}: {record}")) } fn bounded(path: impl AsRef, cap: usize) -> Vec { let path = path.as_ref(); read_file_bounded(path, cap) .unwrap_or_else(|error| panic!("bounded read {}: {error}", path.display())) } fn assert_success(output: &Output, stage: &str) { assert!( output.status.success(), "{stage}: stdout={} stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } fn assert_exit(output: &Output, expected: i32, code: &str) { assert_eq!( output.status.code(), Some(expected), "expected exit {expected} ({code}); stdout={} stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); assert!( String::from_utf8_lossy(&output.stderr).contains(code), "missing {code}: {}", String::from_utf8_lossy(&output.stderr) ); } fn allocation_run_id(output: &Output) -> String { Harness::records(output) .iter() .find(|record| field(record, "code") == "allocation-durable") .and_then(|record| field(record, "run_id").as_str()) .expect("allocation milestone run ID") .to_owned() } fn run_path(harness: &Harness, run_id: &str) -> PathBuf { harness.data.join("runs").join(run_id).join("run.save") } fn decode_saved( harness: &Harness, pack: &hexwork_content::RuntimePack, run_id: &str, ) -> hexwork_save::DecodedRun { decode_run( &bounded(run_path(harness, run_id), MAX_COMPRESSED_BYTES), pack.hash(), ) .expect("decode actual managed save") } fn player_id(run: &hexwork_save::DecodedRun) -> u64 { run.current .actors .iter() .find(|actor| { run.current .identity(actor.id) .is_some_and(|identity| identity.role == ActorRole::Player) }) .expect("one authoritative Player") .id } fn transfer_cycle_item(events: &[SessionEvent]) -> u64 { let drop_id = match events.first().map(|event| &event.kind) { Some(SessionEventKind::Action(CommittedAction::Drop { item_id })) => *item_id, other => panic!("first durable action was not starter drop: {other:?}"), }; let pickup_id = match events.get(1).map(|event| &event.kind) { Some(SessionEventKind::Action(CommittedAction::Pickup { item_id })) => *item_id, other => panic!("second durable action was not matching pickup: {other:?}"), }; assert_eq!(pickup_id, drop_id); assert_eq!(events[0].post_tick - events[0].pre_tick, 10); assert_eq!(events[1].post_tick - events[1].pre_tick, 10); drop_id } fn new_command(harness: &Harness, script: &Path, story: bool) -> Command { let mut command = harness.managed(script); command .arg("--seed") .arg(SEED.to_string()) .arg("--ancestry") .arg("1") .arg("--calling") .arg("1"); if story { command.arg("--story"); } command } fn load_command(harness: &Harness, script: &Path, run_id: &str, story: bool) -> Command { let mut command = harness.managed(script); command.arg("--load").arg(run_id); if story { command.arg("--story"); } command } fn recover_command(harness: &Harness) -> Command { let mut command = harness.command(); command .arg("--data-dir") .arg(&harness.data) .arg("--content-root") .arg(&harness.content) .arg("--recover"); command } fn replay_command(harness: &Harness, replay_path: &Path) -> Command { let mut command = harness.command(); command .arg("--content-root") .arg(&harness.content) .arg("--replay") .arg(replay_path); command } fn assert_actual_replay(harness: &Harness, replay_path: &Path, expected_hash: u64) { let output = Harness::run(replay_command(harness, replay_path)); assert_success(&output, "actual --replay"); let stdout = String::from_utf8(output.stdout).expect("replay stdout UTF-8"); assert!(stdout.contains("verified event-text replay"), "{stdout}"); assert!( stdout.contains(&format!("hash {expected_hash:#x}")), "{stdout}" ); } fn append_route( output: &mut String, writes: &[VoxelWrite], start: Cell, goal: Cell, blockers: BTreeSet, ) { support::sampled_route::append_route(output, writes, start, goal, &blockers); } fn voxel_at(writes: &[VoxelWrite], cell: Cell) -> hexwork_core::voxel::Voxel { writes .binary_search_by_key(&(cell[2], cell[1], cell[0]), |write| { (write.cell[2], write.cell[1], write.cell[0]) }) .ok() .map(|index| writes[index].voxel) .unwrap_or_default() } fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { let body = voxel_at(writes, cell); let head = voxel_at(writes, [cell[0], cell[1], cell[2] + 1]); let below = voxel_at(writes, [cell[0], cell[1], cell[2] - 1]); body.occ == 0 && head.occ == 0 && below.occ != 0 && body.fluid_amt <= 2 } fn world_blockers(plan: &WorldPlan) -> BTreeSet { support::sampled_route::world_blockers(plan) } fn floor_blockers(plan: &FloorPlan) -> BTreeSet { support::sampled_route::floor_blockers(plan) } fn paired<'a>(connectors: &'a [ConnectorPlan], source: &ConnectorPlan) -> &'a ConnectorPlan { connectors .iter() .find(|connector| connector.transition_id == source.paired_transition_id) .expect("paired connector") } fn adjacent_access(writes: &[VoxelWrite], target: Cell, blockers: &BTreeSet) -> Cell { [(0, 1), (-1, 0), (1, 0), (0, -1)] .into_iter() .map(|(dx, dy)| [target[0] + dx, target[1] + dy, target[2]]) .find(|cell| passable(writes, *cell) && !blockers.contains(cell)) .expect("Grate Mother has accessible adjacent cell") } fn genuine_story_checkpoint_route(seed: u64) -> (String, Cell, u64) { let town = hexwork_worldgen::milthollow(seed); let floor1 = &hexwork_worldgen::playable_deep(seed)[0]; let down = town .connectors .iter() .find(|connector| connector.destination == PlanDestination::Deep { depth: 1 }) .expect("Town to Deep-1 connector"); let up = paired(&floor1.connectors, down); let mut commands = String::new(); append_route( &mut commands, &town.writes, town.start, down.access, world_blockers(&town), ); commands.push_str("input confirm\ninput confirm\n"); (commands, down.access, up.transition_id) } fn grate_mother_death_script_from(seed: u64, town_start: Cell) -> String { let town = hexwork_worldgen::milthollow(seed); let floors = hexwork_worldgen::playable_deep(seed); let floor1 = &floors[0]; let floor2 = &floors[1]; let down = town .connectors .iter() .find(|connector| connector.destination == PlanDestination::Deep { depth: 1 }) .expect("Town to Deep-1 connector"); let floor1_up = paired(&floor1.connectors, down); let floor1_down = floor1 .connectors .iter() .find(|connector| connector.destination == PlanDestination::Deep { depth: 2 }) .expect("Deep-1 to Deep-2 connector"); let floor2_up = paired(&floor2.connectors, floor1_down); let mother = floor2 .actors .iter() .find(|actor| actor.template_id == "grate_mother") .expect("generated Grate Mother"); let blockers = floor_blockers(floor2); let access = adjacent_access(&floor2.writes, mother.position, &blockers); let mut commands = String::new(); append_route( &mut commands, &town.writes, town_start, down.access, world_blockers(&town), ); commands.push_str("input confirm\n"); append_route( &mut commands, &floor1.writes, floor1_up.access, floor1_down.access, floor_blockers(floor1), ); commands.push_str("input confirm\n"); append_route( &mut commands, &floor2.writes, floor2_up.access, access, blockers, ); commands.push_str("input confirm\ninput confirm\ninput confirm\ninput confirm\n"); commands } fn milestone_positions(records: &[serde_json::Value], wanted: &[&str]) -> Vec { let codes = records .iter() .filter_map(|record| field(record, "code").as_str()) .collect::>(); wanted .iter() .map(|wanted| { codes .iter() .position(|code| code == wanted) .unwrap_or_else(|| panic!("missing {wanted}: {codes:?}")) }) .collect() } #[test] fn binary_story_transfer_checkpoint_rollback_and_replay() { let harness = Harness::new("ac12-story-transfer-lifecycle"); let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); let (checkpoint_route, return_cell, return_transition_id) = genuine_story_checkpoint_route(SEED); let mut checkpoint_body = String::from(TRANSFER_CYCLE); checkpoint_body.push_str(&checkpoint_route); checkpoint_body.push_str("observe state\nsave\nquit\n"); let checkpoint_script = harness.script("story-checkpoint.commands", &checkpoint_body); let checkpoint_output = Harness::run(new_command(&harness, &checkpoint_script, true)); assert_success(&checkpoint_output, "Story checkpoint process"); let run_id = allocation_run_id(&checkpoint_output); let checkpoint_saved = decode_saved(&harness, &pack, &run_id); assert_eq!(checkpoint_saved.identity.seed, SEED); assert_eq!( checkpoint_saved.identity.ancestry, hexwork_core::character::Ancestry::Mudborn ); assert_eq!( checkpoint_saved.identity.calling, hexwork_core::character::Calling::Ratcatcher ); let selected_id = transfer_cycle_item(&checkpoint_saved.events); let player = player_id(&checkpoint_saved); let checkpoint = checkpoint_saved .story_checkpoint .as_ref() .expect("genuine Story checkpoint"); let checkpoint_prefix = usize::try_from(checkpoint.after_event_count).unwrap(); assert!( checkpoint_prefix > 2, "checkpoint prefix must be nonzero and post-route" ); assert_eq!(checkpoint_prefix, checkpoint_saved.events.len()); assert!(matches!( checkpoint_saved.events[checkpoint_prefix - 1].kind, SessionEventKind::Action(CommittedAction::UseTransition { transition_id }) if transition_id == return_transition_id )); let checkpoint_actor = checkpoint .state .image .actors .iter() .find(|actor| actor.id == player) .expect("checkpoint Player"); assert_eq!(checkpoint_actor.position.cell(), return_cell); assert_ne!(return_cell, hexwork_worldgen::milthollow(SEED).start); let checkpoint_instance = checkpoint.state.image.packs[&player].items[&selected_id].clone(); assert!(!checkpoint.state.image.items.contains_key(&selected_id)); assert!(!checkpoint .state .image .ground_items .contains_key(&selected_id)); let post_drop_script = harness.script("story-post-checkpoint-drop.commands", POST_CHECKPOINT_DROP); let post_drop_output = Harness::run(load_command(&harness, &post_drop_script, &run_id, true)); assert_success(&post_drop_output, "Story post-checkpoint drop process"); let post_drop = decode_saved(&harness, &pack, &run_id); let post_drop_image = post_drop.current.to_image().unwrap(); assert_eq!(post_drop.events.len(), checkpoint_prefix + 1); assert!(matches!( post_drop.events[checkpoint_prefix].kind, SessionEventKind::Action(CommittedAction::Drop { item_id }) if item_id == selected_id )); assert_eq!(post_drop_image.items[&selected_id], checkpoint_instance); assert_eq!( post_drop_image.ground_items[&selected_id], return_cell.into() ); assert!(!post_drop_image.packs[&player] .items .contains_key(&selected_id)); let mut lethal_body = grate_mother_death_script_from(SEED, return_cell); lethal_body.push_str("observe state\nsave\nquit\n"); let lethal_script = harness.script("story-recorded-lethal.commands", &lethal_body); let lethal_output = Harness::run(load_command(&harness, &lethal_script, &run_id, true)); assert_success(&lethal_output, "Story recorded lethal/resurrection process"); let restored = decode_saved(&harness, &pack, &run_id); let restored_image = restored.current.to_image().unwrap(); let active_checkpoint = restored.story_checkpoint.as_ref().unwrap(); assert_eq!( active_checkpoint.after_event_count as usize, checkpoint_prefix ); assert_eq!(active_checkpoint.state.hash, checkpoint.state.hash); assert_eq!(restored_image.packs, checkpoint.state.image.packs); assert_eq!(restored_image.items, checkpoint.state.image.items); assert_eq!( restored_image.ground_items, checkpoint.state.image.ground_items ); assert_eq!( restored_image.packs[&player].items[&selected_id], checkpoint_instance ); assert!(!restored_image.items.contains_key(&selected_id)); assert!(!restored_image.ground_items.contains_key(&selected_id)); assert!(restored_image.chapel_debts.contains_key(&player)); assert_eq!( &restored.events[..checkpoint_prefix], &checkpoint_saved.events[..checkpoint_prefix], "resurrection must retain the genuine checkpoint prefix" ); assert!(matches!( restored.events[checkpoint_prefix].kind, SessionEventKind::Action(CommittedAction::Drop { item_id }) if item_id == selected_id )); let attack_count = restored.events[checkpoint_prefix + 1..] .iter() .filter(|event| { matches!( event.kind, SessionEventKind::Action(CommittedAction::ResolveAttack { .. }) ) }) .count(); assert_eq!( attack_count, 4, "seed-223 route must record four combat exchanges" ); let restored_hash = restored.current.try_hash_state().unwrap(); let resurrection = restored .events .iter() .find_map(|event| match event.kind { SessionEventKind::StoryResurrect { checkpoint_after_event_count, checkpoint_hash, dead_state_hash, restored_state_hash, } => Some(( checkpoint_after_event_count, checkpoint_hash, dead_state_hash, restored_state_hash, )), _ => None, }) .expect("recorded Story resurrection event"); assert_eq!(resurrection.0 as usize, checkpoint_prefix); assert_eq!(resurrection.1, checkpoint.state.hash); assert_ne!(resurrection.2, checkpoint.state.hash); assert_eq!(resurrection.3, restored_hash); let replay = ProductionReplay { identity: restored.identity.clone(), events: restored.events.clone(), expected_final_tick: restored.current.tick, expected_final_hash: restored_hash, }; let replay_path = harness.root.join("story-transfer-history.hexdemo"); fs::write(&replay_path, encode_replay(&replay).unwrap()).unwrap(); let decoded_replay = decode_replay(&bounded(&replay_path, MAX_COMPRESSED_BYTES), pack.hash()) .expect("decode replay labeled from durable Story history"); assert_eq!(decoded_replay.events, restored.events); assert_actual_replay(&harness, &replay_path, restored_hash); } #[test] fn binary_classic_transfer_death_recovery_and_replay() { let harness = Harness::new("ac12-classic-transfer-lifecycle"); let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); let initial_script = harness.script( "classic-transfer-cycle.commands", &format!("{TRANSFER_CYCLE}save\nquit\n"), ); let initial_output = Harness::run(new_command(&harness, &initial_script, false)); assert_success(&initial_output, "Classic transfer cycle process"); let run_id = allocation_run_id(&initial_output); let parsed_run_id = run_id.parse::().unwrap(); let run_dir = harness.data.join("runs").join(&run_id); let initial = decode_saved(&harness, &pack, &run_id); assert_eq!(initial.identity.seed, SEED); assert_eq!( initial.identity.ancestry, hexwork_core::character::Ancestry::Mudborn ); assert_eq!( initial.identity.calling, hexwork_core::character::Calling::Ratcatcher ); let selected_id = transfer_cycle_item(&initial.events); let player = player_id(&initial); let selected_instance = initial.current.to_image().unwrap().packs[&player].items[&selected_id].clone(); let replay_path = run_dir.join("replay.hexdemo"); let pending_path = run_dir.join("death.pending"); let committed_path = run_dir.join("death.committed"); let epitaph_path = run_dir.join("epitaph.txt"); let managed_run_path = run_dir.join("run.save"); let vault_path = harness.data.join("lineage-vault.json"); let living_vault = bounded(&vault_path, MAX_VAULT_BYTES); fs::write(&replay_path, CONFLICTING_REPLAY).expect("install regular replay-file conflict"); let lethal_script = harness.script( "classic-recorded-lethal.commands", &grate_mother_death_script(SEED), ); let failed = Harness::run(load_command(&harness, &lethal_script, &run_id, false)); assert_exit(&failed, 3, "immutable-artifact-mismatch"); let failed_records = Harness::records(&failed); let failed_positions = milestone_positions( &failed_records, &["terminal-snapshot-durable", "pending-death-durable"], ); assert!(failed_positions[0] < failed_positions[1]); let failed_codes = failed_records .iter() .filter_map(|record| field(record, "code").as_str()) .collect::>(); for forbidden in [ "replay-durable", "epitaph-durable", "vault-death-durable", "committed-death-durable", "run-retired-durable", "pending-retired-durable", ] { assert!(!failed_codes.contains(&forbidden), "unexpected {forbidden}"); } assert!(managed_run_path.is_file()); assert!(pending_path.is_file()); assert!(!committed_path.exists()); assert!(!epitaph_path.exists()); assert_eq!( bounded(&replay_path, SMALL_EVIDENCE_CAP), CONFLICTING_REPLAY ); assert_eq!(bounded(&vault_path, MAX_VAULT_BYTES), living_vault); let terminal = decode_saved(&harness, &pack, &run_id); assert_eq!( &terminal.events[..initial.events.len()], initial.events.as_slice() ); assert_eq!(transfer_cycle_item(&terminal.events), selected_id); assert_eq!( terminal .events .iter() .filter(|event| matches!( event.kind, SessionEventKind::Action(CommittedAction::ResolveAttack { .. }) )) .count(), 4 ); let terminal_image = terminal.current.to_image().unwrap(); let terminal_player = terminal_image .actors .iter() .find(|actor| actor.id == player) .expect("terminal Player"); assert!(!terminal_player.alive && terminal_player.hp <= 0); assert_eq!( terminal_image.packs[&player].items[&selected_id], selected_instance ); let terminal_hash = terminal.current.try_hash_state().unwrap(); let terminal_bytes = bounded(&managed_run_path, MAX_COMPRESSED_BYTES); let pending_bytes = bounded(&pending_path, MAX_MARKER_BYTES); let retained = || { ( bounded(&managed_run_path, MAX_COMPRESSED_BYTES), bounded(&pending_path, MAX_MARKER_BYTES), bounded(&replay_path, SMALL_EVIDENCE_CAP), bounded(&vault_path, MAX_VAULT_BYTES), ) }; let retained_before = ( terminal_bytes, pending_bytes, CONFLICTING_REPLAY.to_vec(), living_vault, ); let blocked_recover = Harness::run(recover_command(&harness)); assert_exit(&blocked_recover, 3, "immutable-artifact-mismatch"); assert_eq!(retained(), retained_before); fs::remove_file(&replay_path).expect("remove only regular replay obstacle"); let recovered = Harness::run(recover_command(&harness)); assert_success(&recovered, "independent Classic recovery"); let recovered_records = Harness::records(&recovered); let recovery_positions = milestone_positions( &recovered_records, &[ "replay-durable", "epitaph-durable", "vault-death-durable", "committed-death-durable", "run-retired-durable", "pending-retired-durable", ], ); assert!(recovery_positions.windows(2).all(|pair| pair[0] < pair[1])); assert!(!managed_run_path.exists()); assert!(!pending_path.exists()); assert!(committed_path.is_file()); assert!(epitaph_path.is_file()); let replay_bytes = bounded(&replay_path, MAX_COMPRESSED_BYTES); let replay = decode_replay(&replay_bytes, pack.hash()).expect("death-produced replay"); assert_eq!(replay.identity.run_id, parsed_run_id); assert_eq!(replay.events, terminal.events); assert_eq!(transfer_cycle_item(&replay.events), selected_id); assert_eq!(replay.expected_final_tick, terminal.current.tick); assert_eq!(replay.expected_final_hash, terminal_hash); assert_actual_replay(&harness, &replay_path, terminal_hash); let vault = decode_vault(&bounded(&vault_path, MAX_VAULT_BYTES)).unwrap(); let receipt = vault .applied_deaths .get(&parsed_run_id) .expect("death receipt for actual run"); assert_eq!(receipt.terminal_core_hash, terminal_hash); let selected_lot = vault .current .auction_lots .iter() .find(|lot| lot.origin_run_id == parsed_run_id && lot.origin_item_id == selected_id) .expect("selected transferred instance has death provenance"); assert_eq!(selected_lot.template_id, selected_instance.template); assert_eq!(selected_lot.material, selected_instance.material); assert_eq!(selected_lot.identified, selected_instance.identified); assert_eq!(selected_lot.cursed, selected_instance.cursed); assert_eq!(selected_lot.blessed, selected_instance.blessed); assert_eq!(selected_lot.condition, selected_instance.condition); assert_eq!(selected_lot.silvered, selected_instance.silvered); assert_eq!(selected_lot.illegal, selected_instance.illegal); assert_eq!(selected_lot.tags, selected_instance.tags); assert_eq!(selected_lot.fuel, selected_instance.fuel); assert_eq!(selected_lot.wet_ticks, selected_instance.wet_ticks); let stable_completed = || { ( bounded(&vault_path, MAX_VAULT_BYTES), bounded(&replay_path, MAX_COMPRESSED_BYTES), bounded(&epitaph_path, MAX_EPITAPH_BYTES), bounded(&committed_path, MAX_MARKER_BYTES), ) }; let completed = stable_completed(); let repeated = Harness::run(recover_command(&harness)); assert_success(&repeated, "idempotent repeated recovery"); let repeated_codes = Harness::records(&repeated) .iter() .filter_map(|record| field(record, "code").as_str().map(str::to_owned)) .collect::>(); assert!(repeated_codes.iter().all(|code| ![ "replay-durable", "epitaph-durable", "vault-death-durable", "committed-death-durable", "run-retired-durable", "pending-retired-durable", ] .contains(&code.as_str()))); assert_eq!(stable_completed(), completed); }