Add headless runtime tooling and Campaign.win analysis

This commit is contained in:
Jan Petykiewicz 2026-04-10 01:22:47 -07:00
commit 27172e3786
37 changed files with 11867 additions and 302 deletions

View file

@ -0,0 +1,10 @@
[package]
name = "rrt-fixtures"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
rrt-runtime = { path = "../rrt-runtime" }
serde.workspace = true
serde_json.workspace = true

View file

@ -0,0 +1,82 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JsonDiffEntry {
pub path: String,
pub left: Value,
pub right: Value,
}
pub fn diff_json_values(left: &Value, right: &Value) -> Vec<JsonDiffEntry> {
let mut differences = Vec::new();
collect_json_differences("$", left, right, &mut differences);
differences
}
fn collect_json_differences(
path: &str,
left: &Value,
right: &Value,
differences: &mut Vec<JsonDiffEntry>,
) {
match (left, right) {
(Value::Object(left_map), Value::Object(right_map)) => {
let mut keys = BTreeSet::new();
keys.extend(left_map.keys().cloned());
keys.extend(right_map.keys().cloned());
for key in keys {
let next_path = format!("{path}.{key}");
match (left_map.get(&key), right_map.get(&key)) {
(Some(left_value), Some(right_value)) => {
collect_json_differences(&next_path, left_value, right_value, differences);
}
(left_value, right_value) => differences.push(JsonDiffEntry {
path: next_path,
left: left_value.cloned().unwrap_or(Value::Null),
right: right_value.cloned().unwrap_or(Value::Null),
}),
}
}
}
(Value::Array(left_items), Value::Array(right_items)) => {
let max_len = left_items.len().max(right_items.len());
for index in 0..max_len {
let next_path = format!("{path}[{index}]");
match (left_items.get(index), right_items.get(index)) {
(Some(left_value), Some(right_value)) => {
collect_json_differences(&next_path, left_value, right_value, differences);
}
(left_value, right_value) => differences.push(JsonDiffEntry {
path: next_path,
left: left_value.cloned().unwrap_or(Value::Null),
right: right_value.cloned().unwrap_or(Value::Null),
}),
}
}
}
_ if left != right => differences.push(JsonDiffEntry {
path: path.to_string(),
left: left.clone(),
right: right.clone(),
}),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diffs_nested_json_values() {
let left = serde_json::json!({ "a": { "b": 1 } });
let right = serde_json::json!({ "a": { "b": 2 } });
let diff = diff_json_values(&left, &right);
assert_eq!(diff.len(), 1);
assert_eq!(diff[0].path, "$.a.b");
}
}

View file

@ -0,0 +1,12 @@
pub mod diff;
pub mod load;
pub mod normalize;
pub mod schema;
pub use diff::{JsonDiffEntry, diff_json_values};
pub use load::{load_fixture_document, load_fixture_document_from_str};
pub use normalize::normalize_runtime_state;
pub use schema::{
ExpectedRuntimeSummary, FIXTURE_FORMAT_VERSION, FixtureDocument, FixtureSource,
FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument, validate_fixture_document,
};

View file

@ -0,0 +1,162 @@
use std::path::{Path, PathBuf};
use rrt_runtime::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
use crate::{FixtureDocument, FixtureStateOrigin, RawFixtureDocument};
pub fn load_fixture_document(path: &Path) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
load_fixture_document_from_str_with_base(&text, base_dir)
}
pub fn load_fixture_document_from_str(
text: &str,
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
load_fixture_document_from_str_with_base(text, Path::new("."))
}
pub fn load_fixture_document_from_str_with_base(
text: &str,
base_dir: &Path,
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
let raw: RawFixtureDocument = serde_json::from_str(text)?;
resolve_raw_fixture_document(raw, base_dir)
}
fn resolve_raw_fixture_document(
raw: RawFixtureDocument,
base_dir: &Path,
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
let state = match (&raw.state, &raw.state_snapshot_path) {
(Some(_), Some(_)) => {
return Err(
"fixture must not specify both inline state and state_snapshot_path".into(),
);
}
(None, None) => {
return Err("fixture must specify either inline state or state_snapshot_path".into());
}
(Some(state), None) => state.clone(),
(None, Some(snapshot_path)) => {
let snapshot_path = resolve_snapshot_path(base_dir, snapshot_path);
let snapshot = load_runtime_snapshot_document(&snapshot_path)?;
validate_runtime_snapshot_document(&snapshot).map_err(|err| {
format!(
"invalid runtime snapshot {}: {err}",
snapshot_path.display()
)
})?;
snapshot.state
}
};
let state_origin = match raw.state_snapshot_path {
Some(snapshot_path) => FixtureStateOrigin::SnapshotPath(snapshot_path),
None => FixtureStateOrigin::Inline,
};
Ok(FixtureDocument {
format_version: raw.format_version,
fixture_id: raw.fixture_id,
source: raw.source,
state,
state_origin,
commands: raw.commands,
expected_summary: raw.expected_summary,
})
}
fn resolve_snapshot_path(base_dir: &Path, snapshot_path: &str) -> PathBuf {
let candidate = PathBuf::from(snapshot_path);
if candidate.is_absolute() {
candidate
} else {
base_dir.join(candidate)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FixtureStateOrigin;
use rrt_runtime::{
CalendarPoint, RuntimeServiceState, RuntimeSnapshotDocument, RuntimeSnapshotSource,
RuntimeState, SNAPSHOT_FORMAT_VERSION, save_runtime_snapshot_document,
};
use std::collections::BTreeMap;
#[test]
fn loads_fixture_from_relative_snapshot_path() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let fixture_dir = std::env::temp_dir().join(format!("rrt-fixture-load-{nonce}"));
std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created");
let snapshot_path = fixture_dir.join("state.json");
let snapshot = RuntimeSnapshotDocument {
format_version: SNAPSHOT_FORMAT_VERSION,
snapshot_id: "snapshot-backed-fixture-state".to_string(),
source: RuntimeSnapshotSource {
source_fixture_id: Some("snapshot-backed-fixture".to_string()),
description: Some("test snapshot".to_string()),
},
state: RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 5,
},
world_flags: BTreeMap::new(),
companies: Vec::new(),
event_runtime_records: Vec::new(),
service_state: RuntimeServiceState::default(),
},
};
save_runtime_snapshot_document(&snapshot_path, &snapshot).expect("snapshot should save");
let fixture_json = r#"
{
"format_version": 1,
"fixture_id": "snapshot-backed-fixture",
"source": {
"kind": "captured-runtime"
},
"state_snapshot_path": "state.json",
"commands": [
{
"kind": "step_count",
"steps": 1
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 6
},
"world_flag_count": 0,
"company_count": 0,
"event_runtime_record_count": 0,
"total_company_cash": 0
}
}
"#;
let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir)
.expect("snapshot-backed fixture should load");
assert_eq!(
fixture.state_origin,
FixtureStateOrigin::SnapshotPath("state.json".to_string())
);
assert_eq!(fixture.state.calendar.tick_slot, 5);
let _ = std::fs::remove_file(snapshot_path);
let _ = std::fs::remove_dir(fixture_dir);
}
}

View file

@ -0,0 +1,7 @@
use serde_json::Value;
use rrt_runtime::RuntimeState;
pub fn normalize_runtime_state(state: &RuntimeState) -> Result<Value, Box<dyn std::error::Error>> {
Ok(serde_json::to_value(state)?)
}

View file

@ -0,0 +1,277 @@
use serde::{Deserialize, Serialize};
use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand};
pub const FIXTURE_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct FixtureSource {
pub kind: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub calendar: Option<rrt_runtime::CalendarPoint>,
#[serde(default)]
pub world_flag_count: Option<usize>,
#[serde(default)]
pub company_count: Option<usize>,
#[serde(default)]
pub event_runtime_record_count: Option<usize>,
#[serde(default)]
pub total_event_record_service_count: Option<u64>,
#[serde(default)]
pub periodic_boundary_call_count: Option<u64>,
#[serde(default)]
pub total_trigger_dispatch_count: Option<u64>,
#[serde(default)]
pub dirty_rerun_count: Option<u64>,
#[serde(default)]
pub total_company_cash: Option<i64>,
}
impl ExpectedRuntimeSummary {
pub fn compare(&self, actual: &RuntimeSummary) -> Vec<String> {
let mut mismatches = Vec::new();
if let Some(calendar) = self.calendar {
if actual.calendar != calendar {
mismatches.push(format!(
"calendar mismatch: expected {:?}, got {:?}",
calendar, actual.calendar
));
}
}
if let Some(count) = self.world_flag_count {
if actual.world_flag_count != count {
mismatches.push(format!(
"world_flag_count mismatch: expected {count}, got {}",
actual.world_flag_count
));
}
}
if let Some(count) = self.company_count {
if actual.company_count != count {
mismatches.push(format!(
"company_count mismatch: expected {count}, got {}",
actual.company_count
));
}
}
if let Some(count) = self.event_runtime_record_count {
if actual.event_runtime_record_count != count {
mismatches.push(format!(
"event_runtime_record_count mismatch: expected {count}, got {}",
actual.event_runtime_record_count
));
}
}
if let Some(count) = self.total_event_record_service_count {
if actual.total_event_record_service_count != count {
mismatches.push(format!(
"total_event_record_service_count mismatch: expected {count}, got {}",
actual.total_event_record_service_count
));
}
}
if let Some(count) = self.periodic_boundary_call_count {
if actual.periodic_boundary_call_count != count {
mismatches.push(format!(
"periodic_boundary_call_count mismatch: expected {count}, got {}",
actual.periodic_boundary_call_count
));
}
}
if let Some(count) = self.total_trigger_dispatch_count {
if actual.total_trigger_dispatch_count != count {
mismatches.push(format!(
"total_trigger_dispatch_count mismatch: expected {count}, got {}",
actual.total_trigger_dispatch_count
));
}
}
if let Some(count) = self.dirty_rerun_count {
if actual.dirty_rerun_count != count {
mismatches.push(format!(
"dirty_rerun_count mismatch: expected {count}, got {}",
actual.dirty_rerun_count
));
}
}
if let Some(total) = self.total_company_cash {
if actual.total_company_cash != total {
mismatches.push(format!(
"total_company_cash mismatch: expected {total}, got {}",
actual.total_company_cash
));
}
}
mismatches
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FixtureDocument {
pub format_version: u32,
pub fixture_id: String,
#[serde(default)]
pub source: FixtureSource,
pub state: RuntimeState,
pub state_origin: FixtureStateOrigin,
#[serde(default)]
pub commands: Vec<StepCommand>,
#[serde(default)]
pub expected_summary: ExpectedRuntimeSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FixtureStateOrigin {
Inline,
SnapshotPath(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawFixtureDocument {
pub format_version: u32,
pub fixture_id: String,
#[serde(default)]
pub source: FixtureSource,
#[serde(default)]
pub state: Option<RuntimeState>,
#[serde(default)]
pub state_snapshot_path: Option<String>,
#[serde(default)]
pub commands: Vec<StepCommand>,
#[serde(default)]
pub expected_summary: ExpectedRuntimeSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FixtureValidationReport {
pub fixture_id: String,
pub valid: bool,
pub issue_count: usize,
pub issues: Vec<String>,
}
pub fn validate_fixture_document(document: &FixtureDocument) -> FixtureValidationReport {
let mut issues = Vec::new();
if document.format_version != FIXTURE_FORMAT_VERSION {
issues.push(format!(
"unsupported format_version {} (expected {})",
document.format_version, FIXTURE_FORMAT_VERSION
));
}
if document.fixture_id.trim().is_empty() {
issues.push("fixture_id must not be empty".to_string());
}
if document.source.kind.trim().is_empty() {
issues.push("source.kind must not be empty".to_string());
}
if document.commands.is_empty() {
issues.push("fixture must contain at least one command".to_string());
}
if let Err(err) = document.state.validate() {
issues.push(format!("invalid runtime state: {err}"));
}
for (index, command) in document.commands.iter().enumerate() {
if let Err(err) = command.validate() {
issues.push(format!("invalid command at index {index}: {err}"));
}
}
FixtureValidationReport {
fixture_id: document.fixture_id.clone(),
valid: issues.is_empty(),
issue_count: issues.len(),
issues,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::load_fixture_document_from_str;
const FIXTURE_JSON: &str = r#"
{
"format_version": 1,
"fixture_id": "minimal-world-step-smoke",
"source": {
"kind": "synthetic",
"description": "basic milestone parser smoke fixture"
},
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {
"sandbox": false
},
"companies": [
{
"company_id": 1,
"current_cash": 250000,
"debt": 0
}
],
"event_runtime_records": [],
"service_state": {
"periodic_boundary_calls": 0,
"trigger_dispatch_counts": {},
"total_event_record_services": 0,
"dirty_rerun_count": 0
}
},
"commands": [
{
"kind": "advance_to",
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 2
}
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 2
},
"world_flag_count": 1,
"company_count": 1,
"event_runtime_record_count": 0,
"total_company_cash": 250000
}
}
"#;
#[test]
fn parses_and_validates_fixture() {
let fixture = load_fixture_document_from_str(FIXTURE_JSON).expect("fixture should parse");
let report = validate_fixture_document(&fixture);
assert!(report.valid, "report should be valid: {:?}", report.issues);
assert_eq!(fixture.state_origin, FixtureStateOrigin::Inline);
}
#[test]
fn compares_expected_summary() {
let fixture = load_fixture_document_from_str(FIXTURE_JSON).expect("fixture should parse");
let summary = RuntimeSummary::from_state(&fixture.state);
let mismatches = fixture.expected_summary.compare(&summary);
assert_eq!(mismatches.len(), 1);
assert!(mismatches[0].contains("calendar mismatch"));
}
}