diff --git a/crates/hexwork-cli/tests/phase11/negative.rs b/crates/hexwork-cli/tests/phase11/negative.rs new file mode 100644 index 0000000..33acf66 --- /dev/null +++ b/crates/hexwork-cli/tests/phase11/negative.rs @@ -0,0 +1,431 @@ +//! Negative production-process evidence for Stage 3c2. +//! +//! This module is nested under `phase11_gate`, so every case uses the accepted +//! bounded harness and the actual `CARGO_BIN_EXE_hexwork` binary. + +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use super::{field, support::Harness}; +use hexwork_save::{read_file_bounded, MAX_COMPRESSED_BYTES, MAX_VAULT_BYTES}; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Output; + +const SCRIPT_CAP: u64 = 8 * 1024 * 1024; +const REPLAY_CAP: u64 = 64 * 1024 * 1024; +const REJECTED_SCRIPT: &str = "observe state\ninput wait\nsave\n"; +// Historical v1 shape retained verbatim from hexwork-save's +// `real_v1_vault_rejects_before_v2_body_and_missing_is_explicit` fixture. +const OLD_V1_VAULT: &[u8] = + br#"{"version":1,"mapped_regions":[],"recipes":[],"translated_hymns":[],"ghosts":[]}"#; + +// These exact files were emitted and decoded by the accepted v3 encoder at +// 97edbd8dc95e805bfe4f9167ea09420d2853baa2 in an isolated git archive. Keep +// that provenance alongside every process-level reuse of the historical bytes. +const GENUINE_V3_RUN: &[u8] = + include_bytes!("../../../hexwork-save/testfixtures/run-v3-97edbd8.hexrun"); +const GENUINE_V3_REPLAY: &[u8] = + include_bytes!("../../../hexwork-save/testfixtures/replay-v3-97edbd8.hexdemo"); + +struct Baseline { + run_id: String, + run_path: PathBuf, + vault_path: PathBuf, + run_bytes: Vec, + vault_bytes: Vec, + run_entries: Vec, +} + +fn bounded_bytes(path: &Path, cap: usize) -> Vec { + read_file_bounded(path, cap) + .unwrap_or_else(|error| panic!("bounded snapshot {}: {error}", path.display())) +} + +fn use_private_content(harness: &mut Harness) { + let canonical = harness.content.clone(); + let private = harness.root.join("content"); + for relative in hexwork_content::manifest() { + let source = canonical.join(relative); + let destination = private.join(relative); + fs::create_dir_all(destination.parent().expect("manifest parent")) + .expect("create private content parent"); + fs::copy(&source, &destination).unwrap_or_else(|error| { + panic!( + "copy canonical content {} to {}: {error}", + source.display(), + destination.display() + ) + }); + } + hexwork_content::RuntimePack::load(&private).expect("private canonical pack remains valid"); + harness.content = private; +} + +fn run_entries(harness: &Harness) -> Vec { + let mut entries = fs::read_dir(harness.data.join("runs")) + .expect("managed runs directory") + .map(|entry| { + entry + .expect("managed run entry") + .file_name() + .into_string() + .expect("managed run name is UTF-8") + }) + .collect::>(); + entries.sort(); + entries +} + +fn initialize_baseline(harness: &Harness, seed: u64) -> Baseline { + let script = harness.script("baseline.commands", "save\nquit\n"); + let mut command = harness.managed(&script); + command.arg("--seed").arg(seed.to_string()); + let output = Harness::run(command); + assert!( + output.status.success(), + "baseline stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let records = Harness::records(&output); + let run_id = records + .iter() + .find(|record| field(record, "code") == "allocation-durable") + .and_then(|record| field(record, "run_id").as_str()) + .expect("baseline allocation run ID") + .to_owned(); + let run_path = harness.data.join("runs").join(&run_id).join("run.save"); + let vault_path = harness.data.join("lineage-vault.json"); + Baseline { + run_id, + run_bytes: bounded_bytes(&run_path, MAX_COMPRESSED_BYTES), + vault_bytes: bounded_bytes(&vault_path, MAX_VAULT_BYTES), + run_entries: run_entries(harness), + run_path, + vault_path, + } +} + +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 error code {code}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_no_play(output: &Output) { + let records = Harness::records(output); + assert!( + records.iter().all(|record| { + field(record, "record_kind") != "observation" + && field(record, "record_kind") != "command_ack" + && field(record, "code") != "allocation-durable" + }), + "failure exposed play, executed a command, or allocated a run: {records:?}" + ); +} + +fn assert_baseline_preserved(harness: &Harness, baseline: &Baseline) { + assert_eq!( + bounded_bytes(&baseline.run_path, MAX_COMPRESSED_BYTES), + baseline.run_bytes, + "managed run evidence changed" + ); + assert_eq!( + bounded_bytes(&baseline.vault_path, MAX_VAULT_BYTES), + baseline.vault_bytes, + "vault evidence changed" + ); + assert_eq!(run_entries(harness), baseline.run_entries, "run allocated"); +} + +fn rejected_load(harness: &Harness, script: &Path, run_id: &str) -> std::process::Command { + let mut command = harness.managed(script); + command.arg("--load").arg(run_id); + command +} + +#[test] +fn missing_script_and_replay_are_io_class_before_artifact_root_write() { + let mut harness = Harness::new("negative-missing-inputs"); + use_private_content(&mut harness); + let missing_script = harness.root.join("missing-script.commands"); + let output = Harness::run(harness.managed(&missing_script)); + assert_exit(&output, 4, "script-read"); + assert_no_play(&output); + assert!( + !harness.data.exists(), + "missing script opened artifact root" + ); + + let output = Harness::run({ + let mut command = harness.command(); + command + .arg("--content-root") + .arg(&harness.content) + .arg("--replay") + .arg(harness.root.join("missing-replay.hexdemo")); + command + }); + assert_exit(&output, 4, "replay-read"); + assert_no_play(&output); + assert!( + !harness.data.exists(), + "missing replay opened artifact root" + ); +} + +#[test] +fn sparse_cap_plus_one_script_and_replay_are_limit_class_before_root_write() { + let mut harness = Harness::new("negative-sparse-limits"); + use_private_content(&mut harness); + let script = harness.root.join("script-cap-plus-one.commands"); + File::create(&script) + .expect("create sparse script") + .set_len(SCRIPT_CAP + 1) + .expect("size sparse script"); + let output = Harness::run(harness.managed(&script)); + assert_exit(&output, 5, "script-read"); + assert_no_play(&output); + assert!( + !harness.data.exists(), + "oversize script opened artifact root" + ); + + let replay = harness.root.join("replay-cap-plus-one.hexdemo"); + File::create(&replay) + .expect("create sparse replay") + .set_len(REPLAY_CAP + 1) + .expect("size sparse replay"); + let output = Harness::run({ + let mut command = harness.command(); + command + .arg("--content-root") + .arg(&harness.content) + .arg("--replay") + .arg(&replay); + command + }); + assert_exit(&output, 5, "replay-read"); + assert_no_play(&output); + assert!( + !harness.data.exists(), + "oversize replay opened artifact root" + ); +} + +#[test] +fn malformed_and_path_shaped_load_ids_are_usage_class_without_play() { + let mut harness = Harness::new("negative-load-id"); + use_private_content(&mut harness); + let script = harness.script("must-not-run.commands", REJECTED_SCRIPT); + for value in [ + "run-1", + "../run-00000000000000000001", + "run-00000000000000000001/../../escape", + ] { + let output = Harness::run(rejected_load(&harness, &script, value)); + assert_exit(&output, 2, "invalid --load RUN_ID"); + assert_no_play(&output); + assert!( + !harness.data.exists(), + "invalid load ID {value:?} opened artifact root" + ); + } +} + +#[test] +fn generated_run_seed_and_mode_mismatch_preserve_evidence_and_allocation() { + let mut harness = Harness::new("negative-load-constraints"); + use_private_content(&mut harness); + let baseline = initialize_baseline(&harness, 4_101); + let script = harness.script("constraint-must-not-run.commands", REJECTED_SCRIPT); + + let mut seed = rejected_load(&harness, &script, &baseline.run_id); + seed.arg("--seed").arg("4102"); + let output = Harness::run(seed); + assert_exit(&output, 3, "seed-mismatch"); + assert_no_play(&output); + assert_baseline_preserved(&harness, &baseline); + + let mut mode = rejected_load(&harness, &script, &baseline.run_id); + mode.arg("--story"); + let output = Harness::run(mode); + assert_exit(&output, 3, "mode-mismatch"); + assert_no_play(&output); + assert_baseline_preserved(&harness, &baseline); +} + +#[test] +fn corrupt_managed_run_and_vault_are_retained_without_play() { + let mut harness = Harness::new("negative-corrupt-managed"); + use_private_content(&mut harness); + let baseline = initialize_baseline(&harness, 4_201); + let script = harness.script("corrupt-must-not-run.commands", REJECTED_SCRIPT); + + let corrupt_run = b"phase11 deliberately corrupt run evidence\n"; + fs::write(&baseline.run_path, corrupt_run).expect("install corrupt run"); + let output = Harness::run(rejected_load(&harness, &script, &baseline.run_id)); + assert_exit(&output, 3, "artifact-run"); + assert_no_play(&output); + assert_eq!( + bounded_bytes(&baseline.run_path, MAX_COMPRESSED_BYTES), + corrupt_run, + "failed load rewrote corrupt run evidence" + ); + assert_eq!( + bounded_bytes(&baseline.vault_path, MAX_VAULT_BYTES), + baseline.vault_bytes, + "corrupt run load changed vault" + ); + assert_eq!(run_entries(&harness), baseline.run_entries, "run allocated"); + fs::write(&baseline.run_path, &baseline.run_bytes).expect("restore original run fixture"); + assert_baseline_preserved(&harness, &baseline); + + let corrupt_vault = b"{deliberately-not-json}\n"; + fs::write(&baseline.vault_path, corrupt_vault).expect("install corrupt vault"); + let output = Harness::run(rejected_load(&harness, &script, &baseline.run_id)); + assert_exit(&output, 3, "artifact-schema"); + assert_no_play(&output); + assert_eq!( + bounded_bytes(&baseline.vault_path, MAX_VAULT_BYTES), + corrupt_vault, + "failed load rewrote corrupt vault evidence" + ); + assert_eq!( + bounded_bytes(&baseline.run_path, MAX_COMPRESSED_BYTES), + baseline.run_bytes, + "corrupt vault load changed run" + ); + assert_eq!(run_entries(&harness), baseline.run_entries, "run allocated"); + fs::write(&baseline.vault_path, &baseline.vault_bytes).expect("restore original vault fixture"); + assert_baseline_preserved(&harness, &baseline); +} + +#[test] +fn genuine_v3_run_replay_and_v1_vault_reject_as_old_without_play() { + let mut harness = Harness::new("negative-old-production-formats"); + use_private_content(&mut harness); + let baseline = initialize_baseline(&harness, 4_301); + let script = harness.script("old-must-not-run.commands", REJECTED_SCRIPT); + + fs::write(&baseline.run_path, GENUINE_V3_RUN).expect("install genuine v3 run"); + let output = Harness::run(rejected_load(&harness, &script, &baseline.run_id)); + assert_exit(&output, 3, "artifact-run"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("older than supported version 4"), + "v3 run rejection was not explicit: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_no_play(&output); + assert_eq!( + bounded_bytes(&baseline.run_path, MAX_COMPRESSED_BYTES), + GENUINE_V3_RUN, + "failed load rewrote genuine v3 run" + ); + assert_eq!(run_entries(&harness), baseline.run_entries, "run allocated"); + fs::write(&baseline.run_path, &baseline.run_bytes).expect("restore current run fixture"); + assert_baseline_preserved(&harness, &baseline); + + let replay_path = harness.root.join("genuine-v3.hexdemo"); + fs::write(&replay_path, GENUINE_V3_REPLAY).expect("install genuine v3 replay"); + let output = Harness::run({ + let mut command = harness.command(); + command + .arg("--content-root") + .arg(&harness.content) + .arg("--replay") + .arg(&replay_path); + command + }); + assert_exit(&output, 3, "replay-decode"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("older than supported version 4"), + "v3 replay rejection was not explicit: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_no_play(&output); + assert_eq!( + bounded_bytes(&replay_path, MAX_COMPRESSED_BYTES), + GENUINE_V3_REPLAY, + "failed replay rewrote genuine v3 evidence" + ); + assert_baseline_preserved(&harness, &baseline); + + fs::write(&baseline.vault_path, OLD_V1_VAULT).expect("install historical v1 vault"); + let output = Harness::run(rejected_load(&harness, &script, &baseline.run_id)); + assert_exit(&output, 3, "artifact-schema"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("older than supported version 2"), + "v1 vault rejection was not explicit: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_no_play(&output); + assert_eq!( + bounded_bytes(&baseline.vault_path, MAX_VAULT_BYTES), + OLD_V1_VAULT, + "failed load rewrote historical v1 vault" + ); + assert_eq!(run_entries(&harness), baseline.run_entries, "run allocated"); + fs::write(&baseline.vault_path, &baseline.vault_bytes).expect("restore current vault fixture"); + assert_baseline_preserved(&harness, &baseline); +} + +#[test] +fn valid_changed_content_hash_rejects_generated_run_and_preserves_sources() { + let mut harness = Harness::new("negative-content-hash"); + use_private_content(&mut harness); + let baseline = initialize_baseline(&harness, 4_401); + let script = harness.script("content-must-not-run.commands", REJECTED_SCRIPT); + let changed_root = harness.root.join("changed-content"); + let manifest = hexwork_content::manifest(); + let source_items = harness.content.join(manifest[0]); + let source_items_before = bounded_bytes(&source_items, MAX_VAULT_BYTES); + + for relative in &manifest { + let source = harness.content.join(relative); + let destination = changed_root.join(relative); + fs::create_dir_all(destination.parent().expect("manifest parent")) + .expect("create copied content parent"); + fs::copy(&source, &destination).unwrap_or_else(|error| { + panic!( + "copy canonical content {} to {}: {error}", + source.display(), + destination.display() + ) + }); + } + let changed_items = changed_root.join(manifest[0]); + OpenOptions::new() + .append(true) + .open(&changed_items) + .expect("open copied items") + .write_all(b"\n# phase11 valid content-hash change\n") + .expect("append valid TOML comment"); + hexwork_content::RuntimePack::load(&changed_root).expect("changed copied pack remains valid"); + + let mut command = rejected_load(&harness, &script, &baseline.run_id); + command.args(["--content-root"]).arg(&changed_root); + let output = Harness::run(command); + assert_exit(&output, 3, "artifact-run"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("content hash mismatch"), + "changed pack did not reach hash rejection: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_no_play(&output); + assert_baseline_preserved(&harness, &baseline); + assert_eq!( + bounded_bytes(&source_items, MAX_VAULT_BYTES), + source_items_before, + "canonical source content changed" + ); +} diff --git a/crates/hexwork-cli/tests/phase11/recovery.rs b/crates/hexwork-cli/tests/phase11/recovery.rs new file mode 100644 index 0000000..b154bac --- /dev/null +++ b/crates/hexwork-cli/tests/phase11/recovery.rs @@ -0,0 +1,729 @@ +//! Actual-process recovery and Inn autosave evidence. +//! +//! These tests deliberately exercise deterministic filesystem conflicts, not +//! crashes or claims about an exact interruption between durable operations. +//! The parent integration test registers this file as a nested module, so all +//! processes use the accepted bounded harness and the production binary. + +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use super::{ + field, + support::{grate_mother_death_script, Harness}, +}; +use hexwork_core::{ + action::CommittedAction, + character::{kit, Calling}, + runtime::ActorRole, + services::{self, Service}, +}; +use hexwork_save::artifact::MAX_EPITAPH_BYTES; +use hexwork_save::{ + decode_run, decode_vault, read_file_bounded, RunId, SessionEventKind, MAX_COMPRESSED_BYTES, + MAX_MARKER_BYTES, MAX_VAULT_BYTES, +}; +use hexwork_worldgen::{Cell, InteractionKind, VoxelWrite, WorldPlan}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const SMALL_EVIDENCE_CAP: usize = 64 * 1024; +const CONFLICTING_REPLAY: &[u8] = b"phase11 deterministic replay collision\n"; + +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 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 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 error code {code}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_no_play(output: &Output) { + let records = Harness::records(output); + assert!( + records.iter().all(|record| { + field(record, "record_kind") != "observation" + && field(record, "record_kind") != "command_ack" + && field(record, "code") != "allocation-durable" + }), + "startup/recovery refusal exposed play or allocated a run: {records:?}" + ); +} + +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 run_entries(harness: &Harness) -> Vec { + let mut entries = fs::read_dir(harness.data.join("runs")) + .expect("managed runs directory") + .map(|entry| { + entry + .expect("managed run entry") + .file_name() + .into_string() + .expect("managed run name is UTF-8") + }) + .collect::>(); + entries.sort(); + entries +} + +fn initialize_living(harness: &Harness, seed: u64, name: &str) -> (String, PathBuf) { + let script = harness.script(name, "save\nquit\n"); + let mut command = harness.managed(&script); + command.arg("--seed").arg(seed.to_string()); + let output = Harness::run(command); + assert!( + output.status.success(), + "living initialization stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let run_id = allocation_run_id(&output); + let run_dir = harness.data.join("runs").join(&run_id); + assert!(run_dir.join("run.save").is_file()); + (run_id, run_dir) +} + +#[test] +fn startup_destination_type_collisions_preserve_obstacles_then_fresh_start_succeeds() { + // `runs` is a required directory. A regular file there is classified and + // rejected during ArtifactStore::open, before recovery, allocation, or play. + let runs_harness = Harness::new("recovery-startup-runs-file"); + fs::create_dir_all(&runs_harness.data).expect("create private artifact root"); + let runs_obstacle = runs_harness.data.join("runs"); + let runs_bytes = b"not a managed runs directory\n"; + fs::write(&runs_obstacle, runs_bytes).expect("install runs obstacle"); + let quit = runs_harness.script("quit.commands", "quit\n"); + let refused = Harness::run(runs_harness.managed(&quit)); + assert_exit(&refused, 3, "managed-type-conflict"); + assert_no_play(&refused); + assert_eq!(bounded(&runs_obstacle, SMALL_EVIDENCE_CAP), runs_bytes); + + fs::remove_file(&runs_obstacle).expect("remove only owned runs obstacle"); + let fresh = Harness::run(runs_harness.managed(&quit)); + assert!( + fresh.status.success(), + "fresh startup after runs cleanup: {}", + String::from_utf8_lossy(&fresh.stderr) + ); + assert_eq!( + allocation_run_id(&fresh) + .parse::() + .unwrap() + .sequence(), + 1 + ); + + // The lineage vault is a required regular file. A directory at its exact + // destination is likewise a startup type conflict, not a death-window case. + let vault_harness = Harness::new("recovery-startup-vault-directory"); + fs::create_dir_all(&vault_harness.data).expect("create private artifact root"); + let vault_obstacle = vault_harness.data.join("lineage-vault.json"); + fs::create_dir(&vault_obstacle).expect("install vault directory obstacle"); + let quit = vault_harness.script("quit.commands", "quit\n"); + let refused = Harness::run(vault_harness.managed(&quit)); + assert_exit(&refused, 3, "managed-type-conflict"); + assert_no_play(&refused); + assert!(vault_obstacle.is_dir(), "vault obstacle was not preserved"); + assert_eq!( + fs::read_dir(&vault_obstacle) + .expect("read preserved obstacle") + .count(), + 0 + ); + + fs::remove_dir(&vault_obstacle).expect("remove only owned vault obstacle"); + let fresh = Harness::run(vault_harness.managed(&quit)); + assert!( + fresh.status.success(), + "fresh startup after vault cleanup: {}", + String::from_utf8_lossy(&fresh.stderr) + ); + assert_eq!( + allocation_run_id(&fresh) + .parse::() + .unwrap() + .sequence(), + 1 + ); +} + +#[test] +fn future_sequence_directory_refuses_without_consuming_then_sequence_two_is_allocated() { + let harness = Harness::new("recovery-sequence-collision"); + let (run1, run1_dir) = initialize_living(&harness, 701, "run1.commands"); + assert_eq!(run1.parse::().unwrap().sequence(), 1); + + let vault_path = harness.data.join("lineage-vault.json"); + let run1_path = run1_dir.join("run.save"); + let vault_before = bounded(&vault_path, MAX_VAULT_BYTES); + let run1_before = bounded(&run1_path, MAX_COMPRESSED_BYTES); + let decoded_vault = decode_vault(&vault_before).expect("decode run-1 vault"); + assert_eq!(decoded_vault.next_run_sequence, 2); + assert!(decoded_vault.applied_deaths.is_empty()); + + let future_run_id = RunId::new(decoded_vault.next_run_sequence) + .expect("nonzero next run sequence") + .to_string(); + let future = harness.data.join("runs").join(&future_run_id); + fs::create_dir(&future).expect("install empty future sequence obstacle"); + let entries_with_obstacle = run_entries(&harness); + assert_eq!(entries_with_obstacle, vec![run1.clone(), future_run_id]); + + let must_not_run = harness.script("must-not-run.commands", "observe state\nquit\n"); + let mut new_command = harness.managed(&must_not_run); + new_command.arg("--seed").arg("702"); + for (label, output) in [ + ("new", Harness::run(new_command)), + ("recover", Harness::run(recover_command(&harness))), + ] { + assert_exit(&output, 3, "recovery-conflict"); + assert_no_play(&output); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("managed run directory collides with/rolls back next sequence"), + "{label} refusal was not the sequence collision: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(bounded(&vault_path, MAX_VAULT_BYTES), vault_before); + assert_eq!(bounded(&run1_path, MAX_COMPRESSED_BYTES), run1_before); + assert_eq!(run_entries(&harness), entries_with_obstacle); + assert!(future.is_dir(), "{label} did not preserve empty obstacle"); + } + + fs::remove_dir(&future).expect("remove only empty future obstacle"); + let run2_script = harness.script("run2.commands", "quit\n"); + let mut run2_command = harness.managed(&run2_script); + run2_command.arg("--seed").arg("702"); + let run2_output = Harness::run(run2_command); + assert!( + run2_output.status.success(), + "run-2 allocation stderr: {}", + String::from_utf8_lossy(&run2_output.stderr) + ); + let run2 = allocation_run_id(&run2_output); + assert_eq!( + run2, "run-00000000000000000002", + "the refused attempt must not skip sequence 2" + ); + + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + let run2_bytes = bounded( + harness.data.join("runs").join(&run2).join("run.save"), + MAX_COMPRESSED_BYTES, + ); + let decoded_run2 = decode_run(&run2_bytes, pack.hash()).expect("decode actual run-2 save"); + assert_eq!(decoded_run2.identity.run_id.sequence(), 2); + assert_eq!(decoded_run2.identity.seed, 702); + assert_eq!(bounded(&run1_path, MAX_COMPRESSED_BYTES), run1_before); + + let vault_after = decode_vault(&bounded(&vault_path, MAX_VAULT_BYTES)).unwrap(); + assert_eq!(vault_after.lineage_id, decoded_vault.lineage_id); + assert_eq!(vault_after.next_run_sequence, 3); + assert!( + vault_after.applied_deaths.is_empty(), + "ordinary allocation creates no death receipt" + ); + assert_eq!( + run_entries(&harness), + vec!["run-00000000000000000001", "run-00000000000000000002"] + ); +} + +#[test] +fn regular_replay_collision_retains_terminal_transaction_until_obstacle_removed() { + let harness = Harness::new("recovery-replay-conflict"); + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + let (run_id, run_dir) = initialize_living(&harness, 223, "living.commands"); + let parsed_run_id = run_id.parse::().unwrap(); + let vault_path = harness.data.join("lineage-vault.json"); + 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 run_path = run_dir.join("run.save"); + let living_vault = bounded(&vault_path, MAX_VAULT_BYTES); + + fs::write(&replay_path, CONFLICTING_REPLAY).expect("install regular replay obstacle"); + assert_eq!( + bounded(&replay_path, SMALL_EVIDENCE_CAP), + CONFLICTING_REPLAY + ); + + // Because the snapshot is living at startup, recovery deliberately skips + // archive verification. Normal inputs then cause a real death. The terminal + // snapshot and pending marker become durable before archive publication + // discovers that the regular replay destination contains different bytes. + let route = harness.script("lethal.commands", &grate_mother_death_script(223)); + let mut lethal = harness.managed(&route); + lethal.arg("--load").arg(&run_id); + let failed = Harness::run(lethal); + assert_exit(&failed, 3, "immutable-artifact-mismatch"); + let failed_records = Harness::records(&failed); + let codes = failed_records + .iter() + .filter_map(|record| field(record, "code").as_str()) + .collect::>(); + let terminal = codes + .iter() + .position(|code| *code == "terminal-snapshot-durable") + .expect("actual terminal snapshot milestone"); + let pending = codes + .iter() + .position(|code| *code == "pending-death-durable") + .expect("actual pending-death milestone"); + assert!(terminal < pending); + for forbidden in [ + "replay-durable", + "epitaph-durable", + "vault-death-durable", + "committed-death-durable", + "run-retired-durable", + "pending-retired-durable", + ] { + assert!( + !codes.contains(&forbidden), + "unexpected {forbidden}: {codes:?}" + ); + } + assert!( + failed_records.iter().any(|record| { + field(record, "record_kind") == "command_ack" && field(record, "code") == "input" + }), + "the actual ordinary route did not execute before the in-process conflict" + ); + assert!( + run_path.is_file(), + "terminal snapshot must remain authoritative" + ); + assert!( + pending_path.is_file(), + "pending marker must remain for recovery" + ); + assert!(!committed_path.exists(), "death was not committed"); + assert!( + !epitaph_path.exists(), + "replay conflict precedes epitaph publication" + ); + assert_eq!( + bounded(&replay_path, SMALL_EVIDENCE_CAP), + CONFLICTING_REPLAY + ); + assert_eq!(bounded(&vault_path, MAX_VAULT_BYTES), living_vault); + assert!( + !decode_vault(&living_vault) + .unwrap() + .applied_deaths + .contains_key(&parsed_run_id), + "failed transaction must have no receipt" + ); + + let terminal_bytes = bounded(&run_path, MAX_COMPRESSED_BYTES); + let pending_bytes = bounded(&pending_path, MAX_MARKER_BYTES); + let evidence = || { + ( + bounded(&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 = ( + terminal_bytes.clone(), + pending_bytes.clone(), + CONFLICTING_REPLAY.to_vec(), + living_vault.clone(), + ); + + let rejected_script = harness.script("rejected-load.commands", "observe state\nquit\n"); + let mut rejected_load = harness.managed(&rejected_script); + rejected_load.arg("--load").arg(&run_id); + for (label, output) in [ + ("recover", Harness::run(recover_command(&harness))), + ("load", Harness::run(rejected_load)), + ] { + assert_exit(&output, 3, "immutable-artifact-mismatch"); + assert_no_play(&output); + assert_eq!(evidence(), retained, "{label} changed retained evidence"); + assert!(!committed_path.exists()); + assert!(!epitaph_path.exists()); + } + + fs::remove_file(&replay_path).expect("remove only owned replay obstacle"); + let recovered = Harness::run(recover_command(&harness)); + assert!( + recovered.status.success(), + "recovery after obstacle cleanup: {}", + String::from_utf8_lossy(&recovered.stderr) + ); + let recovery_codes = Harness::records(&recovered) + .iter() + .filter_map(|record| field(record, "code").as_str().map(str::to_owned)) + .collect::>(); + let recovery_positions = [ + "replay-durable", + "epitaph-durable", + "vault-death-durable", + "committed-death-durable", + "run-retired-durable", + "pending-retired-durable", + ] + .map(|wanted| { + recovery_codes + .iter() + .position(|code| code == wanted) + .unwrap_or_else(|| panic!("missing {wanted}: {recovery_codes:?}")) + }); + assert!(recovery_positions.windows(2).all(|pair| pair[0] < pair[1])); + assert!(!run_path.exists()); + assert!(!pending_path.exists()); + assert!(replay_path.is_file()); + assert!(epitaph_path.is_file()); + assert!(committed_path.is_file()); + let completed_vault = decode_vault(&bounded(&vault_path, MAX_VAULT_BYTES)).unwrap(); + assert!(completed_vault.applied_deaths.contains_key(&parsed_run_id)); + + 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 mut retired_load = harness.managed(&rejected_script); + retired_load.arg("--load").arg(&run_id); + let retired = Harness::run(retired_load); + assert_exit(&retired, 3, "recovery-conflict"); + assert_no_play(&retired); + assert_eq!(stable_completed(), completed); + + let repeated = Harness::run(recover_command(&harness)); + assert!( + repeated.status.success(), + "idempotent repeated recovery: {}", + String::from_utf8_lossy(&repeated.stderr) + ); + let repeated_records = Harness::records(&repeated); + let repeated_codes = repeated_records + .iter() + .filter_map(|record| field(record, "code").as_str()) + .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)), + "repeated recovery duplicated effects: {repeated_codes:?}" + ); + assert_eq!(stable_completed(), completed); + + // The final archive is genuine production output, not the obstacle bytes. + let replay = hexwork_save::decode_replay(&completed.1, pack.hash()).unwrap(); + assert_eq!(replay.identity.run_id, parsed_run_id); +} + +#[test] +fn inn_sleep_uses_normal_inputs_charges_wallet_and_autosaves_once() { + let harness = Harness::new("recovery-inn-autosave"); + let seed = 811; + let plan = hexwork_worldgen::milthollow(seed); + let inn = plan + .interactions + .iter() + .find(|interaction| matches!(interaction.kind, InteractionKind::Inn)) + .expect("generated Milthollow Inn interaction"); + let mut commands = route_commands(&plan, plan.start, inn.access); + commands.push_str("input confirm\n"); + // These observations and quit all happen at the post-sleep tick. They prove + // that merely revisiting the cadence boundary does not publish again. + commands.push_str("observe state\nobserve inventory\nobserve knowledge\nquit\n"); + assert!( + !commands.contains("advance "), + "Inn proof must not use raw advance" + ); + // Harness::script adds the header, while parse_script omits it and empty + // space-only lines before entry::run_headless enumerates command indices. + let command_lines = commands + .lines() + .filter(|line| line.split(' ').any(|word| !word.is_empty())) + .collect::>(); + let confirm_index = command_lines + .iter() + .position(|line| *line == "input confirm") + .expect("Inn confirm command index"); + let quit_index = command_lines + .iter() + .position(|line| *line == "quit") + .expect("quit command index"); + assert_eq!( + command_lines + .iter() + .filter(|line| **line == "input confirm") + .count(), + 1 + ); + assert_eq!( + command_lines.iter().filter(|line| **line == "quit").count(), + 1 + ); + assert!(confirm_index < quit_index); + + let script = harness.script("inn.commands", &commands); + let mut command = harness.managed(&script); + command.arg("--seed").arg(seed.to_string()); + let output = Harness::run(command); + assert!( + output.status.success(), + "Inn autosave stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let records = Harness::records(&output); + let saves = records + .iter() + .filter(|record| field(record, "code") == "run-snapshot-durable") + .collect::>(); + let sleep_ticks = services::sleep_ticks(); + assert_eq!( + sleep_ticks, 8_640, + "test derives the production service interval" + ); + assert_eq!( + saves.len(), + 2, + "initial publication plus exactly one autosave" + ); + assert_eq!(field(saves[0], "tick").as_u64(), Some(0)); + assert_eq!(field(saves[1], "tick").as_u64(), Some(sleep_ticks)); + assert_eq!( + field(saves[1], "command_index").as_u64(), + Some(confirm_index as u64), + "tick-8640 snapshot must originate at the Inn confirm boundary" + ); + assert_ne!( + field(saves[1], "command_index").as_u64(), + Some(quit_index as u64), + "quit must not be the origin of the tick-8640 snapshot" + ); + assert_eq!( + saves + .iter() + .filter(|record| field(record, "tick").as_u64() == Some(sleep_ticks)) + .count(), + 1, + "same-tick observations and quit must not repeat the autosave" + ); + assert!( + saves + .iter() + .all(|record| { field(record, "command_index").as_u64() != Some(quit_index as u64) }), + "quit must not publish a run snapshot" + ); + let autosave_position = records + .iter() + .position(|record| std::ptr::eq(record, saves[1])) + .expect("tick-8640 snapshot record position"); + let confirm_ack_position = records + .iter() + .position(|record| { + field(record, "record_kind") == "command_ack" + && field(record, "code") == "input" + && field(record, "command_index").as_u64() == Some(confirm_index as u64) + && field(record, "tick").as_u64() == Some(sleep_ticks) + }) + .expect("Inn confirm acknowledgement position"); + assert!( + autosave_position < confirm_ack_position, + "Inn autosave milestone must precede its corresponding command acknowledgement" + ); + let quit_clean_positions = records + .iter() + .enumerate() + .filter(|(_, record)| { + field(record, "code") == "save-clean" + && field(record, "command_index").as_u64() == Some(quit_index as u64) + && field(record, "tick").as_u64() == Some(sleep_ticks) + }) + .map(|(position, _)| position) + .collect::>(); + assert_eq!( + quit_clean_positions.len(), + 1, + "quit must observe the Inn autosave as clean" + ); + let quit_ack_position = records + .iter() + .position(|record| { + field(record, "record_kind") == "command_ack" + && field(record, "code") == "quit" + && field(record, "command_index").as_u64() == Some(quit_index as u64) + && field(record, "tick").as_u64() == Some(sleep_ticks) + }) + .expect("quit acknowledgement position"); + assert!(quit_clean_positions[0] < quit_ack_position); + + let run_id = allocation_run_id(&output); + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + let run_bytes = bounded( + harness.data.join("runs").join(&run_id).join("run.save"), + MAX_COMPRESSED_BYTES, + ); + let saved = decode_run(&run_bytes, pack.hash()).expect("decode durable Inn save"); + assert_eq!(saved.current.tick, sleep_ticks); + assert_eq!(saved.identity.calling, Calling::Lampbearer); + + let player = saved + .current + .actors + .iter() + .find(|actor| { + saved + .current + .identity(actor.id) + .is_some_and(|identity| identity.role == ActorRole::Player) + }) + .expect("one saved Player") + .id; + let stipend = kit(Calling::Lampbearer).stipend_cu; + let price = services::price(Service::Inn).copper; + assert_eq!(stipend, 25); + assert_eq!(price, 8); + assert_eq!( + saved + .current + .wallet(player) + .expect("saved Player wallet") + .copper(), + stipend - price + ); + + let inn_events = saved + .events + .iter() + .filter(|event| { + matches!( + &event.kind, + SessionEventKind::Action(CommittedAction::UseService { service }) + if *service == Service::Inn + ) + }) + .collect::>(); + assert_eq!( + inn_events.len(), + 1, + "one ordinary confirm used the Inn service" + ); + assert_eq!( + inn_events[0].post_tick - inn_events[0].pre_tick, + sleep_ticks + ); + assert!( + records.iter().any(|record| { + field(record, "record_kind") == "command_ack" + && field(record, "code") == "input" + && field(record, "tick").as_u64() == Some(sleep_ticks) + }), + "the ordinary confirm acknowledgement must expose the post-sleep tick" + ); +} + +fn route_commands(plan: &WorldPlan, start: Cell, goal: Cell) -> String { + if start == goal { + return String::new(); + } + let blockers = plan + .actors + .iter() + .map(|actor| actor.position) + .collect::>(); + let mut queue = VecDeque::from([start]); + let mut previous = BTreeMap::::new(); + let mut seen = BTreeSet::from([start]); + while let Some(cell) = queue.pop_front() { + for (dx, dy, input) in [ + (0, 1, "north"), + (-1, 0, "west"), + (1, 0, "east"), + (0, -1, "south"), + ] { + let next = [cell[0] + dx, cell[1] + dy, cell[2]]; + if seen.contains(&next) || blockers.contains(&next) || !passable(&plan.writes, next) { + continue; + } + previous.insert(next, (cell, input)); + if next == goal { + let mut reversed = Vec::new(); + let mut cursor = goal; + while cursor != start { + let (prior, direction) = previous[&cursor]; + reversed.push(direction); + cursor = prior; + } + let mut commands = String::new(); + for direction in reversed.into_iter().rev() { + commands.push_str("input "); + commands.push_str(direction); + commands.push('\n'); + } + return commands; + } + seen.insert(next); + queue.push_back(next); + } + } + panic!("no deterministic immutable-plan route from {start:?} to Inn access {goal:?}"); +} + +fn passable(writes: &[VoxelWrite], cell: Cell) -> bool { + let voxel = |at: Cell| { + writes + .binary_search_by_key(&(at[2], at[1], at[0]), |write| { + (write.cell[2], write.cell[1], write.cell[0]) + }) + .ok() + .map(|index| writes[index].voxel) + .unwrap_or_default() + }; + let body = voxel(cell); + let head = voxel([cell[0], cell[1], cell[2] + 1]); + let below = voxel([cell[0], cell[1], cell[2] - 1]); + body.occ == 0 && head.occ == 0 && below.occ != 0 && body.fluid_amt <= 2 +} diff --git a/crates/hexwork-cli/tests/phase11_gate.rs b/crates/hexwork-cli/tests/phase11_gate.rs index d9122f1..db98a66 100644 --- a/crates/hexwork-cli/tests/phase11_gate.rs +++ b/crates/hexwork-cli/tests/phase11_gate.rs @@ -1,15 +1,21 @@ //! Stage3c2 production-process gates. Historical flat-vault and actorless //! component replay tests were removed because they are not production evidence. +#[path = "phase11/negative.rs"] +mod negative; +#[path = "phase11/recovery.rs"] +mod recovery; mod support; use hexwork_core::action::CommittedAction; use hexwork_core::character::{Ancestry, Calling}; use hexwork_core::modes::Mode; +use hexwork_core::runtime::ActorRole; use hexwork_save::artifact::ArtifactStore; use hexwork_save::{ - decode_vault, encode_replay, read_file_bounded, InitialLineage, ProductionReplay, RunId, - RunIdentity, SessionEvent, MAX_VAULT_BYTES, + decode_replay, decode_run, decode_vault, encode_replay, encode_vault, read_file_bounded, + InitialLineage, ProductionReplay, RunId, RunIdentity, SessionEvent, MAX_COMPRESSED_BYTES, + MAX_VAULT_BYTES, }; use support::{ first_two_deep_arrivals, grate_mother_death_script, @@ -163,18 +169,31 @@ fn binary_new_save_exit_load_preserves_hash_and_history() { #[test] fn classic_real_death_commits_demo_vault_receipt_before_retirement() { let harness = Harness::new("classic-death"); - let body = grate_mother_death_script(223); - let script = harness.script("death.commands", &body); - let mut command = harness.managed(&script); - command.arg("--seed").arg("223"); - let output = Harness::run(command); + let pack = hexwork_content::RuntimePack::load(&harness.content).unwrap(); + let vault_path = harness.data.join("lineage-vault.json"); + + // The generated route ends in four normal reciprocal-melee confirms. Split at + // its final command instead of assuming an opaque command index: the prefix + // process saves the authoritative pack immediately before the known lethal + // fourth exchange, then the loaded process supplies exactly that omitted input. + let mut prefix = grate_mother_death_script(223); assert!( - output.status.success(), - "death stderr: {}", - String::from_utf8_lossy(&output.stderr) + prefix.ends_with("input confirm\n"), + "death route must end at the verified lethal confirm" ); - let records = Harness::records(&output); - let transition_states = records + prefix.truncate(prefix.len() - "input confirm\n".len()); + prefix.push_str("save\nquit\n"); + let prefix_script = harness.script("prelethal.commands", &prefix); + let mut prefix_command = harness.managed(&prefix_script); + prefix_command.arg("--seed").arg("223"); + let prefix_output = Harness::run(prefix_command); + assert!( + prefix_output.status.success(), + "prelethal stderr: {}", + String::from_utf8_lossy(&prefix_output.stderr) + ); + let prefix_records = Harness::records(&prefix_output); + let transition_states = prefix_records .iter() .filter(|record| field(record, "record_kind") == "observation") .collect::>(); @@ -195,12 +214,49 @@ fn classic_real_death_commits_demo_vault_receipt_before_retirement() { field(record, "detail") ); } - let run_id = records + let run_id = prefix_records .iter() .find(|record| field(record, "code") == "allocation-durable") .and_then(|record| field(record, "run_id").as_str()) .unwrap() .to_owned(); + let parsed_run_id = run_id.parse::().unwrap(); + let run_dir = harness.data.join("runs").join(&run_id); + let prelethal_bytes = + read_file_bounded(run_dir.join("run.save"), MAX_COMPRESSED_BYTES).unwrap(); + let prelethal = decode_run(&prelethal_bytes, pack.hash()).unwrap(); + assert_eq!(prelethal.identity.run_id, parsed_run_id); + let player_id = prelethal + .current + .actors + .iter() + .find(|actor| { + prelethal + .current + .identity(actor.id) + .is_some_and(|identity| identity.role == ActorRole::Player) + }) + .expect("one prelethal Player") + .id; + let prelethal_pack = prelethal + .current + .packs + .get(&player_id) + .expect("prelethal authoritative Player pack") + .clone(); + assert!(!prelethal_pack.items.is_empty()); + let prelethal_events = prelethal.events.len(); + + let lethal_script = harness.script("lethal.commands", "input confirm\n"); + let mut lethal_command = harness.managed(&lethal_script); + lethal_command.arg("--load").arg(&run_id); + let output = Harness::run(lethal_command); + assert!( + output.status.success(), + "death stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let records = Harness::records(&output); let codes = records .iter() .filter_map(|record| field(record, "code").as_str()) @@ -222,23 +278,259 @@ fn classic_real_death_commits_demo_vault_receipt_before_retirement() { .unwrap_or_else(|| panic!("missing {code}: {codes:?}")) }); assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); - let run_dir = harness.data.join("runs").join(&run_id); assert!(!run_dir.join("run.save").exists()); assert!(!run_dir.join("death.pending").exists()); assert!(run_dir.join("death.committed").is_file()); - assert!(run_dir.join("replay.hexdemo").is_file()); - assert!(run_dir.join("epitaph.txt").is_file()); - let vault = decode_vault( - &read_file_bounded(harness.data.join("lineage-vault.json"), MAX_VAULT_BYTES).unwrap(), - ) - .unwrap(); - assert!(vault - .applied_deaths - .contains_key(&run_id.parse::().unwrap())); + let replay_path = run_dir.join("replay.hexdemo"); + let committed_path = run_dir.join("death.committed"); + let epitaph_path = run_dir.join("epitaph.txt"); + assert!(replay_path.is_file()); + assert!(epitaph_path.is_file()); + let replay_bytes = read_file_bounded(&replay_path, MAX_COMPRESSED_BYTES).unwrap(); + let replay = decode_replay(&replay_bytes, pack.hash()).unwrap(); + assert_eq!(replay.identity.run_id, parsed_run_id); + assert_eq!(replay.events.len(), prelethal_events + 1); + assert_eq!( + &replay.events[..prelethal_events], + prelethal.events.as_slice(), + "death archive must retain the complete prelethal history" + ); + + let vault_bytes = read_file_bounded(&vault_path, MAX_VAULT_BYTES).unwrap(); + let vault = decode_vault(&vault_bytes).unwrap(); + assert!(vault.applied_deaths.contains_key(&parsed_run_id)); + let prelethal_knowledge = prelethal.current.knowledge(); + assert_eq!( + vault.current.mapped_regions, + prelethal_knowledge + .mapped_regions + .iter() + .cloned() + .collect::>() + ); + assert_eq!( + vault.current.recipes, + prelethal_knowledge + .recipes + .iter() + .cloned() + .collect::>() + ); + assert_eq!( + vault.current.translated_hymns, + prelethal_knowledge + .translated_hymns + .iter() + .cloned() + .collect::>() + ); + assert_eq!( + vault.current.identified_item_templates, + prelethal + .current + .known_templates() + .map(str::to_owned) + .collect::>() + ); + assert!( + vault.current.recipes.is_empty(), + "normal route learns no recipe" + ); + assert!( + vault.current.translated_hymns.is_empty(), + "normal route translates no hymn" + ); + assert!( + vault.current.identified_item_templates.is_empty(), + "normal route performs no identification action" + ); + + // Core reciprocal melee mutates actors/causes and creates only the synthetic + // corpse; it does not transform the Player's pre-existing Pack. Production + // death extraction excludes template `corpse`, so every resulting lot must + // exactly project one item from this actual prelethal managed save. + let eligible = prelethal_pack + .items + .values() + .filter(|item| item.template != "corpse") + .collect::>(); + let death_lots = vault + .current + .auction_lots + .iter() + .filter(|lot| lot.origin_run_id == parsed_run_id) + .collect::>(); + assert_eq!(death_lots.len(), eligible.len()); + for (lot, item) in death_lots.iter().zip(&eligible) { + assert_eq!(lot.lot_id.origin_run_id(), parsed_run_id); + assert_eq!(lot.lot_id.origin_item_id(), item.id); + assert_eq!(lot.origin_run_id, parsed_run_id); + assert_eq!(lot.origin_item_id, item.id); + assert_eq!(lot.template_id, item.template); + assert_eq!(lot.material, item.material); + assert_eq!(lot.identified, item.identified); + assert_eq!(lot.cursed, item.cursed); + assert_eq!(lot.blessed, item.blessed); + assert_eq!(lot.condition, item.condition); + assert_eq!(lot.silvered, item.silvered); + assert_eq!(lot.illegal, item.illegal); + assert_eq!(lot.tags, item.tags); + assert_eq!(lot.fuel, item.fuel); + assert_eq!(lot.wet_ticks, item.wet_ticks); + let base = pack.item(&item.template).unwrap().base_price_cu; + assert_eq!(lot.base_price_cu, base); + assert_eq!( + lot.buyback_price_cu, + hexwork_core::services::auction_buyback(base) + ); + } + assert!(death_lots.iter().all(|lot| lot.template_id != "corpse")); + let lowest = eligible.first().expect("eligible lowest-ID item"); + let ghost = vault + .current + .ghosts + .iter() + .find(|ghost| ghost.origin_run_id == parsed_run_id) + .expect("death ghost"); + assert_eq!(ghost.item_template_id, lowest.template); + let epitaph = String::from_utf8(std::fs::read(&epitaph_path).unwrap()).unwrap(); + let causal_warning = epitaph + .lines() + .rev() + .find_map(|line| { + let (_, cause) = line.split_once(' ')?; + cause.starts_with("death:").then(|| cause.to_owned()) + }) + .or_else(|| { + epitaph + .lines() + .rev() + .find_map(|line| line.split_once(' ').map(|(_, cause)| cause.to_owned())) + }) + .expect("terminal epitaph cause"); + assert_eq!(ghost.warning, causal_warning); + + let committed_bytes = std::fs::read(&committed_path).unwrap(); + let epitaph_bytes = std::fs::read(&epitaph_path).unwrap(); + let stable_artifacts = || { + ( + std::fs::read(&vault_path).unwrap(), + std::fs::read(&committed_path).unwrap(), + std::fs::read(&replay_path).unwrap(), + std::fs::read(&epitaph_path).unwrap(), + ) + }; + let committed_snapshot = stable_artifacts(); + + let refused_script = harness.script("retired-load.commands", "observe state\n"); + let mut refused_command = harness.managed(&refused_script); + refused_command.arg("--load").arg(&run_id); + let refused = Harness::run(refused_command); + assert_eq!(refused.status.code(), Some(3)); + assert!( + Harness::records(&refused).iter().all(|record| { + field(record, "record_kind") != "observation" + && field(record, "record_kind") != "action" + }), + "retired load must not play or observe" + ); + assert!( + String::from_utf8_lossy(&refused.stderr).contains(&format!( + "recovery-conflict: {run_id}: death marker blocks living operation" + )), + "retired stderr: {}", + String::from_utf8_lossy(&refused.stderr) + ); + assert_eq!(stable_artifacts(), committed_snapshot); + + for attempt in 0..2 { + let recovered = Harness::run({ + let mut recover = harness.command(); + recover + .arg("--data-dir") + .arg(&harness.data) + .arg("--content-root") + .arg(&harness.content) + .arg("--recover"); + recover + }); + assert!( + recovered.status.success(), + "recover {attempt} stderr: {}", + String::from_utf8_lossy(&recovered.stderr) + ); + let recovery_records = Harness::records(&recovered); + assert!(recovery_records + .iter() + .any(|record| field(record, "code") == "recovery-complete")); + assert!(recovery_records.iter().all(|record| { + !matches!( + field(record, "code").as_str(), + Some("vault-death-durable") + | Some("committed-death-durable") + | Some("replay-durable") + | Some("epitaph-durable") + ) + })); + assert_eq!(stable_artifacts(), committed_snapshot); + let recovered_vault = decode_vault(&std::fs::read(&vault_path).unwrap()).unwrap(); + assert_eq!(recovered_vault.current.ghosts, vault.current.ghosts); + assert_eq!( + recovered_vault.current.auction_lots, + vault.current.auction_lots + ); + assert_eq!(recovered_vault.applied_deaths, vault.applied_deaths); + } + + // Standalone archive replay reconstructs from its recorded immutable lineage; + // it neither consults nor writes the external vault. Exercise both a different + // valid vault and no vault, restoring the committed bytes before managed calls. + let mut changed_vault = vault.clone(); + changed_vault.next_run_sequence += 1; + std::fs::write(&vault_path, encode_vault(&changed_vault).unwrap()).unwrap(); + let run_replay = || { + Harness::run({ + let mut replay_command = harness.command(); + replay_command + .arg("--content-root") + .arg(&harness.content) + .arg("--replay") + .arg(&replay_path); + replay_command + }) + }; + let changed_replay = run_replay(); + assert!( + changed_replay.status.success(), + "changed-vault replay stderr: {}", + String::from_utf8_lossy(&changed_replay.stderr) + ); + assert!(String::from_utf8_lossy(&changed_replay.stdout).contains("verified event-text replay")); + assert_eq!( + std::fs::read(&vault_path).unwrap(), + encode_vault(&changed_vault).unwrap(), + "archive replay is read-only" + ); + std::fs::write(&vault_path, &vault_bytes).unwrap(); + std::fs::remove_file(&vault_path).unwrap(); + let removed_replay = run_replay(); + assert!( + removed_replay.status.success(), + "removed-vault replay stderr: {}", + String::from_utf8_lossy(&removed_replay.stderr) + ); + assert!(String::from_utf8_lossy(&removed_replay.stdout).contains("verified event-text replay")); + assert!( + !vault_path.exists(), + "archive replay must not create a store" + ); + std::fs::write(&vault_path, &vault_bytes).unwrap(); + assert_eq!(std::fs::read(&committed_path).unwrap(), committed_bytes); + assert_eq!(std::fs::read(&epitaph_path).unwrap(), epitaph_bytes); let descendant_script = harness.script( "descendant.commands", - "observe knowledge\nobserve inventory\nobserve state\nquit\n", + "observe knowledge\nobserve inventory\nobserve state\nsave\nquit\n", ); let mut descendant_command = harness.managed(&descendant_script); descendant_command @@ -253,6 +545,17 @@ fn classic_real_death_commits_demo_vault_receipt_before_retirement() { String::from_utf8_lossy(&descendant.stderr) ); let descendant_records = Harness::records(&descendant); + let descendant_run_id = descendant_records + .iter() + .find(|record| field(record, "code") == "allocation-durable") + .and_then(|record| field(record, "run_id").as_str()) + .unwrap(); + assert_ne!(descendant_run_id, run_id); + assert_eq!( + descendant_run_id.parse::().unwrap().sequence(), + parsed_run_id.sequence() + 1, + "descendant allocation is sequential" + ); let knowledge = descendant_records .iter() .find(|record| { @@ -264,68 +567,56 @@ fn classic_real_death_commits_demo_vault_receipt_before_retirement() { let knowledge_entries = field(knowledge, "entries").as_array().unwrap(); assert!(knowledge_entries.iter().any(|entry| entry == "map:deep:1")); assert!(knowledge_entries.iter().any(|entry| entry == "map:deep:2")); - let inventory = descendant_records - .iter() - .find(|record| { - record - .get("detail") - .is_some_and(|value| value == "inventory") - }) - .unwrap(); - let mut templates = field(inventory, "entries") - .as_array() - .unwrap() + + let descendant_save = harness + .data + .join("runs") + .join(descendant_run_id) + .join("run.save"); + let descendant_decoded = decode_run( + &read_file_bounded(descendant_save, MAX_COMPRESSED_BYTES).unwrap(), + pack.hash(), + ) + .unwrap(); + let descendant_player = descendant_decoded + .current + .actors .iter() - .map(|entry| { - entry - .as_str() - .unwrap() - .split(':') - .nth(1) - .unwrap() - .to_owned() + .find(|actor| { + descendant_decoded + .current + .identity(actor.id) + .is_some_and(|identity| identity.role == ActorRole::Player) }) - .collect::>(); - templates.sort(); - let mut expected = hexwork_core::character::kit(Calling::Ratcatcher) + .expect("one descendant Player") + .id; + let kit = hexwork_core::character::kit(Calling::Ratcatcher); + let mut actual_templates = descendant_decoded.current.packs[&descendant_player] .items - .into_iter() - .map(str::to_owned) + .values() + .map(|item| item.template.clone()) .collect::>(); - expected.sort(); - assert_eq!(templates, expected, "auction lots did not become live gear"); - let descendant_state = descendant_records - .iter() - .find(|record| { - record - .get("detail") - .and_then(serde_json::Value::as_str) - .is_some_and(|detail| detail.starts_with("mode=")) - }) - .unwrap(); - assert!(field(descendant_state, "detail") - .as_str() - .unwrap() - .contains("chapel_debt=none")); - - std::fs::remove_file(harness.data.join("lineage-vault.json")).unwrap(); - let replayed = Harness::run({ - let mut replay = harness.command(); - replay - .arg("--content-root") - .arg(&harness.content) - .arg("--replay") - .arg(run_dir.join("replay.hexdemo")); - replay - }); - assert!( - replayed.status.success(), - "store replay stderr: {}", - String::from_utf8_lossy(&replayed.stderr) + actual_templates.sort(); + let mut expected_templates = kit.items.into_iter().map(str::to_owned).collect::>(); + expected_templates.sort(); + assert_eq!( + actual_templates, expected_templates, + "auction lots did not become live descendant gear" + ); + let wallet = descendant_decoded + .current + .wallet(descendant_player) + .expect("descendant wallet"); + assert_eq!(wallet.copper(), kit.stipend_cu); + assert_eq!(wallet.scrip(), 0); + assert!(descendant_decoded + .current + .chapel_debt(descendant_player) + .is_none()); + assert_eq!( + descendant_decoded.identity.initial_lineage, vault.current, + "descendant records the exact committed lineage snapshot" ); - assert!(String::from_utf8(replayed.stdout) - .unwrap() - .contains("verified event-text replay")); } #[test] diff --git a/docs/06-implementation-checklist.md b/docs/06-implementation-checklist.md index 8ca412d..56b577a 100644 --- a/docs/06-implementation-checklist.md +++ b/docs/06-implementation-checklist.md @@ -152,7 +152,7 @@ Checked historical items below describe earlier component milestones, not presen - [x] Story mode: town-entry checkpoints, chapel-debt resurrection quest; Ironcartographer daily seed mode — DONE: `core/modes.rs` (Mode, Checkpoint, ChapelDebt 1-burial, daily_seed FNV pins) + `--story`/`--daily` CLI + Story wake path in handle_death - [x] Saves: bincode+zstd + seed + input tail, versioned with migrations; demo export `--export-ansi` shareable casts — DONE: `save/save.rs` (SaveHeader, envelope level 3, version gates, delete_save) + autosave writer + `--replay`/`--export-ansi` runner (verify → restep → compare) - [x] Death replay viewer with rule-chain subtitles; SPS prompt ("worth retelling? [y/n]", local-only) — DONE: `save/demo.rs` (record/verify/export/restep) + `ui/death_ui.rs` (frames, exact SPS wording, vault-kept + buyback lines); subtitles resolve via epitaphs::subtitle per link -- [x] **Component gate:** `cli/tests/phase11_gate.rs` verifies delete-save-keeps-vault, a no-input bare re-step, mode pins, and composed surfaces; this does not prove process-boundary resume or input-carrying replay. +- [x] **Accepted bounded process-evidence tranche:** `cli/tests/phase11_gate.rs` now exercises the real binary for resume, action-carrying replay, Classic provenance/descendant consequences, retained corrupt/old/mismatched input rejection, deterministic archive-conflict recovery, allocation-counter collision, and ordinary-input Inn autosave. Review and final workspace gates passed. Historical bare-restep tests are not used as production replay proof; unavailable acquisition categories and exact crash instants remain separate gaps. - [ ] **Persistence gate:** mutate authoritative inventory/world/NPC/contract state → save → terminate → reload → canonical hash identical; Classic death atomically persists lineage and writes an input-carrying `.hexdemo`; Story death restores the stored town checkpoint. - [ ] **Replay gate:** same seed + content pack + committed player actions at recorded ticks reproduces the stored expected terminal canonical hash, with clear first-divergence diagnostics. @@ -160,7 +160,7 @@ Checked historical items below describe earlier component milestones, not presen - [ ] **Playable-loop gate:** chargen → populated Milthollow → NPC schedules running → real board inspection/acceptance → generated Deep → traverse several real floors → interact with actor/item/environment systems → satisfy objective → return/reward → use inn/service → sleep/world advancement → save/quit/reload → identical authoritative state. - [ ] **Classic-lineage gate:** production Classic death → run-save lifecycle as designed → input-carrying `.hexdemo` written → lineage vault atomically persisted → descendant startup loads inherited knowledge. -- [ ] **Deterministic-demo gate:** production `.hexdemo` replay applies the recorded committed actions at their ticks and reaches its stored final canonical hash. +- [x] **Deterministic-demo gate:** production death's actual `.hexdemo` replays committed actions/history to its stored final canonical hash through the real binary, including after changed/removed external vault. Accepted `classic_real_death_commits_demo_vault_receipt_before_retirement` also checks prelethal saved event prefix plus one event; final locked workspace gates pass. This does not certify missing acquisition verbs or exact crash windows. - [ ] **Runtime debt gate:** `--arena --seed` affects arena state; tactical Z moves only on explicit input; panic hook writes `crash-epitaph.md`; actors render from identity rather than generic rat fallback; stale phase placeholders are removed or accurately qualified; entity authority is documented and drift-tested. ## Phase 12 — QoL, accessibility, audio [03 §3.10; 05 §5.4–5.5; 01 §7] diff --git a/docs/live-integration-plan.md b/docs/live-integration-plan.md index 2234347..cd1ae1e 100644 --- a/docs/live-integration-plan.md +++ b/docs/live-integration-plan.md @@ -365,6 +365,30 @@ That HIGH finding is now **closed and independently accepted**. `TerminalLifecyc Full game release, broad accessibility, exact multi-window binary crash evidence and unsupported-platform certification remain separate gates. +### Next production-evidence tranche (after accepted frontend correction) + +The next test-only work closes bounded gaps without adding gameplay or changing accepted persistence semantics: + +1. **Negative real-binary inputs and load constraints (new nested process-test module).** Exercise missing script/replay as I/O class 4; oversized script/replay via sparse cap-plus-one files as limit class 5; invalid managed RunId/path-shaped inputs as usage class 2; explicit load seed/mode mismatch and corrupt managed run/vault as format/integrity class 3. Include real old-format fixtures and changed-content mismatch where existing fixtures permit a bounded faithful test. Every case must assert no successful play/observation and preservation of preexisting evidence; cases rejected before root opening must prove no artifact root creation. Preserve the distinction between malformed input fixtures and ordinary gameplay evidence. No large literal in-memory cap fixtures, script-raised work limits, or production parser bypass. +2. **Classic/descendant/provenance assertions (existing real-death process scenario).** Reuse ordinary generated route and reciprocal melee. Capture a real prelethal managed save where needed, then kill through a normal loaded input. Check all eligible inherited provenance fields against actual saved item authority, known-only knowledge, lowest-ID ghost selection and canonical warning, fresh descendant kit/wallet/no debt, unique run IDs, retired-run load refusal, and repeated actual `--recover` with unchanged receipts/ghosts/lots/artifacts. Replay the death-produced archive after changed as well as removed external vault. Where a category cannot be acquired by current normal gameplay, report the missing category or clearly separate a recorded-lineage fixture; do not invent an identification/recipe action or count a fixture as acquisition gameplay. +3. **Read-only recovery-evidence investigation.** Determine which exact CLI failure/restart states can be forced with ordinary filesystem collisions or bounded OS control under the current startup preflight. Distinguish deterministic operation failure from process interruption. No hidden pause/kill/injection verbs or changed production semantics merely to make tests pass. ACK-then-kill cannot establish an exact inter-operation stop; inspect actual surviving files and report only achieved states. Freeze any subsequent implementation contract from concrete findings. + +Parent verification corrected the recovery investigation: `Application::load_with_output_checked` calls recovery before managed load, and recovery inventory classifies **all** destinations before its living-snapshot skip. Therefore a precreated directory at `death.pending` or `replay.hexdemo` is startup rejection, not an interior death-transaction failure. The next recovery module instead tests startup destination collisions, future-sequence directory rejection with unchanged allocation counter, and the source-grounded hypothesis that a conflicting **regular** archive file passes living-snapshot startup but blocks later death recovery after terminal/pending publication. Actual observed milestones and surviving bytes must establish that hypothesis; no result is presumed. It also tests ordinary-input Inn sleep crossing autosave cadence without raw advance commands. + +These nodes use private `/var/tmp` roots and the accepted bounded process harness. Their ownership is disjoint; parent registers the new nested module after its file exists, integrates results, and runs the actual binary tests. New test failures exposing runtime defects are reported for a separate narrow correction rather than changing expected exit classes to hide them. Full Phase 12, acquisition verbs, auction buyback, volume and ship gates remain open. + +### Production-evidence tranche verification — accepted bounded tranche + +- Parent ran the registered `negative::` actual-binary suite: **7 passed**. Cases cover missing files, sparse cap-plus-one inputs, malformed run IDs, explicit load constraints, retained corrupt evidence, genuine old formats, and changed content. Independent read-only review accepted this module. +- The strengthened Classic process scenario **passed**, and independent review accepted its scope: ordinary-route prelethal save/load then final confirm; full replay prefix plus one added event; auction fields compared to the actual prelethal pack under unchanged melee semantics; lowest-ID ghost and warning equality with emitted epitaph cause text; retired load refusal; unchanged retained artifacts/effects after two recoveries; archive replay with changed/removed external vault; sequential descendant ID, kit template composition, default wallet, no chapel debt, and exact recorded InitialLineage. This is not full equipment-field equality, all-debt-system coverage, a standalone general terminal-pack theorem, or proof of no transient retirement-stage churn. Recipe/translation/known-template acquisition sets are empty on this route and remain explicitly unproven by normal gameplay. +- The registered recovery/Inn module **passed all four tests** after correcting test lifetime and canonical future-RunId fixture errors. Actual binary tests prove startup destination-type rejection, counter-preserving future-sequence collision/retry, and a regular archive conflict after terminal/pending publication with preserved evidence and successful independent recovery after removing only the obstacle. Recovery-path validation makes the retained terminal and pending matching valid authority before the specific replay mismatch; the test does not directly decode those two files. This is deterministic operation-failure/restart proof, not an exact process-crash instant. +- Independent review caught an initially vacuous Inn autosave assertion (quit could have supplied the save). The corrected exact Inn test **passed** and reviewer closure **accepted** it: the tick-8,640 snapshot belongs to the dynamically derived Inn confirm command, precedes its acknowledgment, and quit emits only `save-clean`. The ordinary route uses no raw advance, pays 8 copper from 25, and records one Inn service event. +- Parent final workspace formatting, strict locked workspace all-target clippy, and locked workspace all-target tests **passed after the last assertion edit**. Independent negative/Classic/collision and Inn-origin closure reviews accepted this bounded tranche. No production runtime changes were required. Broad acquisition verbs, all-debt/equipment semantics, exact interruption windows, cross-platform certification, live Phase 12 integration and ship acceptance are not inferred from these results. + +### Next Phase 12 research checkpoint (not an implementation handoff) + +Read-only investigation recommends live physical-key remapping as the first small node, followed by actual settings/event-stream/audio integration. Parent source checks already corrected two assumptions: `remap::load_keymap` currently uses unbounded `read_to_string`, and `entry.rs` polls input in both play and chargen. A handoff must freeze bounded keymap input/root/failure behavior and both input surfaces, preserve normalized headless grammar and canonical replay authority, and avoid CWD-relative user configuration. Existing palette tests remain component evidence, not live visual/accessibility certification. Parent review of the rest of the research and a concrete node contract remain required before runtime edits. + ### Review Strategy This plan requires parent + independent plan review before implementation, particularly core knowledge ownership, complete initial snapshot, run allocation/idempotency, death write ordering, immutable recovery references, and bounded replay/export costs. Review core/schema and artifact transaction independently before caller wiring. Review final process tests against actual production paths. Fix/rebut critical/high findings and obtain closure on substantive changes. Do not accept green tests that silently inject state, omit crash windows, or conflate single-file replacement with whole-transaction durability.