main.rs
1 //! Tiny dev CLI for the DYNOSTIC core. 2 //! 3 //! Usage: 4 //! cargo run -p dynostic_cli --release -- --ticks 100 --seed 123 --abilities abilities.json 5 6 use dynostic_core::{ 7 ev, generate_ed25519_keypair, sha256_hex, sign_payload_ed25519, signature_status_ed25519, 8 AbilityDef, AbilitySet, AiBehaviorConfig, AiConfig, AssetManifest, Campaign, 9 CampaignDefinition, CampaignState, CineChoiceRecord, CineEvent, CineOp, CinePlayer, 10 CineSaveState, CineTimeline, CineWorldEffect, CineWorldState, ContentPack, DirectorConfig, 11 DynosticEvent, EncounterSpec, Engine, HazardKind, Intent, ItemStack, MetricProfile, 12 MissionTileScar, PackDependency, PackPatch, PackPermissions, PackSignature, 13 PackSignatureStatus, PartyMember, Phase, Pos, ReactionDef, ReactionSet, Replay, SemVer, 14 TileKind, World, ABILITY_SET_VERSION, REACTION_SET_VERSION, VERSION_MAJOR, VERSION_MINOR, 15 VERSION_PATCH, 16 }; 17 use serde::{Deserialize, Serialize}; 18 use serde_json::{Map, Number, Value}; 19 use std::collections::{BTreeMap, HashMap, HashSet}; 20 use std::fs; 21 use std::path::{Path, PathBuf}; 22 23 fn print_usage() { 24 eprintln!( 25 "Usage: dynostic_cli [--ticks N] [--seed SEED] [--abilities PATH] [--validate-abilities PATH]\n\ 26 [--ai] [--ai-team N] [--ai-aggression N] [--ai-risk N] [--ai-focus N]\n\ 27 [--ai-vision N] [--metric-profile NAME] [--record PATH] [--replay PATH] [--turns N]\n\ 28 [--snapshot-in PATH] [--snapshot-out PATH]\n\ 29 [--pack PATH] [--validate-pack PATH]\n\ 30 [--campaign PATH] [--validate-campaign PATH]\n\ 31 [--campaign-run N] [--campaign-state-in PATH]\n\ 32 [--campaign-state-out PATH]\n\ 33 [--dump-json] [--no-plan] [--profile]\n\ 34 Subcommands:\n\ 35 dynostic_cli pack lint [--pack PATH]\n\ 36 dynostic_cli pack resolve [--pack PATH] [--out PATH]\n\ 37 dynostic_cli pack build [--pack PATH] [--out-dir DIR]\n\ 38 dynostic_cli pack cache [--pack PATH] [--out PATH]\n\ 39 dynostic_cli pack hash [--pack PATH] [--out PATH]\n\ 40 dynostic_cli pack verify [--pack PATH] [--in PATH]\n\ 41 dynostic_cli pack sign --pack PATH --key PATH [--out PATH]\n\ 42 dynostic_cli pack verify-signature --pack PATH [--allow-unsigned]\n\ 43 dynostic_cli pack keygen --out-public PATH --out-secret PATH\n\ 44 dynostic_cli mod list [--manifest PATH]\n\ 45 dynostic_cli mod add --id ID [--path PATH] [--enable] [--allow-unsigned] [--allow-scripts] [--manifest PATH]\n\ 46 dynostic_cli mod enable ID [--manifest PATH]\n\ 47 dynostic_cli mod disable ID [--manifest PATH]\n\ 48 dynostic_cli mod remove ID [--manifest PATH]\n\ 49 dynostic_cli mod publish --pack PATH [--registry PATH]\n\ 50 dynostic_cli release manifest --out PATH [--engine PATH] [--pack PATH ...] [--sign-key PATH] [--allow-unsigned]\n\ 51 dynostic_cli release sign --manifest PATH --key PATH [--out PATH]\n\ 52 dynostic_cli release verify --manifest PATH [--allow-unsigned] [--allow-missing-files]\n\ 53 dynostic_cli release keygen --out-public PATH --out-secret PATH\n\ 54 dynostic_cli balance --config PATH [--out PATH] [--baseline PATH] [--max-win-rate-delta N]\n\ 55 dynostic_cli compat build --out PATH [--replay PATH ...] [--snapshot PATH ...]\n\ 56 dynostic_cli compat verify --manifest PATH\n\ 57 dynostic_cli golden build --replay PATH --out PATH [--script PATH] [--name NAME]\n\ 58 dynostic_cli golden build --cine PATH --out PATH [--choices PATH] [--name NAME]\n\ 59 dynostic_cli golden verify --manifest PATH\n\ 60 dynostic_cli tutorial record --pack PATH --intents PATH --out PATH [--script PATH]\n\ 61 dynostic_cli tutorial verify --manifest PATH\n\ 62 dynostic_cli tutorial verify --pack PATH --replay PATH\n\ 63 dynostic_cli episode validate --manifest PATH\n\ 64 dynostic_cli episode validate --dir PATH\n\ 65 dynostic_cli cine validate --timeline PATH\n\ 66 dynostic_cli cine lint --timeline PATH [--pack PATH]\n\ 67 dynostic_cli cine run --timeline PATH [--out PATH]\n\ 68 dynostic_cli cine dialogue-export --timeline PATH [--out PATH]\n\ 69 dynostic_cli cine dialogue-import --timeline PATH --csv PATH [--out PATH]\n\ 70 dynostic_cli cine locale-qa --timeline PATH [--out PATH]\n\ 71 dynostic_cli cine save --timeline PATH --ticks N --out PATH [--choices PATH]\n\ 72 dynostic_cli cine resume --snapshot PATH [--out PATH]\n\ 73 dynostic_cli campaign lint (--campaign PATH | --pack PATH)\n\ 74 dynostic_cli campaign run (--campaign PATH | --pack PATH) --script PATH [--state-in PATH] [--out PATH]\n\ 75 dynostic_cli asset audit --pack PATH [--out PATH] [--strict]\n\ 76 dynostic_cli repro export --replay PATH --out PATH [--pack PATH] [--events N] [--turn N]\n\ 77 dynostic_cli new <game_name> [--out DIR] [--force]\n\ 78 Defaults: --ticks 10 --seed 123 (demo attack intents queued)" 79 ); 80 } 81 82 fn parse_u32(value: impl AsRef<str>, name: &str) -> u32 { 83 let value = value.as_ref(); 84 value.parse().unwrap_or_else(|_| { 85 eprintln!("Invalid {}: {}", name, value); 86 std::process::exit(2); 87 }) 88 } 89 90 fn parse_u64(value: impl AsRef<str>, name: &str) -> u64 { 91 let value = value.as_ref(); 92 value.parse().unwrap_or_else(|_| { 93 eprintln!("Invalid {}: {}", name, value); 94 std::process::exit(2); 95 }) 96 } 97 98 fn parse_i32(value: &str, name: &str) -> i32 { 99 value.parse().unwrap_or_else(|_| { 100 eprintln!("Invalid {}: {}", name, value); 101 std::process::exit(2); 102 }) 103 } 104 105 fn parse_f64(value: &str, name: &str) -> f64 { 106 value.parse().unwrap_or_else(|_| { 107 eprintln!("Invalid {}: {}", name, value); 108 std::process::exit(2); 109 }) 110 } 111 112 fn parse_metric_profile(value: impl AsRef<str>) -> MetricProfile { 113 match value.as_ref().to_ascii_lowercase().as_str() { 114 "manhattan" => MetricProfile::Manhattan, 115 "chebyshev" => MetricProfile::Chebyshev, 116 other => { 117 eprintln!( 118 "Invalid metric-profile: {} (expected manhattan or chebyshev)", 119 other 120 ); 121 std::process::exit(2); 122 } 123 } 124 } 125 126 struct NewTemplateContext { 127 name: String, 128 id: String, 129 engine_major: u32, 130 engine_minor: u32, 131 engine_patch: u32, 132 } 133 134 const NEW_TEMPLATE_FILES: &[(&str, &str)] = &[ 135 ( 136 "README.md", 137 include_str!("../../../templates/reference_game/README.md"), 138 ), 139 ( 140 "pack.json", 141 include_str!("../../../templates/reference_game/pack.json"), 142 ), 143 ( 144 "content/abilities.json", 145 include_str!("../../../templates/reference_game/content/abilities.json"), 146 ), 147 ( 148 "content/reactions.json", 149 include_str!("../../../templates/reference_game/content/reactions.json"), 150 ), 151 ( 152 "content/director.json", 153 include_str!("../../../templates/reference_game/content/director.json"), 154 ), 155 ( 156 "content/ai.json", 157 include_str!("../../../templates/reference_game/content/ai.json"), 158 ), 159 ( 160 "content/tutorial.json", 161 include_str!("../../../templates/reference_game/content/tutorial.json"), 162 ), 163 ( 164 "content/campaign.json", 165 include_str!("../../../templates/reference_game/content/campaign.json"), 166 ), 167 ( 168 "content/campaign_state.json", 169 include_str!("../../../templates/reference_game/content/campaign_state.json"), 170 ), 171 ( 172 "content/maps/hello_map.json", 173 include_str!("../../../templates/reference_game/content/maps/hello_map.json"), 174 ), 175 ( 176 "content/encounters/hello_encounter.json", 177 include_str!("../../../templates/reference_game/content/encounters/hello_encounter.json"), 178 ), 179 ( 180 "content/cutscenes/hello_cutscene.json", 181 include_str!("../../../templates/reference_game/content/cutscenes/hello_cutscene.json"), 182 ), 183 ( 184 "mods/mods.json", 185 include_str!("../../../templates/reference_game/mods/mods.json"), 186 ), 187 ( 188 "editor/presets.json", 189 include_str!("../../../templates/reference_game/editor/presets.json"), 190 ), 191 ( 192 "tools/golden/replay_hello.json", 193 include_str!("../../../templates/reference_game/tools/golden/replay_hello.json"), 194 ), 195 ( 196 "tools/golden_manifest.json", 197 include_str!("../../../templates/reference_game/tools/golden_manifest.json"), 198 ), 199 ( 200 "tools/golden_frames/hello_encounter/.gitkeep", 201 include_str!( 202 "../../../templates/reference_game/tools/golden_frames/hello_encounter/.gitkeep" 203 ), 204 ), 205 ( 206 "scripts/build_pack.sh", 207 include_str!("../../../templates/reference_game/scripts/build_pack.sh"), 208 ), 209 ( 210 "scripts/build_release.sh", 211 include_str!("../../../templates/reference_game/scripts/build_release.sh"), 212 ), 213 ( 214 "scripts/ci.sh", 215 include_str!("../../../templates/reference_game/scripts/ci.sh"), 216 ), 217 ( 218 ".github/workflows/ci.yml", 219 include_str!("../../../templates/reference_game/.github/workflows/ci.yml"), 220 ), 221 ]; 222 223 fn render_new_template(contents: &str, ctx: &NewTemplateContext) -> String { 224 let mut output = contents.to_string(); 225 output = output.replace("{{GAME_NAME}}", &ctx.name); 226 output = output.replace("{{GAME_ID}}", &ctx.id); 227 output = output.replace("{{ENGINE_MAJOR}}", &ctx.engine_major.to_string()); 228 output = output.replace("{{ENGINE_MINOR}}", &ctx.engine_minor.to_string()); 229 output = output.replace("{{ENGINE_PATCH}}", &ctx.engine_patch.to_string()); 230 output 231 } 232 233 fn slugify_game_id(name: &str) -> String { 234 let mut output = String::new(); 235 let mut last_underscore = false; 236 for ch in name.chars() { 237 if ch.is_ascii_alphanumeric() { 238 output.push(ch.to_ascii_lowercase()); 239 last_underscore = false; 240 } else if !last_underscore { 241 output.push('_'); 242 last_underscore = true; 243 } 244 } 245 let trimmed = output.trim_matches('_').to_string(); 246 if trimmed.is_empty() { 247 "dynostic_game".to_string() 248 } else { 249 trimmed 250 } 251 } 252 253 fn print_new_usage() { 254 eprintln!( 255 "Usage: dynostic_cli new <game_name> [--out DIR] [--force]\n\ 256 Example:\n\ 257 dynostic_cli new \"My Game\" --out games" 258 ); 259 } 260 261 fn write_new_project(dest: &Path, ctx: &NewTemplateContext, force: bool) -> Result<(), String> { 262 if dest.exists() { 263 let has_entries = dest 264 .read_dir() 265 .map_err(|err| format!("Failed to read {}: {}", dest.display(), err))? 266 .next() 267 .is_some(); 268 if has_entries && !force { 269 return Err(format!( 270 "Destination {} already exists (use --force to overwrite)", 271 dest.display() 272 )); 273 } 274 } else { 275 fs::create_dir_all(dest) 276 .map_err(|err| format!("Failed to create {}: {}", dest.display(), err))?; 277 } 278 279 for (rel_path, contents) in NEW_TEMPLATE_FILES { 280 let rendered = render_new_template(contents, ctx); 281 let target = dest.join(rel_path); 282 if let Some(parent) = target.parent() { 283 fs::create_dir_all(parent) 284 .map_err(|err| format!("Failed to create {}: {}", parent.display(), err))?; 285 } 286 fs::write(&target, rendered) 287 .map_err(|err| format!("Failed to write {}: {}", target.display(), err))?; 288 #[cfg(unix)] 289 { 290 use std::os::unix::fs::PermissionsExt; 291 if rel_path.starts_with("scripts/") { 292 let mut perms = fs::metadata(&target) 293 .map_err(|err| format!("Failed to read {}: {}", target.display(), err))? 294 .permissions(); 295 perms.set_mode(0o755); 296 fs::set_permissions(&target, perms).map_err(|err| { 297 format!( 298 "Failed to set permissions for {}: {}", 299 target.display(), 300 err 301 ) 302 })?; 303 } 304 } 305 } 306 Ok(()) 307 } 308 309 fn handle_new_command(args: Vec<String>) { 310 if args.is_empty() { 311 print_new_usage(); 312 std::process::exit(2); 313 } 314 let mut name: Option<String> = None; 315 let mut out_dir: Option<String> = None; 316 let mut force = false; 317 let mut idx = 0; 318 while idx < args.len() { 319 match args[idx].as_str() { 320 "--out" => { 321 idx += 1; 322 if idx >= args.len() { 323 eprintln!("Missing value for --out"); 324 print_new_usage(); 325 std::process::exit(2); 326 } 327 out_dir = Some(args[idx].clone()); 328 } 329 "--force" => { 330 force = true; 331 } 332 value => { 333 if name.is_none() { 334 name = Some(value.to_string()); 335 } else { 336 eprintln!("Unexpected argument: {}", value); 337 print_new_usage(); 338 std::process::exit(2); 339 } 340 } 341 } 342 idx += 1; 343 } 344 345 let name = name.unwrap_or_else(|| { 346 print_new_usage(); 347 std::process::exit(2); 348 }); 349 let id = slugify_game_id(&name); 350 let base = out_dir.unwrap_or_else(|| ".".to_string()); 351 let dest = Path::new(&base).join(&id); 352 let ctx = NewTemplateContext { 353 name, 354 id, 355 engine_major: VERSION_MAJOR, 356 engine_minor: VERSION_MINOR, 357 engine_patch: VERSION_PATCH, 358 }; 359 360 if let Err(err) = write_new_project(&dest, &ctx, force) { 361 eprintln!("Failed to scaffold project: {}", err); 362 std::process::exit(2); 363 } 364 println!("OK: new project written to {}", dest.display()); 365 } 366 367 struct Args { 368 ticks: u32, 369 seed: u64, 370 dump_json: bool, 371 no_plan: bool, 372 abilities_path: Option<String>, 373 validate_path: Option<String>, 374 ai_enabled: bool, 375 ai_team: u8, 376 ai_aggression: Option<i32>, 377 ai_risk: Option<i32>, 378 ai_focus: Option<i32>, 379 ai_vision: Option<u32>, 380 record_path: Option<String>, 381 replay_path: Option<String>, 382 turns: u32, 383 snapshot_in: Option<String>, 384 snapshot_out: Option<String>, 385 profile: bool, 386 pack_path: Option<String>, 387 validate_pack_path: Option<String>, 388 campaign_path: Option<String>, 389 validate_campaign_path: Option<String>, 390 campaign_run: u32, 391 campaign_state_in: Option<String>, 392 campaign_state_out: Option<String>, 393 metric_profile: Option<MetricProfile>, 394 } 395 396 fn parse_args() -> Args { 397 let mut ticks: u32 = 10; 398 let mut seed: u64 = 123; 399 let mut dump_json = false; 400 let mut no_plan = false; 401 let mut abilities_path: Option<String> = None; 402 let mut validate_path: Option<String> = None; 403 let mut ai_enabled = false; 404 let mut ai_team: u8 = 1; 405 let mut ai_aggression: Option<i32> = None; 406 let mut ai_risk: Option<i32> = None; 407 let mut ai_focus: Option<i32> = None; 408 let mut ai_vision: Option<u32> = None; 409 let mut record_path: Option<String> = None; 410 let mut replay_path: Option<String> = None; 411 let mut turns: u32 = 1; 412 let mut snapshot_in: Option<String> = None; 413 let mut snapshot_out: Option<String> = None; 414 let mut profile = false; 415 let mut pack_path: Option<String> = None; 416 let mut validate_pack_path: Option<String> = None; 417 let mut campaign_path: Option<String> = None; 418 let mut validate_campaign_path: Option<String> = None; 419 let mut campaign_run: u32 = 0; 420 let mut campaign_state_in: Option<String> = None; 421 let mut campaign_state_out: Option<String> = None; 422 let mut metric_profile: Option<MetricProfile> = None; 423 424 let mut args = std::env::args().skip(1).peekable(); 425 while let Some(arg) = args.next() { 426 match arg.as_str() { 427 "--ticks" => { 428 let Some(value) = args.next() else { 429 eprintln!("Missing value for --ticks"); 430 print_usage(); 431 std::process::exit(2); 432 }; 433 ticks = parse_u32(value, "ticks"); 434 } 435 "--seed" => { 436 let Some(value) = args.next() else { 437 eprintln!("Missing value for --seed"); 438 print_usage(); 439 std::process::exit(2); 440 }; 441 seed = parse_u64(value, "seed"); 442 } 443 "--dump-json" => { 444 dump_json = true; 445 } 446 "--no-plan" => { 447 no_plan = true; 448 } 449 "--profile" => { 450 profile = true; 451 } 452 "--ai" => { 453 ai_enabled = true; 454 } 455 "--ai-team" => { 456 let Some(value) = args.next() else { 457 eprintln!("Missing value for --ai-team"); 458 print_usage(); 459 std::process::exit(2); 460 }; 461 ai_team = parse_u32(value, "ai-team") as u8; 462 ai_enabled = true; 463 } 464 "--ai-aggression" => { 465 let Some(value) = args.next() else { 466 eprintln!("Missing value for --ai-aggression"); 467 print_usage(); 468 std::process::exit(2); 469 }; 470 ai_aggression = Some(parse_i32(&value, "ai-aggression")); 471 ai_enabled = true; 472 } 473 "--ai-risk" => { 474 let Some(value) = args.next() else { 475 eprintln!("Missing value for --ai-risk"); 476 print_usage(); 477 std::process::exit(2); 478 }; 479 ai_risk = Some(parse_i32(&value, "ai-risk")); 480 ai_enabled = true; 481 } 482 "--ai-focus" => { 483 let Some(value) = args.next() else { 484 eprintln!("Missing value for --ai-focus"); 485 print_usage(); 486 std::process::exit(2); 487 }; 488 ai_focus = Some(parse_i32(&value, "ai-focus")); 489 ai_enabled = true; 490 } 491 "--ai-vision" => { 492 let Some(value) = args.next() else { 493 eprintln!("Missing value for --ai-vision"); 494 print_usage(); 495 std::process::exit(2); 496 }; 497 ai_vision = Some(parse_u32(value, "ai-vision")); 498 ai_enabled = true; 499 } 500 "--record" => { 501 let Some(value) = args.next() else { 502 eprintln!("Missing value for --record"); 503 print_usage(); 504 std::process::exit(2); 505 }; 506 record_path = Some(value); 507 } 508 "--replay" => { 509 let Some(value) = args.next() else { 510 eprintln!("Missing value for --replay"); 511 print_usage(); 512 std::process::exit(2); 513 }; 514 replay_path = Some(value); 515 } 516 "--turns" => { 517 let Some(value) = args.next() else { 518 eprintln!("Missing value for --turns"); 519 print_usage(); 520 std::process::exit(2); 521 }; 522 turns = parse_u32(value, "turns"); 523 } 524 "--snapshot-in" => { 525 let Some(value) = args.next() else { 526 eprintln!("Missing value for --snapshot-in"); 527 print_usage(); 528 std::process::exit(2); 529 }; 530 snapshot_in = Some(value); 531 } 532 "--snapshot-out" => { 533 let Some(value) = args.next() else { 534 eprintln!("Missing value for --snapshot-out"); 535 print_usage(); 536 std::process::exit(2); 537 }; 538 snapshot_out = Some(value); 539 } 540 "--pack" => { 541 let Some(value) = args.next() else { 542 eprintln!("Missing value for --pack"); 543 print_usage(); 544 std::process::exit(2); 545 }; 546 pack_path = Some(value); 547 } 548 "--validate-pack" => { 549 let Some(value) = args.next() else { 550 eprintln!("Missing value for --validate-pack"); 551 print_usage(); 552 std::process::exit(2); 553 }; 554 validate_pack_path = Some(value); 555 } 556 "--campaign" => { 557 let Some(value) = args.next() else { 558 eprintln!("Missing value for --campaign"); 559 print_usage(); 560 std::process::exit(2); 561 }; 562 campaign_path = Some(value); 563 } 564 "--validate-campaign" => { 565 let Some(value) = args.next() else { 566 eprintln!("Missing value for --validate-campaign"); 567 print_usage(); 568 std::process::exit(2); 569 }; 570 validate_campaign_path = Some(value); 571 } 572 "--campaign-run" => { 573 let Some(value) = args.next() else { 574 eprintln!("Missing value for --campaign-run"); 575 print_usage(); 576 std::process::exit(2); 577 }; 578 campaign_run = parse_u32(value, "campaign-run"); 579 } 580 "--campaign-state-in" => { 581 let Some(value) = args.next() else { 582 eprintln!("Missing value for --campaign-state-in"); 583 print_usage(); 584 std::process::exit(2); 585 }; 586 campaign_state_in = Some(value); 587 } 588 "--campaign-state-out" => { 589 let Some(value) = args.next() else { 590 eprintln!("Missing value for --campaign-state-out"); 591 print_usage(); 592 std::process::exit(2); 593 }; 594 campaign_state_out = Some(value); 595 } 596 "--metric-profile" => { 597 let Some(value) = args.next() else { 598 eprintln!("Missing value for --metric-profile"); 599 print_usage(); 600 std::process::exit(2); 601 }; 602 metric_profile = Some(parse_metric_profile(value)); 603 } 604 "--abilities" => { 605 let Some(value) = args.next() else { 606 eprintln!("Missing value for --abilities"); 607 print_usage(); 608 std::process::exit(2); 609 }; 610 abilities_path = Some(value); 611 } 612 "--validate-abilities" => { 613 let Some(value) = args.next() else { 614 eprintln!("Missing value for --validate-abilities"); 615 print_usage(); 616 std::process::exit(2); 617 }; 618 validate_path = Some(value); 619 } 620 "-h" | "--help" => { 621 print_usage(); 622 std::process::exit(0); 623 } 624 _ if arg.starts_with("--ticks=") => { 625 let value = arg.trim_start_matches("--ticks="); 626 ticks = parse_u32(value, "ticks"); 627 } 628 _ if arg.starts_with("--seed=") => { 629 let value = arg.trim_start_matches("--seed="); 630 seed = parse_u64(value, "seed"); 631 } 632 _ if arg.starts_with("--ai-team=") => { 633 let value = arg.trim_start_matches("--ai-team="); 634 ai_team = parse_u32(value, "ai-team") as u8; 635 ai_enabled = true; 636 } 637 _ if arg.starts_with("--ai-aggression=") => { 638 let value = arg.trim_start_matches("--ai-aggression="); 639 ai_aggression = Some(parse_i32(value, "ai-aggression")); 640 ai_enabled = true; 641 } 642 _ if arg.starts_with("--ai-risk=") => { 643 let value = arg.trim_start_matches("--ai-risk="); 644 ai_risk = Some(parse_i32(value, "ai-risk")); 645 ai_enabled = true; 646 } 647 _ if arg.starts_with("--ai-focus=") => { 648 let value = arg.trim_start_matches("--ai-focus="); 649 ai_focus = Some(parse_i32(value, "ai-focus")); 650 ai_enabled = true; 651 } 652 _ if arg.starts_with("--ai-vision=") => { 653 let value = arg.trim_start_matches("--ai-vision="); 654 ai_vision = Some(parse_u32(value, "ai-vision")); 655 ai_enabled = true; 656 } 657 _ if arg.starts_with("--record=") => { 658 let value = arg.trim_start_matches("--record="); 659 record_path = Some(value.to_string()); 660 } 661 _ if arg.starts_with("--replay=") => { 662 let value = arg.trim_start_matches("--replay="); 663 replay_path = Some(value.to_string()); 664 } 665 _ if arg.starts_with("--turns=") => { 666 let value = arg.trim_start_matches("--turns="); 667 turns = parse_u32(value, "turns"); 668 } 669 _ if arg.starts_with("--snapshot-in=") => { 670 let value = arg.trim_start_matches("--snapshot-in="); 671 snapshot_in = Some(value.to_string()); 672 } 673 _ if arg.starts_with("--snapshot-out=") => { 674 let value = arg.trim_start_matches("--snapshot-out="); 675 snapshot_out = Some(value.to_string()); 676 } 677 _ if arg == "--profile" => { 678 profile = true; 679 } 680 _ if arg.starts_with("--pack=") => { 681 let value = arg.trim_start_matches("--pack="); 682 pack_path = Some(value.to_string()); 683 } 684 _ if arg.starts_with("--validate-pack=") => { 685 let value = arg.trim_start_matches("--validate-pack="); 686 validate_pack_path = Some(value.to_string()); 687 } 688 _ if arg.starts_with("--campaign=") => { 689 let value = arg.trim_start_matches("--campaign="); 690 campaign_path = Some(value.to_string()); 691 } 692 _ if arg.starts_with("--validate-campaign=") => { 693 let value = arg.trim_start_matches("--validate-campaign="); 694 validate_campaign_path = Some(value.to_string()); 695 } 696 _ if arg.starts_with("--campaign-run=") => { 697 let value = arg.trim_start_matches("--campaign-run="); 698 campaign_run = parse_u32(value, "campaign-run"); 699 } 700 _ if arg.starts_with("--campaign-state-in=") => { 701 let value = arg.trim_start_matches("--campaign-state-in="); 702 campaign_state_in = Some(value.to_string()); 703 } 704 _ if arg.starts_with("--campaign-state-out=") => { 705 let value = arg.trim_start_matches("--campaign-state-out="); 706 campaign_state_out = Some(value.to_string()); 707 } 708 _ if arg.starts_with("--metric-profile=") => { 709 let value = arg.trim_start_matches("--metric-profile="); 710 metric_profile = Some(parse_metric_profile(value)); 711 } 712 _ if arg.starts_with("--abilities=") => { 713 let value = arg.trim_start_matches("--abilities="); 714 abilities_path = Some(value.to_string()); 715 } 716 _ if arg.starts_with("--validate-abilities=") => { 717 let value = arg.trim_start_matches("--validate-abilities="); 718 validate_path = Some(value.to_string()); 719 } 720 _ => { 721 eprintln!("Unknown argument: {}", arg); 722 print_usage(); 723 std::process::exit(2); 724 } 725 } 726 } 727 728 Args { 729 ticks, 730 seed, 731 dump_json, 732 no_plan, 733 abilities_path, 734 validate_path, 735 ai_enabled, 736 ai_team, 737 ai_aggression, 738 ai_risk, 739 ai_focus, 740 ai_vision, 741 record_path, 742 replay_path, 743 turns, 744 snapshot_in, 745 snapshot_out, 746 profile, 747 pack_path, 748 validate_pack_path, 749 campaign_path, 750 validate_campaign_path, 751 campaign_run, 752 campaign_state_in, 753 campaign_state_out, 754 metric_profile, 755 } 756 } 757 758 fn load_abilities(path: &str) -> Result<AbilitySet, String> { 759 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 760 if path.ends_with(".toml") { 761 toml::from_str(&data).map_err(|err| err.to_string()) 762 } else { 763 serde_json::from_str(&data).map_err(|err| err.to_string()) 764 } 765 } 766 767 fn validate_abilities(path: &str) -> Result<(), String> { 768 let abilities = load_abilities(path)?; 769 if let Err(errors) = abilities.validate() { 770 return Err(errors.join("; ")); 771 } 772 Ok(()) 773 } 774 775 fn load_pack_manifest(path: &str) -> Result<ContentPack, String> { 776 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 777 if path.ends_with(".toml") { 778 toml::from_str(&data).map_err(|err| err.to_string()) 779 } else { 780 serde_json::from_str(&data).map_err(|err| err.to_string()) 781 } 782 } 783 784 const PACK_HASH_VERSION: u32 = 1; 785 const PACK_HASH_ALGO: &str = "sha256"; 786 const PACK_CACHE_VERSION: u32 = 3; 787 const TUTORIAL_VERSION: u32 = 1; 788 789 #[derive(Serialize, Deserialize)] 790 struct PackHashManifest { 791 version: u32, 792 algorithm: String, 793 files: BTreeMap<String, String>, 794 } 795 796 fn resolve_relative(base: &str, rel: &str) -> PathBuf { 797 let base_path = Path::new(base); 798 let base_dir = base_path.parent().unwrap_or_else(|| Path::new("")); 799 base_dir.join(rel) 800 } 801 802 fn pack_hashes_path(pack_path: &str) -> PathBuf { 803 let base_path = Path::new(pack_path); 804 let base_dir = base_path.parent().unwrap_or_else(|| Path::new("")); 805 base_dir.join("pack.hashes.json") 806 } 807 808 fn pack_cache_path(pack_path: &str) -> PathBuf { 809 let base_path = Path::new(pack_path); 810 let base_dir = base_path.parent().unwrap_or_else(|| Path::new("")); 811 base_dir.join("pack.cache.json") 812 } 813 814 fn add_pack_path(pack_path: &str, rel: &str, out: &mut Vec<PathBuf>, seen: &mut HashSet<String>) { 815 let path = resolve_relative(pack_path, rel); 816 let key = path.to_string_lossy().to_string(); 817 if seen.insert(key) { 818 out.push(path); 819 } 820 } 821 822 fn collect_pack_files(pack_path: &str, pack: &ContentPack) -> Vec<PathBuf> { 823 let mut out = Vec::new(); 824 let mut seen = HashSet::new(); 825 let pack_manifest = Path::new(pack_path).to_path_buf(); 826 let key = pack_manifest.to_string_lossy().to_string(); 827 if seen.insert(key) { 828 out.push(pack_manifest); 829 } 830 add_pack_path(pack_path, &pack.abilities, &mut out, &mut seen); 831 if let Some(reactions) = &pack.reactions { 832 add_pack_path(pack_path, reactions, &mut out, &mut seen); 833 } 834 if let Some(director) = &pack.director { 835 add_pack_path(pack_path, director, &mut out, &mut seen); 836 } 837 if let Some(ai) = &pack.ai { 838 add_pack_path(pack_path, ai, &mut out, &mut seen); 839 } 840 if let Some(tutorial) = &pack.tutorial { 841 add_pack_path(pack_path, tutorial, &mut out, &mut seen); 842 } 843 if let Some(campaign) = &pack.campaign { 844 add_pack_path(pack_path, campaign, &mut out, &mut seen); 845 } 846 for map in &pack.maps { 847 add_pack_path(pack_path, map, &mut out, &mut seen); 848 } 849 for rel in pack 850 .assets 851 .sprites 852 .values() 853 .chain(pack.assets.audio.values()) 854 { 855 add_pack_path(pack_path, rel, &mut out, &mut seen); 856 } 857 for patch in &pack.patches { 858 add_pack_path(pack_path, &patch.path, &mut out, &mut seen); 859 } 860 out 861 } 862 863 fn relative_to_pack_dir(pack_path: &str, path: &Path) -> String { 864 let base_path = Path::new(pack_path); 865 let base_dir = base_path.parent().unwrap_or_else(|| Path::new("")); 866 path.strip_prefix(base_dir) 867 .unwrap_or(path) 868 .to_string_lossy() 869 .to_string() 870 } 871 872 fn build_pack_hash_manifest(pack_path: &str) -> Result<PackHashManifest, String> { 873 let pack = load_pack_manifest(pack_path)?; 874 let mut files = BTreeMap::new(); 875 for path in collect_pack_files(pack_path, &pack) { 876 let data = 877 fs::read(&path).map_err(|err| format!("Failed to read {}: {}", path.display(), err))?; 878 let hash = sha256_hex(&data); 879 let rel = relative_to_pack_dir(pack_path, &path); 880 files.insert(rel, hash); 881 } 882 Ok(PackHashManifest { 883 version: PACK_HASH_VERSION, 884 algorithm: PACK_HASH_ALGO.to_string(), 885 files, 886 }) 887 } 888 889 fn pack_hash_from_inputs(inputs: &BTreeMap<String, String>) -> String { 890 let mut data = String::new(); 891 for (path, hash) in inputs { 892 data.push_str(path); 893 data.push('\0'); 894 data.push_str(hash); 895 data.push('\n'); 896 } 897 sha256_hex(data.as_bytes()) 898 } 899 900 fn build_pack_cache_inputs( 901 root_pack_path: &str, 902 packs: &[(String, ContentPack)], 903 ) -> Result<BTreeMap<String, String>, String> { 904 let mut inputs = BTreeMap::new(); 905 let mut seen = HashSet::new(); 906 for (pack_path, pack) in packs { 907 for path in collect_pack_files(pack_path, pack) { 908 let key = path.to_string_lossy().to_string(); 909 if !seen.insert(key) { 910 continue; 911 } 912 let data = fs::read(&path) 913 .map_err(|err| format!("Failed to read {}: {}", path.display(), err))?; 914 let hash = sha256_hex(&data); 915 let rel = relative_to_pack_dir(root_pack_path, &path); 916 inputs.insert(rel, hash); 917 } 918 } 919 Ok(inputs) 920 } 921 922 fn write_pack_hashes(pack_path: &str, out_path: Option<String>) -> Result<(), String> { 923 let manifest = build_pack_hash_manifest(pack_path)?; 924 let encoded = serde_json::to_string_pretty(&manifest).map_err(|err| err.to_string())?; 925 let out_path = out_path 926 .map(PathBuf::from) 927 .unwrap_or_else(|| pack_hashes_path(pack_path)); 928 fs::write(&out_path, encoded).map_err(|err| err.to_string())?; 929 Ok(()) 930 } 931 932 fn verify_pack_hashes(pack_path: &str, manifest_path: Option<String>) -> Result<(), String> { 933 let manifest_path = manifest_path 934 .map(PathBuf::from) 935 .unwrap_or_else(|| pack_hashes_path(pack_path)); 936 let data = fs::read_to_string(&manifest_path).map_err(|err| err.to_string())?; 937 let manifest: PackHashManifest = serde_json::from_str(&data).map_err(|err| err.to_string())?; 938 if !manifest.algorithm.eq_ignore_ascii_case(PACK_HASH_ALGO) { 939 return Err(format!("Unsupported hash algorithm {}", manifest.algorithm)); 940 } 941 let base_path = Path::new(pack_path); 942 let base_dir = base_path.parent().unwrap_or_else(|| Path::new("")); 943 for (rel, expected) in manifest.files { 944 let path = if Path::new(&rel).is_absolute() { 945 PathBuf::from(&rel) 946 } else { 947 base_dir.join(&rel) 948 }; 949 let data = 950 fs::read(&path).map_err(|err| format!("Failed to read {}: {}", path.display(), err))?; 951 let actual = sha256_hex(&data); 952 if !actual.eq_ignore_ascii_case(&expected) { 953 return Err(format!( 954 "Integrity mismatch for {} (expected {}, got {})", 955 rel, expected, actual 956 )); 957 } 958 } 959 Ok(()) 960 } 961 962 const MODS_MANIFEST_VERSION: u32 = 1; 963 const MOD_REGISTRY_VERSION: u32 = 1; 964 965 #[derive(Clone, Debug, Default, Serialize, Deserialize)] 966 #[serde(default)] 967 struct ModEntry { 968 id: String, 969 enabled: bool, 970 path: Option<String>, 971 allow_unsigned: bool, 972 allow_scripts: bool, 973 } 974 975 #[derive(Clone, Debug, Default, Serialize, Deserialize)] 976 #[serde(default)] 977 struct ModManifest { 978 version: u32, 979 mods: Vec<ModEntry>, 980 } 981 982 #[derive(Clone, Debug, Default, Serialize, Deserialize)] 983 #[serde(default)] 984 struct ModRegistry { 985 version: u32, 986 mods: Vec<ModRegistryEntry>, 987 } 988 989 #[derive(Clone, Debug, Serialize, Deserialize)] 990 struct ModRegistryEntry { 991 id: String, 992 name: String, 993 version: SemVer, 994 path: String, 995 sha256: String, 996 signature: Option<PackSignature>, 997 } 998 999 fn mods_manifest_path() -> PathBuf { 1000 if let Ok(value) = std::env::var("DYNOSTIC_MODS_MANIFEST") { 1001 if !value.trim().is_empty() { 1002 return PathBuf::from(value); 1003 } 1004 } 1005 Path::new("mods").join("mods.json") 1006 } 1007 1008 fn mod_registry_path() -> PathBuf { 1009 if let Ok(value) = std::env::var("DYNOSTIC_MODS_REGISTRY") { 1010 if !value.trim().is_empty() { 1011 return PathBuf::from(value); 1012 } 1013 } 1014 Path::new("mods").join("registry.json") 1015 } 1016 1017 fn load_mod_manifest(path: &Path) -> Result<ModManifest, String> { 1018 if !path.exists() { 1019 return Ok(ModManifest { 1020 version: MODS_MANIFEST_VERSION, 1021 mods: Vec::new(), 1022 }); 1023 } 1024 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 1025 let mut manifest: ModManifest = serde_json::from_str(&data).map_err(|err| err.to_string())?; 1026 if manifest.version == 0 { 1027 manifest.version = MODS_MANIFEST_VERSION; 1028 } 1029 Ok(manifest) 1030 } 1031 1032 fn save_mod_manifest(path: &Path, manifest: &ModManifest) -> Result<(), String> { 1033 if let Some(parent) = path.parent() { 1034 fs::create_dir_all(parent).map_err(|err| err.to_string())?; 1035 } 1036 let encoded = serde_json::to_string_pretty(manifest).map_err(|err| err.to_string())?; 1037 fs::write(path, encoded).map_err(|err| err.to_string())?; 1038 Ok(()) 1039 } 1040 1041 fn load_mod_registry(path: &Path) -> Result<ModRegistry, String> { 1042 if !path.exists() { 1043 return Ok(ModRegistry { 1044 version: MOD_REGISTRY_VERSION, 1045 mods: Vec::new(), 1046 }); 1047 } 1048 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 1049 let mut registry: ModRegistry = serde_json::from_str(&data).map_err(|err| err.to_string())?; 1050 if registry.version == 0 { 1051 registry.version = MOD_REGISTRY_VERSION; 1052 } 1053 Ok(registry) 1054 } 1055 1056 fn save_mod_registry(path: &Path, registry: &ModRegistry) -> Result<(), String> { 1057 if let Some(parent) = path.parent() { 1058 fs::create_dir_all(parent).map_err(|err| err.to_string())?; 1059 } 1060 let encoded = serde_json::to_string_pretty(registry).map_err(|err| err.to_string())?; 1061 fs::write(path, encoded).map_err(|err| err.to_string())?; 1062 Ok(()) 1063 } 1064 1065 fn load_json_value(path: &Path) -> Result<Value, String> { 1066 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 1067 if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 1068 if ext.eq_ignore_ascii_case("toml") { 1069 let toml_value: toml::Value = toml::from_str(&data).map_err(|err| err.to_string())?; 1070 return serde_json::to_value(toml_value).map_err(|err| err.to_string()); 1071 } 1072 } 1073 serde_json::from_str(&data).map_err(|err| err.to_string()) 1074 } 1075 1076 fn apply_merge_patch(base: &mut Value, patch: Value) { 1077 match patch { 1078 Value::Object(patch_map) => { 1079 if !base.is_object() { 1080 *base = Value::Object(serde_json::Map::new()); 1081 } 1082 let base_map = base.as_object_mut().unwrap(); 1083 for (key, value) in patch_map { 1084 if value.is_null() { 1085 base_map.remove(&key); 1086 } else { 1087 let entry = base_map.entry(key).or_insert(Value::Null); 1088 apply_merge_patch(entry, value); 1089 } 1090 } 1091 } 1092 other => { 1093 *base = other; 1094 } 1095 } 1096 } 1097 1098 fn apply_patch(base: &mut Value, patch: Value, format: Option<&str>) -> Result<(), String> { 1099 let format = format.unwrap_or("merge"); 1100 if !format.eq_ignore_ascii_case("merge") { 1101 return Err(format!("unsupported patch format {}", format)); 1102 } 1103 apply_merge_patch(base, patch); 1104 Ok(()) 1105 } 1106 1107 fn patch_target_matches(patch: &PackPatch, target: &str) -> bool { 1108 patch.target.eq_ignore_ascii_case(target) 1109 } 1110 1111 fn apply_patches( 1112 base: &mut Value, 1113 pack: &ContentPack, 1114 pack_path: &str, 1115 target: &str, 1116 ) -> Result<(), String> { 1117 for patch in &pack.patches { 1118 if !patch_target_matches(patch, target) { 1119 continue; 1120 } 1121 let patch_path = resolve_relative(pack_path, &patch.path); 1122 let patch_value = load_json_value(&patch_path)?; 1123 apply_patch(base, patch_value, patch.format.as_deref())?; 1124 } 1125 Ok(()) 1126 } 1127 1128 fn load_assets_with_patches(pack: &ContentPack, pack_path: &str) -> Result<AssetManifest, String> { 1129 let mut value = serde_json::to_value(&pack.assets).map_err(|err| err.to_string())?; 1130 apply_patches(&mut value, pack, pack_path, "assets")?; 1131 serde_json::from_value(value).map_err(|err| err.to_string()) 1132 } 1133 1134 fn load_maps_with_patches(pack: &ContentPack, pack_path: &str) -> Result<Vec<String>, String> { 1135 let mut value = serde_json::to_value(&pack.maps).map_err(|err| err.to_string())?; 1136 apply_patches(&mut value, pack, pack_path, "maps")?; 1137 serde_json::from_value(value).map_err(|err| err.to_string()) 1138 } 1139 1140 fn load_abilities_with_patches(pack: &ContentPack, pack_path: &str) -> Result<AbilitySet, String> { 1141 let abilities_path = resolve_relative(pack_path, &pack.abilities); 1142 let mut value = load_json_value(&abilities_path)?; 1143 apply_patches(&mut value, pack, pack_path, "abilities")?; 1144 serde_json::from_value(value).map_err(|err| err.to_string()) 1145 } 1146 1147 fn load_reactions_with_patches(pack: &ContentPack, pack_path: &str) -> Result<ReactionSet, String> { 1148 let Some(reactions_path) = &pack.reactions else { 1149 return Ok(ReactionSet::default()); 1150 }; 1151 let reactions_path = resolve_relative(pack_path, reactions_path); 1152 let mut value = load_json_value(&reactions_path)?; 1153 apply_patches(&mut value, pack, pack_path, "reactions")?; 1154 serde_json::from_value(value).map_err(|err| err.to_string()) 1155 } 1156 1157 fn load_director_with_patches( 1158 pack: &ContentPack, 1159 pack_path: &str, 1160 ) -> Result<Option<DirectorConfig>, String> { 1161 let Some(director_path) = &pack.director else { 1162 return Ok(None); 1163 }; 1164 let director_path = resolve_relative(pack_path, director_path); 1165 let mut value = load_json_value(&director_path)?; 1166 apply_patches(&mut value, pack, pack_path, "director")?; 1167 let config: DirectorConfig = serde_json::from_value(value).map_err(|err| err.to_string())?; 1168 if let Err(errors) = config.validate() { 1169 return Err(errors.join("; ")); 1170 } 1171 Ok(Some(config)) 1172 } 1173 1174 fn load_ai_with_patches( 1175 pack: &ContentPack, 1176 pack_path: &str, 1177 ) -> Result<Option<AiBehaviorConfig>, String> { 1178 let Some(ai_path) = &pack.ai else { 1179 return Ok(None); 1180 }; 1181 let ai_path = resolve_relative(pack_path, ai_path); 1182 let mut value = load_json_value(&ai_path)?; 1183 apply_patches(&mut value, pack, pack_path, "ai")?; 1184 let config: AiBehaviorConfig = serde_json::from_value(value).map_err(|err| err.to_string())?; 1185 if let Err(errors) = config.validate() { 1186 return Err(errors.join("; ")); 1187 } 1188 Ok(Some(config)) 1189 } 1190 1191 fn validate_tutorial_condition_value(value: &Value, path: &str, errors: &mut Vec<String>) { 1192 if value.is_null() { 1193 return; 1194 } 1195 let Some(obj) = value.as_object() else { 1196 errors.push(format!("{} must be an object", path)); 1197 return; 1198 }; 1199 if let Some(any) = obj.get("any") { 1200 let Some(arr) = any.as_array() else { 1201 errors.push(format!("{}.any must be an array", path)); 1202 return; 1203 }; 1204 if arr.is_empty() { 1205 errors.push(format!("{}.any must be non-empty", path)); 1206 } 1207 for (index, entry) in arr.iter().enumerate() { 1208 validate_tutorial_condition_value(entry, &format!("{}.any[{}]", path, index), errors); 1209 } 1210 return; 1211 } 1212 if let Some(all) = obj.get("all") { 1213 let Some(arr) = all.as_array() else { 1214 errors.push(format!("{}.all must be an array", path)); 1215 return; 1216 }; 1217 if arr.is_empty() { 1218 errors.push(format!("{}.all must be non-empty", path)); 1219 } 1220 for (index, entry) in arr.iter().enumerate() { 1221 validate_tutorial_condition_value(entry, &format!("{}.all[{}]", path, index), errors); 1222 } 1223 return; 1224 } 1225 let ctype = obj 1226 .get("type") 1227 .and_then(|value| value.as_str()) 1228 .unwrap_or(""); 1229 if ctype.is_empty() { 1230 errors.push(format!("{} missing type", path)); 1231 return; 1232 } 1233 match ctype { 1234 "event" => { 1235 let kind = obj.get("kind"); 1236 if kind.is_none() { 1237 errors.push(format!("{} event kind is missing", path)); 1238 } else if !matches!(kind, Some(Value::String(_)) | Some(Value::Number(_))) { 1239 errors.push(format!("{} event kind must be string or number", path)); 1240 } 1241 } 1242 "phase" => { 1243 let phase = obj.get("phase"); 1244 if phase.is_none() { 1245 errors.push(format!("{} phase is missing", path)); 1246 } else if !matches!(phase, Some(Value::String(_)) | Some(Value::Number(_))) { 1247 errors.push(format!("{} phase must be string or number", path)); 1248 } 1249 } 1250 "tick" => { 1251 let tick = obj.get("tick").or_else(|| obj.get("min_tick")); 1252 if !matches!(tick, Some(Value::Number(_))) { 1253 errors.push(format!("{} tick must be a number", path)); 1254 } 1255 } 1256 "plan" => { 1257 let intent = obj.get("intent"); 1258 if let Some(intent) = intent { 1259 if !intent.is_string() { 1260 errors.push(format!("{} intent must be a string", path)); 1261 } 1262 } 1263 } 1264 _ => { 1265 errors.push(format!("{} invalid type '{}'", path, ctype)); 1266 } 1267 } 1268 } 1269 1270 fn validate_tutorial_value(value: &Value) -> Result<(), String> { 1271 let mut errors = Vec::new(); 1272 let Some(obj) = value.as_object() else { 1273 return Err("tutorial config must be an object".to_string()); 1274 }; 1275 let Some(version) = obj.get("version").and_then(|value| value.as_u64()) else { 1276 return Err("tutorial.version must be a number".to_string()); 1277 }; 1278 if version as u32 != TUTORIAL_VERSION { 1279 errors.push("tutorial.version mismatch".to_string()); 1280 } 1281 let Some(steps) = obj.get("steps").and_then(|value| value.as_array()) else { 1282 return Err("tutorial.steps must be an array".to_string()); 1283 }; 1284 if steps.is_empty() { 1285 errors.push("tutorial.steps is empty".to_string()); 1286 } 1287 for (index, step) in steps.iter().enumerate() { 1288 let Some(step_obj) = step.as_object() else { 1289 errors.push(format!("tutorial.steps[{}] must be an object", index)); 1290 continue; 1291 }; 1292 let id = step_obj 1293 .get("id") 1294 .and_then(|value| value.as_str()) 1295 .unwrap_or(""); 1296 if id.trim().is_empty() { 1297 errors.push(format!("tutorial.steps[{}].id is empty", index)); 1298 } 1299 let text = step_obj.get("text").and_then(|value| value.as_str()); 1300 let text_id = step_obj.get("text_id").and_then(|value| value.as_str()); 1301 if text.is_none() && text_id.is_none() { 1302 errors.push(format!("tutorial.steps[{}] missing text or text_id", index)); 1303 } 1304 if let Some(trigger) = step_obj.get("trigger") { 1305 validate_tutorial_condition_value( 1306 trigger, 1307 &format!("tutorial.steps[{}].trigger", index), 1308 &mut errors, 1309 ); 1310 } 1311 if let Some(complete) = step_obj.get("complete") { 1312 validate_tutorial_condition_value( 1313 complete, 1314 &format!("tutorial.steps[{}].complete", index), 1315 &mut errors, 1316 ); 1317 } 1318 if let Some(fail) = step_obj.get("fail") { 1319 validate_tutorial_condition_value( 1320 fail, 1321 &format!("tutorial.steps[{}].fail", index), 1322 &mut errors, 1323 ); 1324 } 1325 if let Some(allowed) = step_obj.get("allowed") { 1326 let Some(arr) = allowed.as_array() else { 1327 errors.push(format!( 1328 "tutorial.steps[{}].allowed must be an array", 1329 index 1330 )); 1331 continue; 1332 }; 1333 if arr.is_empty() { 1334 errors.push(format!("tutorial.steps[{}].allowed is empty", index)); 1335 } 1336 for (aidx, action) in arr.iter().enumerate() { 1337 if action.as_str().is_none_or(|value| value.trim().is_empty()) { 1338 errors.push(format!( 1339 "tutorial.steps[{}].allowed[{}] must be a string", 1340 index, aidx 1341 )); 1342 } 1343 } 1344 } 1345 } 1346 if errors.is_empty() { 1347 Ok(()) 1348 } else { 1349 Err(errors.join("; ")) 1350 } 1351 } 1352 1353 fn load_tutorial_with_patches( 1354 pack: &ContentPack, 1355 pack_path: &str, 1356 ) -> Result<Option<Value>, String> { 1357 let Some(tutorial_path) = &pack.tutorial else { 1358 return Ok(None); 1359 }; 1360 let tutorial_path = resolve_relative(pack_path, tutorial_path); 1361 let mut value = load_json_value(&tutorial_path)?; 1362 apply_patches(&mut value, pack, pack_path, "tutorial")?; 1363 validate_tutorial_value(&value)?; 1364 Ok(Some(value)) 1365 } 1366 1367 fn load_campaign_with_patches( 1368 pack: &ContentPack, 1369 pack_path: &str, 1370 ) -> Result<Option<CampaignDefinition>, String> { 1371 let Some(campaign_path) = &pack.campaign else { 1372 return Ok(None); 1373 }; 1374 let campaign_path = resolve_relative(pack_path, campaign_path); 1375 let mut value = load_json_value(&campaign_path)?; 1376 apply_patches(&mut value, pack, pack_path, "campaign")?; 1377 let campaign = serde_json::from_value(value).map_err(|err| err.to_string())?; 1378 Ok(Some(campaign)) 1379 } 1380 1381 fn validate_pack(path: &str) -> Result<(), String> { 1382 let mut pack = load_pack_manifest(path)?; 1383 let assets = load_assets_with_patches(&pack, path)?; 1384 pack.assets = assets; 1385 let maps = load_maps_with_patches(&pack, path)?; 1386 pack.maps = maps; 1387 validate_map_projection_policy(path, &pack.maps)?; 1388 let abilities = load_abilities_with_patches(&pack, path)?; 1389 if let Err(errors) = abilities.validate() { 1390 return Err(errors.join("; ")); 1391 } 1392 let reactions = load_reactions_with_patches(&pack, path)?; 1393 if let Err(errors) = reactions.validate() { 1394 return Err(errors.join("; ")); 1395 } 1396 if let Err(errors) = pack.validate(Some(&abilities)) { 1397 return Err(errors.join("; ")); 1398 } 1399 if let Some(_director) = load_director_with_patches(&pack, path)? { 1400 // Director config is validated by load_director_with_patches. 1401 } 1402 if let Some(_ai) = load_ai_with_patches(&pack, path)? { 1403 // AI config is validated by load_ai_with_patches. 1404 } 1405 if let Some(_tutorial) = load_tutorial_with_patches(&pack, path)? { 1406 // Tutorial config is validated by load_tutorial_with_patches. 1407 } 1408 if let Some(campaign) = load_campaign_with_patches(&pack, path)? { 1409 if let Err(errors) = campaign.validate() { 1410 return Err(errors.join("; ")); 1411 } 1412 } 1413 Ok(()) 1414 } 1415 1416 fn load_campaign(path: &str) -> Result<CampaignDefinition, String> { 1417 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 1418 serde_json::from_str(&data).map_err(|err| err.to_string()) 1419 } 1420 1421 fn validate_campaign(path: &str) -> Result<(), String> { 1422 let campaign = load_campaign(path)?; 1423 campaign.validate().map_err(|errors| errors.join("; ")) 1424 } 1425 1426 #[derive(Clone, Debug)] 1427 struct ResolvedDependency { 1428 id: String, 1429 path: String, 1430 pack: ContentPack, 1431 version: SemVer, 1432 } 1433 1434 #[derive(Clone, Debug)] 1435 struct PackResources { 1436 pack: ContentPack, 1437 path: String, 1438 abilities: AbilitySet, 1439 reactions: ReactionSet, 1440 director: Option<DirectorConfig>, 1441 ai: Option<AiBehaviorConfig>, 1442 tutorial: Option<serde_json::Value>, 1443 assets: AssetManifest, 1444 maps: Vec<String>, 1445 } 1446 1447 #[derive(Clone, Debug)] 1448 struct ResolvedPackContext { 1449 pack: ContentPack, 1450 abilities: AbilitySet, 1451 reactions: ReactionSet, 1452 director: Option<DirectorConfig>, 1453 ai: Option<AiBehaviorConfig>, 1454 tutorial: Option<serde_json::Value>, 1455 assets: AssetManifest, 1456 campaign: Option<CampaignDefinition>, 1457 maps: Vec<String>, 1458 warnings: Vec<String>, 1459 } 1460 1461 #[derive(Serialize)] 1462 struct StableAssetManifest { 1463 sprites: BTreeMap<String, String>, 1464 audio: BTreeMap<String, String>, 1465 } 1466 1467 #[derive(Serialize)] 1468 struct StableContentPack { 1469 id: String, 1470 name: String, 1471 version: SemVer, 1472 engine: dynostic_core::PackEngineCompat, 1473 permissions: PackPermissions, 1474 abilities: String, 1475 reactions: Option<String>, 1476 director: Option<String>, 1477 ai: Option<String>, 1478 tutorial: Option<String>, 1479 campaign: Option<String>, 1480 assets: StableAssetManifest, 1481 dependencies: Vec<PackDependency>, 1482 patches: Vec<PackPatch>, 1483 maps: Vec<String>, 1484 } 1485 1486 #[derive(Serialize)] 1487 struct ResolvedPackOutput { 1488 pack: StableContentPack, 1489 abilities: AbilitySet, 1490 reactions: ReactionSet, 1491 director: Option<DirectorConfig>, 1492 ai: Option<AiBehaviorConfig>, 1493 tutorial: Option<serde_json::Value>, 1494 campaign: Option<CampaignDefinition>, 1495 } 1496 1497 #[derive(Serialize)] 1498 struct PackCacheContext { 1499 pack: StableContentPack, 1500 abilities: AbilitySet, 1501 reactions: ReactionSet, 1502 director: Option<DirectorConfig>, 1503 ai: Option<AiBehaviorConfig>, 1504 tutorial: Option<serde_json::Value>, 1505 campaign: Option<CampaignDefinition>, 1506 } 1507 1508 #[derive(Serialize)] 1509 struct PackCacheManifest { 1510 version: u32, 1511 engine: SemVer, 1512 pack_hash: String, 1513 inputs: BTreeMap<String, String>, 1514 context: PackCacheContext, 1515 } 1516 1517 fn sorted_dependencies(pack: &ContentPack) -> Vec<PackDependency> { 1518 let mut deps = pack.dependencies.clone(); 1519 deps.sort_by(|a, b| a.id.cmp(&b.id)); 1520 deps 1521 } 1522 1523 fn version_in_range(version: SemVer, min: Option<SemVer>, max: Option<SemVer>) -> bool { 1524 if let Some(min) = min { 1525 if version < min { 1526 return false; 1527 } 1528 } 1529 if let Some(max) = max { 1530 if version > max { 1531 return false; 1532 } 1533 } 1534 true 1535 } 1536 1537 fn default_dependency_path(dep_id: &str) -> PathBuf { 1538 let base = Path::new("mods").join(dep_id); 1539 let toml_path = base.join("pack.toml"); 1540 if toml_path.exists() { 1541 return toml_path; 1542 } 1543 base.join("pack.json") 1544 } 1545 1546 fn resolve_pack_dependencies( 1547 root_pack: &ContentPack, 1548 root_path: &str, 1549 ) -> Result<Vec<ResolvedDependency>, String> { 1550 let mut resolved: HashMap<String, ResolvedDependency> = HashMap::new(); 1551 let mut visiting: HashSet<String> = HashSet::new(); 1552 let mut ordered: Vec<ResolvedDependency> = Vec::new(); 1553 1554 fn visit( 1555 dep: &PackDependency, 1556 parent_path: &str, 1557 resolved: &mut HashMap<String, ResolvedDependency>, 1558 visiting: &mut HashSet<String>, 1559 ordered: &mut Vec<ResolvedDependency>, 1560 ) -> Result<(), String> { 1561 let dep_id = dep.id.trim(); 1562 if dep_id.is_empty() { 1563 return Err("dependency id is empty".to_string()); 1564 } 1565 if visiting.contains(dep_id) { 1566 return Err(format!("dependency cycle detected: {}", dep_id)); 1567 } 1568 if let Some(existing) = resolved.get(dep_id) { 1569 if !version_in_range(existing.version, dep.min, dep.max) { 1570 return Err(format!("dependency {} version mismatch", dep_id)); 1571 } 1572 return Ok(()); 1573 } 1574 1575 visiting.insert(dep_id.to_string()); 1576 let dep_path = if let Some(path) = &dep.path { 1577 resolve_relative(parent_path, path) 1578 } else { 1579 default_dependency_path(dep_id) 1580 }; 1581 let dep_path_str = dep_path 1582 .to_str() 1583 .ok_or_else(|| "invalid dependency path".to_string())?; 1584 let dep_pack = load_pack_manifest(dep_path_str)?; 1585 let dep_version = dep_pack.version; 1586 if !version_in_range(dep_version, dep.min, dep.max) { 1587 return Err(format!("dependency {} version out of range", dep_id)); 1588 } 1589 1590 let entry = ResolvedDependency { 1591 id: dep_id.to_string(), 1592 path: dep_path_str.to_string(), 1593 pack: dep_pack.clone(), 1594 version: dep_version, 1595 }; 1596 resolved.insert(dep_id.to_string(), entry.clone()); 1597 ordered.push(entry); 1598 1599 for child in sorted_dependencies(&dep_pack) { 1600 visit(&child, dep_path_str, resolved, visiting, ordered)?; 1601 } 1602 1603 visiting.remove(dep_id); 1604 Ok(()) 1605 } 1606 1607 for dep in sorted_dependencies(root_pack) { 1608 visit(&dep, root_path, &mut resolved, &mut visiting, &mut ordered)?; 1609 } 1610 1611 Ok(ordered) 1612 } 1613 1614 fn load_pack_resources(pack: ContentPack, path: &str) -> Result<PackResources, String> { 1615 let mut pack = pack; 1616 let assets = load_assets_with_patches(&pack, path)?; 1617 let maps = load_maps_with_patches(&pack, path)?; 1618 pack.assets = assets.clone(); 1619 pack.maps = maps.clone(); 1620 validate_map_projection_policy(path, &maps)?; 1621 let abilities = load_abilities_with_patches(&pack, path)?; 1622 if let Err(errors) = abilities.validate() { 1623 return Err(errors.join("; ")); 1624 } 1625 let reactions = load_reactions_with_patches(&pack, path)?; 1626 if let Err(errors) = reactions.validate() { 1627 return Err(errors.join("; ")); 1628 } 1629 if let Err(errors) = pack.validate(Some(&abilities)) { 1630 return Err(errors.join("; ")); 1631 } 1632 let director = load_director_with_patches(&pack, path)?; 1633 let ai = load_ai_with_patches(&pack, path)?; 1634 let tutorial = load_tutorial_with_patches(&pack, path)?; 1635 Ok(PackResources { 1636 pack, 1637 path: path.to_string(), 1638 abilities, 1639 reactions, 1640 director, 1641 ai, 1642 tutorial, 1643 assets, 1644 maps, 1645 }) 1646 } 1647 1648 fn merge_assets(into: &mut AssetManifest, from: &AssetManifest, overwrite: bool) { 1649 for (key, value) in &from.sprites { 1650 if overwrite || !into.sprites.contains_key(key) { 1651 into.sprites.insert(key.clone(), value.clone()); 1652 } 1653 } 1654 for (key, value) in &from.audio { 1655 if overwrite || !into.audio.contains_key(key) { 1656 into.audio.insert(key.clone(), value.clone()); 1657 } 1658 } 1659 } 1660 1661 fn merge_maps(into: &mut Vec<String>, from: &[String]) { 1662 let mut seen: HashSet<String> = into.iter().cloned().collect(); 1663 for entry in from { 1664 if seen.insert(entry.clone()) { 1665 into.push(entry.clone()); 1666 } 1667 } 1668 } 1669 1670 fn merge_abilities(sets: Vec<(String, AbilitySet)>, warnings: &mut Vec<String>) -> AbilitySet { 1671 let mut by_id: HashMap<u32, AbilityDef> = HashMap::new(); 1672 for (source, set) in sets { 1673 for ability in set.abilities { 1674 if by_id.contains_key(&ability.id) { 1675 warnings.push(format!("ability {} overridden by {}", ability.id, source)); 1676 } 1677 by_id.insert(ability.id, ability); 1678 } 1679 } 1680 let mut abilities: Vec<AbilityDef> = by_id.into_values().collect(); 1681 abilities.sort_by_key(|ability| ability.id); 1682 AbilitySet { 1683 version: ABILITY_SET_VERSION, 1684 abilities, 1685 } 1686 } 1687 1688 fn merge_reactions(sets: Vec<(String, ReactionSet)>, warnings: &mut Vec<String>) -> ReactionSet { 1689 let mut by_id: HashMap<u32, ReactionDef> = HashMap::new(); 1690 for (source, set) in sets { 1691 for reaction in set.reactions { 1692 if by_id.contains_key(&reaction.id) { 1693 warnings.push(format!("reaction {} overridden by {}", reaction.id, source)); 1694 } 1695 by_id.insert(reaction.id, reaction); 1696 } 1697 } 1698 let mut reactions: Vec<ReactionDef> = by_id.into_values().collect(); 1699 reactions.sort_by_key(|reaction| reaction.id); 1700 ReactionSet { 1701 version: REACTION_SET_VERSION, 1702 reactions, 1703 } 1704 } 1705 1706 fn merge_director_configs( 1707 sets: Vec<(String, DirectorConfig)>, 1708 warnings: &mut Vec<String>, 1709 ) -> DirectorConfig { 1710 let mut barks: BTreeMap<String, dynostic_core::DirectorBark> = BTreeMap::new(); 1711 let mut music: BTreeMap<String, dynostic_core::DirectorMusicState> = BTreeMap::new(); 1712 let mut camera: BTreeMap<String, dynostic_core::DirectorCameraCue> = BTreeMap::new(); 1713 let mut global_bark_cooldown = 0_u64; 1714 1715 for (source, config) in sets { 1716 global_bark_cooldown = config.global_bark_cooldown; 1717 for bark in config.barks { 1718 if barks.contains_key(&bark.id) { 1719 warnings.push(format!( 1720 "director bark {} overridden by {}", 1721 bark.id, source 1722 )); 1723 } 1724 barks.insert(bark.id.clone(), bark); 1725 } 1726 for state in config.music { 1727 if music.contains_key(&state.id) { 1728 warnings.push(format!( 1729 "director music {} overridden by {}", 1730 state.id, source 1731 )); 1732 } 1733 music.insert(state.id.clone(), state); 1734 } 1735 for cue in config.camera { 1736 if camera.contains_key(&cue.id) { 1737 warnings.push(format!( 1738 "director camera {} overridden by {}", 1739 cue.id, source 1740 )); 1741 } 1742 camera.insert(cue.id.clone(), cue); 1743 } 1744 } 1745 1746 DirectorConfig { 1747 version: DirectorConfig::default().version, 1748 global_bark_cooldown, 1749 barks: barks.into_values().collect(), 1750 music: music.into_values().collect(), 1751 camera: camera.into_values().collect(), 1752 } 1753 } 1754 1755 fn merge_ai_configs( 1756 sets: Vec<(String, AiBehaviorConfig)>, 1757 warnings: &mut Vec<String>, 1758 ) -> AiBehaviorConfig { 1759 let mut profiles: BTreeMap<String, dynostic_core::AiBehaviorProfile> = BTreeMap::new(); 1760 let mut team_profiles: BTreeMap<u8, String> = BTreeMap::new(); 1761 let mut version = dynostic_core::AI_BEHAVIOR_VERSION; 1762 1763 for (source, config) in sets { 1764 version = config.version; 1765 for profile in config.profiles { 1766 if profiles.contains_key(&profile.id) { 1767 warnings.push(format!( 1768 "ai profile {} overridden by {}", 1769 profile.id, source 1770 )); 1771 } 1772 profiles.insert(profile.id.clone(), profile); 1773 } 1774 for (team, profile_id) in config.team_profiles { 1775 team_profiles.insert(team, profile_id); 1776 } 1777 } 1778 1779 AiBehaviorConfig { 1780 version, 1781 profiles: profiles.into_values().collect(), 1782 team_profiles, 1783 } 1784 } 1785 1786 fn merge_tutorial_configs(sets: Vec<(String, Value)>, warnings: &mut Vec<String>) -> Value { 1787 let mut version = TUTORIAL_VERSION; 1788 let mut settings: Map<String, Value> = Map::new(); 1789 let mut order: Vec<String> = Vec::new(); 1790 let mut steps: HashMap<String, Value> = HashMap::new(); 1791 1792 for (source, config) in sets { 1793 if let Some(value) = config.get("version").and_then(|value| value.as_u64()) { 1794 version = value as u32; 1795 } 1796 if let Some(obj) = config.get("settings").and_then(|value| value.as_object()) { 1797 for (key, value) in obj { 1798 settings.insert(key.clone(), value.clone()); 1799 } 1800 } 1801 if let Some(arr) = config.get("steps").and_then(|value| value.as_array()) { 1802 for step in arr { 1803 let id = step 1804 .get("id") 1805 .and_then(|value| value.as_str()) 1806 .unwrap_or("") 1807 .to_string(); 1808 if id.is_empty() { 1809 continue; 1810 } 1811 if steps.contains_key(&id) { 1812 warnings.push(format!("tutorial step {} overridden by {}", id, source)); 1813 } else { 1814 order.push(id.clone()); 1815 } 1816 steps.insert(id, step.clone()); 1817 } 1818 } 1819 } 1820 1821 let mut merged_steps = Vec::new(); 1822 for id in order { 1823 if let Some(step) = steps.get(&id) { 1824 merged_steps.push(step.clone()); 1825 } 1826 } 1827 1828 let mut merged = Map::new(); 1829 merged.insert("version".to_string(), Value::Number(Number::from(version))); 1830 if !settings.is_empty() { 1831 merged.insert("settings".to_string(), Value::Object(settings)); 1832 } 1833 merged.insert("steps".to_string(), Value::Array(merged_steps)); 1834 1835 Value::Object(merged) 1836 } 1837 1838 const GAMEPLAY_PROJECTION_ISOMETRIC: &str = "isometric"; 1839 1840 fn projection_mode_from_value(value: &Value, field: &str) -> Result<String, String> { 1841 match value { 1842 Value::String(raw) => { 1843 let normalized = raw.trim().to_ascii_lowercase(); 1844 if normalized.is_empty() { 1845 Err(format!("{} must be a non-empty string", field)) 1846 } else { 1847 Ok(normalized) 1848 } 1849 } 1850 _ => Err(format!("{} must be a string", field)), 1851 } 1852 } 1853 1854 fn map_gameplay_projection_mode(map: &Value) -> Result<Option<String>, String> { 1855 let Some(object) = map.as_object() else { 1856 return Ok(None); 1857 }; 1858 if let Some(value) = object.get("gameplay_projection") { 1859 return projection_mode_from_value(value, "map.gameplay_projection").map(Some); 1860 } 1861 let Some(projection) = object.get("projection") else { 1862 return Ok(None); 1863 }; 1864 match projection { 1865 Value::String(_) => projection_mode_from_value(projection, "map.projection").map(Some), 1866 Value::Object(projection_object) => { 1867 if let Some(value) = projection_object.get("gameplay") { 1868 projection_mode_from_value(value, "map.projection.gameplay").map(Some) 1869 } else if let Some(value) = projection_object.get("mode") { 1870 projection_mode_from_value(value, "map.projection.mode").map(Some) 1871 } else { 1872 Ok(None) 1873 } 1874 } 1875 _ => Err("map.projection must be a string or object".to_string()), 1876 } 1877 } 1878 1879 fn gameplay_projection_policy_violation(map: &Value) -> Result<Option<String>, String> { 1880 let Some(mode) = map_gameplay_projection_mode(map)? else { 1881 return Ok(None); 1882 }; 1883 if mode == GAMEPLAY_PROJECTION_ISOMETRIC { 1884 return Ok(None); 1885 } 1886 Ok(Some(format!( 1887 "gameplay projection must be {} (found {})", 1888 GAMEPLAY_PROJECTION_ISOMETRIC, mode 1889 ))) 1890 } 1891 1892 fn validate_map_projection_policy(pack_path: &str, maps: &[String]) -> Result<(), String> { 1893 for map in maps { 1894 let path = resolve_relative(pack_path, map); 1895 if !path.exists() { 1896 continue; 1897 } 1898 let data = fs::read_to_string(&path) 1899 .map_err(|err| format!("Failed to read map {}: {}", map, err))?; 1900 let parsed: Value = serde_json::from_str(&data) 1901 .map_err(|err| format!("Failed to parse map {}: {}", map, err))?; 1902 let violation = gameplay_projection_policy_violation(&parsed) 1903 .map_err(|err| format!("map {}: {}", map, err))?; 1904 if let Some(message) = violation { 1905 return Err(format!("map {}: {}", map, message)); 1906 } 1907 } 1908 Ok(()) 1909 } 1910 1911 fn warn_missing_assets(pack_path: &str, assets: &AssetManifest, warnings: &mut Vec<String>) { 1912 for (key, rel) in assets.sprites.iter().chain(assets.audio.iter()) { 1913 let path = resolve_relative(pack_path, rel); 1914 if !path.exists() { 1915 warnings.push(format!("missing asset {}: {}", key, rel)); 1916 } 1917 } 1918 } 1919 1920 fn warn_missing_maps(pack_path: &str, maps: &[String], warnings: &mut Vec<String>) { 1921 for map in maps { 1922 let path = resolve_relative(pack_path, map); 1923 if !path.exists() { 1924 warnings.push(format!("missing map: {}", map)); 1925 } 1926 } 1927 } 1928 1929 fn build_pack_context(pack_path: &str) -> Result<ResolvedPackContext, String> { 1930 let root_pack = load_pack_manifest(pack_path)?; 1931 let deps = resolve_pack_dependencies(&root_pack, pack_path)?; 1932 let mut warnings = Vec::new(); 1933 1934 let mut dep_resources = Vec::new(); 1935 for dep in deps { 1936 let res = load_pack_resources(dep.pack, &dep.path)?; 1937 warn_missing_assets(&res.path, &res.assets, &mut warnings); 1938 warn_missing_maps(&res.path, &res.maps, &mut warnings); 1939 dep_resources.push((dep.id, res)); 1940 } 1941 1942 let root_resources = load_pack_resources(root_pack, pack_path)?; 1943 warn_missing_assets(&root_resources.path, &root_resources.assets, &mut warnings); 1944 warn_missing_maps(&root_resources.path, &root_resources.maps, &mut warnings); 1945 1946 let mut merged_assets = AssetManifest::default(); 1947 for (_, res) in &dep_resources { 1948 merge_assets(&mut merged_assets, &res.assets, false); 1949 } 1950 merge_assets(&mut merged_assets, &root_resources.assets, true); 1951 1952 let mut merged_maps = Vec::new(); 1953 for (_, res) in &dep_resources { 1954 merge_maps(&mut merged_maps, &res.maps); 1955 } 1956 merge_maps(&mut merged_maps, &root_resources.maps); 1957 1958 let mut ability_sets = Vec::new(); 1959 for (id, res) in &dep_resources { 1960 ability_sets.push((id.clone(), res.abilities.clone())); 1961 } 1962 ability_sets.push(( 1963 root_resources.pack.id.clone(), 1964 root_resources.abilities.clone(), 1965 )); 1966 let merged_abilities = merge_abilities(ability_sets, &mut warnings); 1967 1968 let mut reaction_sets = Vec::new(); 1969 for (id, res) in &dep_resources { 1970 reaction_sets.push((id.clone(), res.reactions.clone())); 1971 } 1972 reaction_sets.push(( 1973 root_resources.pack.id.clone(), 1974 root_resources.reactions.clone(), 1975 )); 1976 let merged_reactions = merge_reactions(reaction_sets, &mut warnings); 1977 1978 let mut director_sets = Vec::new(); 1979 for (id, res) in &dep_resources { 1980 if let Some(config) = &res.director { 1981 director_sets.push((id.clone(), config.clone())); 1982 } 1983 } 1984 if let Some(config) = &root_resources.director { 1985 director_sets.push((root_resources.pack.id.clone(), config.clone())); 1986 } 1987 let merged_director = if director_sets.is_empty() { 1988 None 1989 } else { 1990 Some(merge_director_configs(director_sets, &mut warnings)) 1991 }; 1992 1993 let mut ai_sets = Vec::new(); 1994 for (id, res) in &dep_resources { 1995 if let Some(config) = &res.ai { 1996 ai_sets.push((id.clone(), config.clone())); 1997 } 1998 } 1999 if let Some(config) = &root_resources.ai { 2000 ai_sets.push((root_resources.pack.id.clone(), config.clone())); 2001 } 2002 let merged_ai = if ai_sets.is_empty() { 2003 None 2004 } else { 2005 Some(merge_ai_configs(ai_sets, &mut warnings)) 2006 }; 2007 2008 let mut tutorial_sets = Vec::new(); 2009 for (id, res) in &dep_resources { 2010 if let Some(config) = &res.tutorial { 2011 tutorial_sets.push((id.clone(), config.clone())); 2012 } 2013 } 2014 if let Some(config) = &root_resources.tutorial { 2015 tutorial_sets.push((root_resources.pack.id.clone(), config.clone())); 2016 } 2017 let merged_tutorial = if tutorial_sets.is_empty() { 2018 None 2019 } else { 2020 Some(merge_tutorial_configs(tutorial_sets, &mut warnings)) 2021 }; 2022 2023 let campaign = load_campaign_with_patches(&root_resources.pack, pack_path)?; 2024 2025 let mut resolved_pack = root_resources.pack.clone(); 2026 resolved_pack.assets = merged_assets.clone(); 2027 resolved_pack.maps = merged_maps.clone(); 2028 2029 Ok(ResolvedPackContext { 2030 pack: resolved_pack, 2031 abilities: merged_abilities, 2032 reactions: merged_reactions, 2033 director: merged_director, 2034 ai: merged_ai, 2035 tutorial: merged_tutorial, 2036 assets: merged_assets, 2037 campaign, 2038 maps: merged_maps, 2039 warnings, 2040 }) 2041 } 2042 2043 fn to_btreemap(map: &HashMap<String, String>) -> BTreeMap<String, String> { 2044 map.iter() 2045 .map(|(key, value)| (key.clone(), value.clone())) 2046 .collect() 2047 } 2048 2049 fn stable_pack( 2050 pack: &ContentPack, 2051 assets: &AssetManifest, 2052 maps: &[String], 2053 clear_deps: bool, 2054 clear_patches: bool, 2055 ) -> StableContentPack { 2056 let mut dependencies = if clear_deps { 2057 Vec::new() 2058 } else { 2059 pack.dependencies.clone() 2060 }; 2061 dependencies.sort_by(|a, b| a.id.cmp(&b.id)); 2062 2063 let mut patches = if clear_patches { 2064 Vec::new() 2065 } else { 2066 pack.patches.clone() 2067 }; 2068 patches.sort_by(|a, b| { 2069 a.target 2070 .cmp(&b.target) 2071 .then(a.format.cmp(&b.format)) 2072 .then(a.path.cmp(&b.path)) 2073 }); 2074 2075 let mut maps = maps.to_vec(); 2076 maps.sort(); 2077 2078 StableContentPack { 2079 id: pack.id.clone(), 2080 name: pack.name.clone(), 2081 version: pack.version, 2082 engine: pack.engine.clone(), 2083 permissions: pack.permissions.clone(), 2084 abilities: pack.abilities.clone(), 2085 reactions: pack.reactions.clone(), 2086 director: pack.director.clone(), 2087 ai: pack.ai.clone(), 2088 tutorial: pack.tutorial.clone(), 2089 campaign: pack.campaign.clone(), 2090 assets: StableAssetManifest { 2091 sprites: to_btreemap(&assets.sprites), 2092 audio: to_btreemap(&assets.audio), 2093 }, 2094 dependencies, 2095 patches, 2096 maps, 2097 } 2098 } 2099 2100 fn build_pack_cache_manifest( 2101 pack_path: &str, 2102 ctx: &ResolvedPackContext, 2103 ) -> Result<PackCacheManifest, String> { 2104 let root_pack = load_pack_manifest(pack_path)?; 2105 let deps = resolve_pack_dependencies(&root_pack, pack_path)?; 2106 let mut packs = Vec::new(); 2107 packs.push((pack_path.to_string(), root_pack)); 2108 for dep in deps { 2109 packs.push((dep.path.clone(), dep.pack)); 2110 } 2111 let inputs = build_pack_cache_inputs(pack_path, &packs)?; 2112 let pack_hash = pack_hash_from_inputs(&inputs); 2113 let stable = stable_pack(&ctx.pack, &ctx.assets, &ctx.maps, true, true); 2114 Ok(PackCacheManifest { 2115 version: PACK_CACHE_VERSION, 2116 engine: SemVer::current(), 2117 pack_hash, 2118 inputs, 2119 context: PackCacheContext { 2120 pack: stable, 2121 abilities: ctx.abilities.clone(), 2122 reactions: ctx.reactions.clone(), 2123 director: ctx.director.clone(), 2124 ai: ctx.ai.clone(), 2125 tutorial: ctx.tutorial.clone(), 2126 campaign: ctx.campaign.clone(), 2127 }, 2128 }) 2129 } 2130 2131 fn print_pack_usage() { 2132 eprintln!( 2133 "Usage: dynostic_cli pack <lint|resolve|build|cache|hash|verify|sign|verify-signature|keygen> [--pack PATH] [--out PATH] [--out-dir DIR] [--in PATH] [--key PATH]" 2134 ); 2135 } 2136 2137 type PackFlags = ( 2138 Option<String>, 2139 Option<String>, 2140 Option<String>, 2141 Option<String>, 2142 ); 2143 2144 fn parse_pack_flags(args: &[String]) -> Result<PackFlags, String> { 2145 let mut pack: Option<String> = None; 2146 let mut out: Option<String> = None; 2147 let mut out_dir: Option<String> = None; 2148 let mut input: Option<String> = None; 2149 let mut positional: Vec<String> = Vec::new(); 2150 let mut iter = args.iter().peekable(); 2151 while let Some(arg) = iter.next() { 2152 match arg.as_str() { 2153 "--pack" => { 2154 let value = iter 2155 .next() 2156 .ok_or_else(|| "Missing value for --pack".to_string())?; 2157 pack = Some(value.clone()); 2158 } 2159 "--out" => { 2160 let value = iter 2161 .next() 2162 .ok_or_else(|| "Missing value for --out".to_string())?; 2163 out = Some(value.clone()); 2164 } 2165 "--out-dir" => { 2166 let value = iter 2167 .next() 2168 .ok_or_else(|| "Missing value for --out-dir".to_string())?; 2169 out_dir = Some(value.clone()); 2170 } 2171 "--in" => { 2172 let value = iter 2173 .next() 2174 .ok_or_else(|| "Missing value for --in".to_string())?; 2175 input = Some(value.clone()); 2176 } 2177 _ if arg.starts_with("--pack=") => { 2178 pack = Some(arg.trim_start_matches("--pack=").to_string()); 2179 } 2180 _ if arg.starts_with("--out=") => { 2181 out = Some(arg.trim_start_matches("--out=").to_string()); 2182 } 2183 _ if arg.starts_with("--out-dir=") => { 2184 out_dir = Some(arg.trim_start_matches("--out-dir=").to_string()); 2185 } 2186 _ if arg.starts_with("--in=") => { 2187 input = Some(arg.trim_start_matches("--in=").to_string()); 2188 } 2189 _ if arg.starts_with("--") => { 2190 return Err(format!("Unknown flag {}", arg)); 2191 } 2192 _ => { 2193 positional.push(arg.to_string()); 2194 } 2195 } 2196 } 2197 if pack.is_none() && !positional.is_empty() { 2198 pack = Some(positional.remove(0)); 2199 } 2200 Ok((pack, out, out_dir, input)) 2201 } 2202 2203 #[derive(Default)] 2204 struct PackSignFlags { 2205 pack: Option<String>, 2206 out: Option<String>, 2207 key: Option<String>, 2208 } 2209 2210 fn parse_pack_sign_flags(args: &[String]) -> Result<PackSignFlags, String> { 2211 let mut flags = PackSignFlags::default(); 2212 let mut positional: Vec<String> = Vec::new(); 2213 let mut iter = args.iter().peekable(); 2214 while let Some(arg) = iter.next() { 2215 match arg.as_str() { 2216 "--pack" => { 2217 flags.pack = iter.next().map(|value| value.to_string()); 2218 } 2219 "--out" => { 2220 flags.out = iter.next().map(|value| value.to_string()); 2221 } 2222 "--key" => { 2223 flags.key = iter.next().map(|value| value.to_string()); 2224 } 2225 _ if arg.starts_with("--pack=") => { 2226 flags.pack = Some(arg.trim_start_matches("--pack=").to_string()); 2227 } 2228 _ if arg.starts_with("--out=") => { 2229 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 2230 } 2231 _ if arg.starts_with("--key=") => { 2232 flags.key = Some(arg.trim_start_matches("--key=").to_string()); 2233 } 2234 _ if arg.starts_with("--") => { 2235 return Err(format!("Unknown flag {}", arg)); 2236 } 2237 _ => positional.push(arg.to_string()), 2238 } 2239 } 2240 if flags.pack.is_none() && !positional.is_empty() { 2241 flags.pack = Some(positional.remove(0)); 2242 } 2243 Ok(flags) 2244 } 2245 2246 #[derive(Default)] 2247 struct PackVerifySignatureFlags { 2248 pack: Option<String>, 2249 allow_unsigned: bool, 2250 } 2251 2252 fn parse_pack_verify_signature_flags(args: &[String]) -> Result<PackVerifySignatureFlags, String> { 2253 let mut flags = PackVerifySignatureFlags::default(); 2254 let mut positional: Vec<String> = Vec::new(); 2255 let mut iter = args.iter().peekable(); 2256 while let Some(arg) = iter.next() { 2257 match arg.as_str() { 2258 "--pack" => { 2259 flags.pack = iter.next().map(|value| value.to_string()); 2260 } 2261 "--allow-unsigned" => { 2262 flags.allow_unsigned = true; 2263 } 2264 _ if arg.starts_with("--pack=") => { 2265 flags.pack = Some(arg.trim_start_matches("--pack=").to_string()); 2266 } 2267 _ if arg.starts_with("--") => { 2268 return Err(format!("Unknown flag {}", arg)); 2269 } 2270 _ => positional.push(arg.to_string()), 2271 } 2272 } 2273 if flags.pack.is_none() && !positional.is_empty() { 2274 flags.pack = Some(positional.remove(0)); 2275 } 2276 Ok(flags) 2277 } 2278 2279 #[derive(Default)] 2280 struct PackKeygenFlags { 2281 out_public: Option<String>, 2282 out_secret: Option<String>, 2283 } 2284 2285 fn parse_pack_keygen_flags(args: &[String]) -> Result<PackKeygenFlags, String> { 2286 let mut flags = PackKeygenFlags::default(); 2287 let mut iter = args.iter().peekable(); 2288 while let Some(arg) = iter.next() { 2289 match arg.as_str() { 2290 "--out-public" => { 2291 flags.out_public = iter.next().map(|value| value.to_string()); 2292 } 2293 "--out-secret" => { 2294 flags.out_secret = iter.next().map(|value| value.to_string()); 2295 } 2296 _ if arg.starts_with("--out-public=") => { 2297 flags.out_public = Some(arg.trim_start_matches("--out-public=").to_string()); 2298 } 2299 _ if arg.starts_with("--out-secret=") => { 2300 flags.out_secret = Some(arg.trim_start_matches("--out-secret=").to_string()); 2301 } 2302 _ if arg.starts_with("--") => { 2303 return Err(format!("Unknown flag {}", arg)); 2304 } 2305 _ => { 2306 return Err(format!("Unexpected arg {}", arg)); 2307 } 2308 } 2309 } 2310 Ok(flags) 2311 } 2312 2313 fn handle_pack_command(args: Vec<String>) { 2314 if args.is_empty() { 2315 print_pack_usage(); 2316 std::process::exit(2); 2317 } 2318 let subcommand = &args[0]; 2319 let mut standard_flags: Option<PackFlags> = None; 2320 let mut pack_path: Option<String> = None; 2321 if !matches!(subcommand.as_str(), "keygen" | "sign" | "verify-signature") { 2322 let flags = match parse_pack_flags(&args[1..]) { 2323 Ok(flags) => flags, 2324 Err(err) => { 2325 eprintln!("{}", err); 2326 print_pack_usage(); 2327 std::process::exit(2); 2328 } 2329 }; 2330 let path = match flags.0.clone() { 2331 Some(path) => path, 2332 None => { 2333 eprintln!("Missing pack path"); 2334 print_pack_usage(); 2335 std::process::exit(2); 2336 } 2337 }; 2338 standard_flags = Some(flags); 2339 pack_path = Some(path); 2340 } 2341 2342 match subcommand.as_str() { 2343 "keygen" => { 2344 let flags = match parse_pack_keygen_flags(&args[1..]) { 2345 Ok(flags) => flags, 2346 Err(err) => { 2347 eprintln!("{}", err); 2348 print_pack_usage(); 2349 std::process::exit(2); 2350 } 2351 }; 2352 let out_public = match flags.out_public { 2353 Some(path) => path, 2354 None => { 2355 eprintln!("Missing --out-public"); 2356 print_pack_usage(); 2357 std::process::exit(2); 2358 } 2359 }; 2360 let out_secret = match flags.out_secret { 2361 Some(path) => path, 2362 None => { 2363 eprintln!("Missing --out-secret"); 2364 print_pack_usage(); 2365 std::process::exit(2); 2366 } 2367 }; 2368 let (public_key, secret_key) = generate_ed25519_keypair().unwrap_or_else(|err| { 2369 eprintln!("Failed to generate keypair: {}", err); 2370 std::process::exit(2); 2371 }); 2372 if let Err(err) = fs::write(&out_public, public_key) { 2373 eprintln!("Failed to write {}: {}", out_public, err); 2374 std::process::exit(2); 2375 } 2376 if let Err(err) = fs::write(&out_secret, secret_key) { 2377 eprintln!("Failed to write {}: {}", out_secret, err); 2378 std::process::exit(2); 2379 } 2380 println!("OK: wrote pack keys to {} and {}", out_public, out_secret); 2381 } 2382 "sign" => { 2383 let flags = match parse_pack_sign_flags(&args[1..]) { 2384 Ok(flags) => flags, 2385 Err(err) => { 2386 eprintln!("{}", err); 2387 print_pack_usage(); 2388 std::process::exit(2); 2389 } 2390 }; 2391 let pack_path = match flags.pack { 2392 Some(path) => path, 2393 None => { 2394 eprintln!("Missing --pack"); 2395 print_pack_usage(); 2396 std::process::exit(2); 2397 } 2398 }; 2399 let key_path = match flags.key { 2400 Some(path) => path, 2401 None => { 2402 eprintln!("Missing --key"); 2403 print_pack_usage(); 2404 std::process::exit(2); 2405 } 2406 }; 2407 let pack_data = fs::read_to_string(&pack_path).unwrap_or_else(|err| { 2408 eprintln!("Failed to read pack {}: {}", pack_path, err); 2409 std::process::exit(2); 2410 }); 2411 let mut pack = ContentPack::from_json(&pack_data).unwrap_or_else(|err| { 2412 eprintln!("Failed to parse pack {}: {}", pack_path, err); 2413 std::process::exit(2); 2414 }); 2415 let key_data = fs::read_to_string(&key_path).unwrap_or_else(|err| { 2416 eprintln!("Failed to read key {}: {}", key_path, err); 2417 std::process::exit(2); 2418 }); 2419 let signature = pack.sign_ed25519(key_data.trim()).unwrap_or_else(|err| { 2420 eprintln!("Failed to sign pack: {}", err); 2421 std::process::exit(2); 2422 }); 2423 pack.signature = Some(signature); 2424 let encoded = serde_json::to_string_pretty(&pack).unwrap_or_else(|err| { 2425 eprintln!("Failed to serialize pack: {}", err); 2426 std::process::exit(2); 2427 }); 2428 let out_path = flags.out.unwrap_or_else(|| pack_path.clone()); 2429 if let Err(err) = fs::write(&out_path, encoded) { 2430 eprintln!("Failed to write {}: {}", out_path, err); 2431 std::process::exit(2); 2432 } 2433 println!("OK: pack signed at {}", out_path); 2434 } 2435 "verify-signature" => { 2436 let flags = match parse_pack_verify_signature_flags(&args[1..]) { 2437 Ok(flags) => flags, 2438 Err(err) => { 2439 eprintln!("{}", err); 2440 print_pack_usage(); 2441 std::process::exit(2); 2442 } 2443 }; 2444 let pack_path = match flags.pack { 2445 Some(path) => path, 2446 None => { 2447 eprintln!("Missing --pack"); 2448 print_pack_usage(); 2449 std::process::exit(2); 2450 } 2451 }; 2452 let pack_data = fs::read_to_string(&pack_path).unwrap_or_else(|err| { 2453 eprintln!("Failed to read pack {}: {}", pack_path, err); 2454 std::process::exit(2); 2455 }); 2456 let pack = ContentPack::from_json(&pack_data).unwrap_or_else(|err| { 2457 eprintln!("Failed to parse pack {}: {}", pack_path, err); 2458 std::process::exit(2); 2459 }); 2460 match pack.signature_status() { 2461 PackSignatureStatus::Valid => { 2462 println!("OK: pack signature valid: {}", pack_path); 2463 } 2464 PackSignatureStatus::Missing => { 2465 if flags.allow_unsigned { 2466 println!("OK: pack unsigned (allowed): {}", pack_path); 2467 } else { 2468 eprintln!("pack signature missing: {}", pack_path); 2469 std::process::exit(2); 2470 } 2471 } 2472 PackSignatureStatus::Invalid => { 2473 eprintln!("pack signature invalid: {}", pack_path); 2474 std::process::exit(2); 2475 } 2476 } 2477 } 2478 "lint" => { 2479 let pack_path = pack_path.as_ref().unwrap(); 2480 match build_pack_context(pack_path) { 2481 Ok(ctx) => { 2482 for warning in ctx.warnings { 2483 eprintln!("warning: {}", warning); 2484 } 2485 println!("OK: pack lint passed: {}", pack_path); 2486 } 2487 Err(err) => { 2488 eprintln!("pack lint failed: {}", err); 2489 std::process::exit(2); 2490 } 2491 } 2492 } 2493 "resolve" => { 2494 let pack_path = pack_path.as_ref().unwrap(); 2495 let flags = standard_flags.as_ref().unwrap(); 2496 let ctx = match build_pack_context(pack_path) { 2497 Ok(ctx) => ctx, 2498 Err(err) => { 2499 eprintln!("pack resolve failed: {}", err); 2500 std::process::exit(2); 2501 } 2502 }; 2503 for warning in &ctx.warnings { 2504 eprintln!("warning: {}", warning); 2505 } 2506 let mut pack = ctx.pack.clone(); 2507 pack.dependencies.clear(); 2508 pack.patches.clear(); 2509 let stable = stable_pack(&pack, &ctx.assets, &ctx.maps, true, true); 2510 let output = ResolvedPackOutput { 2511 pack: stable, 2512 abilities: ctx.abilities.clone(), 2513 reactions: ctx.reactions.clone(), 2514 director: ctx.director.clone(), 2515 ai: ctx.ai.clone(), 2516 tutorial: ctx.tutorial.clone(), 2517 campaign: ctx.campaign.clone(), 2518 }; 2519 let encoded = serde_json::to_string_pretty(&output).unwrap_or_else(|err| { 2520 eprintln!("Failed to serialize resolved pack: {}", err); 2521 std::process::exit(2); 2522 }); 2523 if let Some(out_path) = &flags.1 { 2524 if let Err(err) = fs::write(out_path, encoded) { 2525 eprintln!("Failed to write {}: {}", out_path, err); 2526 std::process::exit(2); 2527 } 2528 } else { 2529 println!("{}", encoded); 2530 } 2531 } 2532 "build" => { 2533 let pack_path = pack_path.as_ref().unwrap(); 2534 let flags = standard_flags.as_ref().unwrap(); 2535 let ctx = match build_pack_context(pack_path) { 2536 Ok(ctx) => ctx, 2537 Err(err) => { 2538 eprintln!("pack build failed: {}", err); 2539 std::process::exit(2); 2540 } 2541 }; 2542 for warning in &ctx.warnings { 2543 eprintln!("warning: {}", warning); 2544 } 2545 let out_dir = flags.2.clone().unwrap_or_else(|| "dist/pack".to_string()); 2546 if let Err(err) = fs::create_dir_all(&out_dir) { 2547 eprintln!("Failed to create {}: {}", out_dir, err); 2548 std::process::exit(2); 2549 } 2550 let mut pack = ctx.pack.clone(); 2551 pack.dependencies.clear(); 2552 pack.patches.clear(); 2553 pack.abilities = "abilities.json".to_string(); 2554 pack.reactions = Some("reactions.json".to_string()); 2555 if ctx.director.is_some() { 2556 pack.director = Some("director.json".to_string()); 2557 } else { 2558 pack.director = None; 2559 } 2560 if ctx.ai.is_some() { 2561 pack.ai = Some("ai.json".to_string()); 2562 } else { 2563 pack.ai = None; 2564 } 2565 if ctx.tutorial.is_some() { 2566 pack.tutorial = Some("tutorial.json".to_string()); 2567 } else { 2568 pack.tutorial = None; 2569 } 2570 if ctx.campaign.is_some() { 2571 pack.campaign = Some("campaign.json".to_string()); 2572 } 2573 let stable = stable_pack(&pack, &ctx.assets, &ctx.maps, true, true); 2574 let pack_json = serde_json::to_string_pretty(&stable).unwrap_or_else(|err| { 2575 eprintln!("Failed to serialize pack: {}", err); 2576 std::process::exit(2); 2577 }); 2578 let pack_path_out = Path::new(&out_dir).join("pack.json"); 2579 if let Err(err) = fs::write(&pack_path_out, pack_json) { 2580 eprintln!("Failed to write pack.json: {}", err); 2581 std::process::exit(2); 2582 } 2583 let abilities_json = 2584 serde_json::to_string_pretty(&ctx.abilities).unwrap_or_else(|err| { 2585 eprintln!("Failed to serialize abilities: {}", err); 2586 std::process::exit(2); 2587 }); 2588 let abilities_path_out = Path::new(&out_dir).join("abilities.json"); 2589 if let Err(err) = fs::write(&abilities_path_out, abilities_json) { 2590 eprintln!("Failed to write abilities.json: {}", err); 2591 std::process::exit(2); 2592 } 2593 let reactions_json = 2594 serde_json::to_string_pretty(&ctx.reactions).unwrap_or_else(|err| { 2595 eprintln!("Failed to serialize reactions: {}", err); 2596 std::process::exit(2); 2597 }); 2598 let reactions_path_out = Path::new(&out_dir).join("reactions.json"); 2599 if let Err(err) = fs::write(&reactions_path_out, reactions_json) { 2600 eprintln!("Failed to write reactions.json: {}", err); 2601 std::process::exit(2); 2602 } 2603 if let Some(campaign) = &ctx.campaign { 2604 let campaign_json = serde_json::to_string_pretty(campaign).unwrap_or_else(|err| { 2605 eprintln!("Failed to serialize campaign: {}", err); 2606 std::process::exit(2); 2607 }); 2608 let campaign_path_out = Path::new(&out_dir).join("campaign.json"); 2609 if let Err(err) = fs::write(&campaign_path_out, campaign_json) { 2610 eprintln!("Failed to write campaign.json: {}", err); 2611 std::process::exit(2); 2612 } 2613 } 2614 if let Some(director) = &ctx.director { 2615 let director_json = serde_json::to_string_pretty(director).unwrap_or_else(|err| { 2616 eprintln!("Failed to serialize director: {}", err); 2617 std::process::exit(2); 2618 }); 2619 let director_path_out = Path::new(&out_dir).join("director.json"); 2620 if let Err(err) = fs::write(&director_path_out, director_json) { 2621 eprintln!("Failed to write director.json: {}", err); 2622 std::process::exit(2); 2623 } 2624 } 2625 if let Some(ai) = &ctx.ai { 2626 let ai_json = serde_json::to_string_pretty(ai).unwrap_or_else(|err| { 2627 eprintln!("Failed to serialize ai: {}", err); 2628 std::process::exit(2); 2629 }); 2630 let ai_path_out = Path::new(&out_dir).join("ai.json"); 2631 if let Err(err) = fs::write(&ai_path_out, ai_json) { 2632 eprintln!("Failed to write ai.json: {}", err); 2633 std::process::exit(2); 2634 } 2635 } 2636 if let Some(tutorial) = &ctx.tutorial { 2637 let tutorial_json = serde_json::to_string_pretty(tutorial).unwrap_or_else(|err| { 2638 eprintln!("Failed to serialize tutorial: {}", err); 2639 std::process::exit(2); 2640 }); 2641 let tutorial_path_out = Path::new(&out_dir).join("tutorial.json"); 2642 if let Err(err) = fs::write(&tutorial_path_out, tutorial_json) { 2643 eprintln!("Failed to write tutorial.json: {}", err); 2644 std::process::exit(2); 2645 } 2646 } 2647 println!("OK: pack built at {}", out_dir); 2648 } 2649 "cache" => { 2650 let pack_path = pack_path.as_ref().unwrap(); 2651 let flags = standard_flags.as_ref().unwrap(); 2652 let ctx = match build_pack_context(pack_path) { 2653 Ok(ctx) => ctx, 2654 Err(err) => { 2655 eprintln!("pack cache failed: {}", err); 2656 std::process::exit(2); 2657 } 2658 }; 2659 for warning in &ctx.warnings { 2660 eprintln!("warning: {}", warning); 2661 } 2662 let cache = match build_pack_cache_manifest(pack_path, &ctx) { 2663 Ok(cache) => cache, 2664 Err(err) => { 2665 eprintln!("pack cache failed: {}", err); 2666 std::process::exit(2); 2667 } 2668 }; 2669 let encoded = serde_json::to_string_pretty(&cache).unwrap_or_else(|err| { 2670 eprintln!("Failed to serialize pack cache: {}", err); 2671 std::process::exit(2); 2672 }); 2673 let out_path = flags 2674 .1 2675 .clone() 2676 .unwrap_or_else(|| pack_cache_path(pack_path).to_string_lossy().to_string()); 2677 if let Err(err) = fs::write(&out_path, encoded) { 2678 eprintln!("Failed to write {}: {}", out_path, err); 2679 std::process::exit(2); 2680 } 2681 println!("OK: pack cache written to {}", out_path); 2682 } 2683 "hash" => { 2684 let pack_path = pack_path.as_ref().unwrap(); 2685 let flags = standard_flags.as_ref().unwrap(); 2686 if let Err(err) = write_pack_hashes(pack_path, flags.1.clone()) { 2687 eprintln!("pack hash failed: {}", err); 2688 std::process::exit(2); 2689 } 2690 println!("OK: pack hashes written for {}", pack_path); 2691 } 2692 "verify" => { 2693 let pack_path = pack_path.as_ref().unwrap(); 2694 let flags = standard_flags.as_ref().unwrap(); 2695 if let Err(err) = verify_pack_hashes(pack_path, flags.3.clone()) { 2696 eprintln!("pack verify failed: {}", err); 2697 std::process::exit(2); 2698 } 2699 println!("OK: pack hashes verified for {}", pack_path); 2700 } 2701 _ => { 2702 eprintln!("Unknown pack subcommand: {}", subcommand); 2703 print_pack_usage(); 2704 std::process::exit(2); 2705 } 2706 } 2707 } 2708 2709 const RELEASE_MANIFEST_VERSION: u32 = 1; 2710 2711 #[derive(Clone, Debug, Serialize, Deserialize)] 2712 struct ReleaseFile { 2713 path: String, 2714 sha256: String, 2715 bytes: u64, 2716 } 2717 2718 #[derive(Clone, Debug, Serialize, Deserialize)] 2719 struct ReleaseArtifact { 2720 artifact: String, 2721 files: Vec<ReleaseFile>, 2722 } 2723 2724 #[derive(Clone, Debug, Serialize, Deserialize)] 2725 struct ReleaseEngineEntry { 2726 version: SemVer, 2727 platform: String, 2728 arch: String, 2729 artifact: ReleaseArtifact, 2730 } 2731 2732 #[derive(Clone, Debug, Serialize, Deserialize)] 2733 struct ReleasePackEntry { 2734 id: String, 2735 version: SemVer, 2736 pack_json: String, 2737 artifact: ReleaseArtifact, 2738 signature: Option<PackSignature>, 2739 } 2740 2741 #[derive(Clone, Debug, Serialize, Deserialize)] 2742 struct ReleaseManifest { 2743 version: u32, 2744 source_date_epoch: Option<u64>, 2745 engine: Option<ReleaseEngineEntry>, 2746 packs: Vec<ReleasePackEntry>, 2747 signature: Option<PackSignature>, 2748 } 2749 2750 fn normalize_manifest_path(path: &str) -> String { 2751 path.replace('\\', "/") 2752 } 2753 2754 fn release_file_from_path(path: &Path, rel: &Path) -> Result<ReleaseFile, String> { 2755 let data = 2756 fs::read(path).map_err(|err| format!("Failed to read {}: {}", path.display(), err))?; 2757 let rel_str = normalize_manifest_path(&rel.to_string_lossy()); 2758 Ok(ReleaseFile { 2759 path: rel_str, 2760 sha256: sha256_hex(&data), 2761 bytes: data.len() as u64, 2762 }) 2763 } 2764 2765 fn collect_release_files(root: &Path) -> Result<Vec<ReleaseFile>, String> { 2766 let mut files: Vec<ReleaseFile> = Vec::new(); 2767 if root.is_file() { 2768 let file_name = root 2769 .file_name() 2770 .map(|value| value.to_string_lossy().to_string()) 2771 .unwrap_or_else(|| "artifact".to_string()); 2772 files.push(release_file_from_path(root, Path::new(&file_name))?); 2773 } else { 2774 let mut stack = vec![root.to_path_buf()]; 2775 while let Some(dir) = stack.pop() { 2776 let mut entries: Vec<_> = fs::read_dir(&dir) 2777 .map_err(|err| format!("Failed to read {}: {}", dir.display(), err))? 2778 .collect(); 2779 entries.sort_by(|a, b| { 2780 a.as_ref() 2781 .ok() 2782 .map(|e| e.path()) 2783 .cmp(&b.as_ref().ok().map(|e| e.path())) 2784 }); 2785 for entry in entries { 2786 let entry = entry.map_err(|err| err.to_string())?; 2787 let path = entry.path(); 2788 let meta = entry.metadata().map_err(|err| err.to_string())?; 2789 if meta.is_dir() { 2790 stack.push(path); 2791 } else if meta.is_file() { 2792 let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); 2793 files.push(release_file_from_path(&path, &rel)?); 2794 } 2795 } 2796 } 2797 } 2798 files.sort_by(|a, b| a.path.cmp(&b.path)); 2799 Ok(files) 2800 } 2801 2802 fn build_release_artifact(path: &str) -> Result<ReleaseArtifact, String> { 2803 let root = Path::new(path); 2804 if !root.exists() { 2805 return Err(format!("Artifact path missing: {}", path)); 2806 } 2807 let files = collect_release_files(root)?; 2808 Ok(ReleaseArtifact { 2809 artifact: normalize_manifest_path(path), 2810 files, 2811 }) 2812 } 2813 2814 fn resolve_manifest_artifact(manifest_path: &str, artifact: &str) -> PathBuf { 2815 let path = PathBuf::from(artifact); 2816 if path.is_absolute() { 2817 return path; 2818 } 2819 let base = Path::new(manifest_path) 2820 .parent() 2821 .unwrap_or_else(|| Path::new(".")); 2822 base.join(path) 2823 } 2824 2825 fn read_source_date_epoch() -> Option<u64> { 2826 if let Ok(value) = std::env::var("SOURCE_DATE_EPOCH") { 2827 if let Ok(parsed) = value.parse::<u64>() { 2828 return Some(parsed); 2829 } 2830 } 2831 None 2832 } 2833 2834 fn build_release_engine_entry(engine_path: &str) -> Result<ReleaseEngineEntry, String> { 2835 Ok(ReleaseEngineEntry { 2836 version: SemVer::current(), 2837 platform: std::env::consts::OS.to_string(), 2838 arch: std::env::consts::ARCH.to_string(), 2839 artifact: build_release_artifact(engine_path)?, 2840 }) 2841 } 2842 2843 fn build_release_pack_entry( 2844 pack_path: &str, 2845 allow_unsigned: bool, 2846 ) -> Result<ReleasePackEntry, String> { 2847 let pack_root = Path::new(pack_path); 2848 let pack_file = if pack_root.is_dir() { 2849 pack_root.join("pack.json") 2850 } else { 2851 pack_root.to_path_buf() 2852 }; 2853 let pack_json = pack_file 2854 .file_name() 2855 .map(|value| value.to_string_lossy().to_string()) 2856 .unwrap_or_else(|| "pack.json".to_string()); 2857 let pack_data = fs::read_to_string(&pack_file) 2858 .map_err(|err| format!("Failed to read pack {}: {}", pack_file.display(), err))?; 2859 let pack = ContentPack::from_json(&pack_data) 2860 .map_err(|err| format!("Failed to parse pack {}: {}", pack_file.display(), err))?; 2861 match pack.signature_status() { 2862 PackSignatureStatus::Valid => {} 2863 PackSignatureStatus::Missing => { 2864 if !allow_unsigned { 2865 return Err(format!("Pack {} is unsigned", pack_file.display())); 2866 } 2867 } 2868 PackSignatureStatus::Invalid => { 2869 return Err(format!("Pack {} signature invalid", pack_file.display())); 2870 } 2871 } 2872 Ok(ReleasePackEntry { 2873 id: pack.id.clone(), 2874 version: pack.version, 2875 pack_json, 2876 artifact: build_release_artifact(pack_path)?, 2877 signature: pack.signature.clone(), 2878 }) 2879 } 2880 2881 fn release_manifest_payload(manifest: &ReleaseManifest) -> Result<Vec<u8>, String> { 2882 #[derive(Serialize)] 2883 struct ReleaseManifestPayload { 2884 version: u32, 2885 source_date_epoch: Option<u64>, 2886 engine: Option<ReleaseEngineEntry>, 2887 packs: Vec<ReleasePackEntry>, 2888 } 2889 2890 let mut engine = manifest.engine.clone(); 2891 if let Some(entry) = &mut engine { 2892 entry.artifact.files.sort_by(|a, b| a.path.cmp(&b.path)); 2893 } 2894 let mut packs = manifest.packs.clone(); 2895 packs.sort_by(|a, b| a.id.cmp(&b.id)); 2896 for entry in &mut packs { 2897 entry.artifact.files.sort_by(|a, b| a.path.cmp(&b.path)); 2898 } 2899 2900 let payload = ReleaseManifestPayload { 2901 version: manifest.version, 2902 source_date_epoch: manifest.source_date_epoch, 2903 engine, 2904 packs, 2905 }; 2906 serde_json::to_vec(&payload).map_err(|err| err.to_string()) 2907 } 2908 2909 fn load_release_manifest(path: &str) -> Result<ReleaseManifest, String> { 2910 let data = 2911 fs::read_to_string(path).map_err(|err| format!("Failed to read {}: {}", path, err))?; 2912 serde_json::from_str(&data).map_err(|err| err.to_string()) 2913 } 2914 2915 fn verify_release_artifact( 2916 manifest_path: &str, 2917 artifact: &ReleaseArtifact, 2918 allow_missing: bool, 2919 ) -> Result<(), String> { 2920 let root = resolve_manifest_artifact(manifest_path, &artifact.artifact); 2921 if !root.exists() { 2922 if allow_missing { 2923 return Ok(()); 2924 } 2925 return Err(format!("Artifact root missing: {}", root.display())); 2926 } 2927 for file in &artifact.files { 2928 let file_path = if root.is_file() { 2929 root.clone() 2930 } else { 2931 root.join(Path::new(&file.path)) 2932 }; 2933 if !file_path.exists() { 2934 if allow_missing { 2935 continue; 2936 } 2937 return Err(format!("Missing artifact file: {}", file_path.display())); 2938 } 2939 let data = fs::read(&file_path) 2940 .map_err(|err| format!("Failed to read {}: {}", file_path.display(), err))?; 2941 let digest = sha256_hex(&data); 2942 if digest != file.sha256 { 2943 return Err(format!("Hash mismatch for {}", file_path.display())); 2944 } 2945 if data.len() as u64 != file.bytes { 2946 return Err(format!("Size mismatch for {}", file_path.display())); 2947 } 2948 } 2949 Ok(()) 2950 } 2951 2952 fn verify_release_pack_entry( 2953 manifest_path: &str, 2954 entry: &ReleasePackEntry, 2955 allow_unsigned: bool, 2956 allow_missing_files: bool, 2957 ) -> Result<(), String> { 2958 verify_release_artifact(manifest_path, &entry.artifact, allow_missing_files)?; 2959 let root = resolve_manifest_artifact(manifest_path, &entry.artifact.artifact); 2960 let pack_path = if root.is_file() { 2961 root 2962 } else { 2963 root.join(Path::new(&entry.pack_json)) 2964 }; 2965 if !pack_path.exists() { 2966 if allow_missing_files { 2967 return Ok(()); 2968 } 2969 return Err(format!("Missing pack file: {}", pack_path.display())); 2970 } 2971 let data = fs::read_to_string(&pack_path) 2972 .map_err(|err| format!("Failed to read pack {}: {}", pack_path.display(), err))?; 2973 let pack = ContentPack::from_json(&data) 2974 .map_err(|err| format!("Failed to parse pack {}: {}", pack_path.display(), err))?; 2975 if pack.id != entry.id { 2976 return Err(format!( 2977 "Pack id mismatch for {} (manifest {}, file {})", 2978 pack_path.display(), 2979 entry.id, 2980 pack.id 2981 )); 2982 } 2983 if pack.version != entry.version { 2984 return Err(format!( 2985 "Pack version mismatch for {} (manifest {:?}, file {:?})", 2986 pack_path.display(), 2987 entry.version, 2988 pack.version 2989 )); 2990 } 2991 if let Some(signature) = &entry.signature { 2992 if pack.signature.as_ref() != Some(signature) { 2993 return Err(format!( 2994 "Pack signature mismatch for {}", 2995 pack_path.display() 2996 )); 2997 } 2998 } 2999 match pack.signature_status() { 3000 PackSignatureStatus::Valid => Ok(()), 3001 PackSignatureStatus::Missing => { 3002 if allow_unsigned { 3003 Ok(()) 3004 } else { 3005 Err(format!("Pack signature missing: {}", pack_path.display())) 3006 } 3007 } 3008 PackSignatureStatus::Invalid => { 3009 Err(format!("Pack signature invalid: {}", pack_path.display())) 3010 } 3011 } 3012 } 3013 3014 fn print_release_usage() { 3015 eprintln!( 3016 "Usage: dynostic_cli release <manifest|sign|verify|keygen> [options]\n\ 3017 manifest --out PATH [--engine PATH] [--pack PATH ...] [--sign-key PATH] [--allow-unsigned] [--source-date-epoch N]\n\ 3018 sign --manifest PATH --key PATH [--out PATH]\n\ 3019 verify --manifest PATH [--allow-unsigned] [--allow-missing-files]\n\ 3020 keygen --out-public PATH --out-secret PATH" 3021 ); 3022 } 3023 3024 #[derive(Default)] 3025 struct ReleaseManifestFlags { 3026 engine: Option<String>, 3027 packs: Vec<String>, 3028 out: Option<String>, 3029 sign_key: Option<String>, 3030 allow_unsigned: bool, 3031 source_date_epoch: Option<u64>, 3032 } 3033 3034 fn parse_release_manifest_flags(args: &[String]) -> Result<ReleaseManifestFlags, String> { 3035 let mut flags = ReleaseManifestFlags::default(); 3036 let mut iter = args.iter().peekable(); 3037 while let Some(arg) = iter.next() { 3038 match arg.as_str() { 3039 "--engine" => { 3040 flags.engine = iter.next().map(|value| value.to_string()); 3041 } 3042 "--pack" => { 3043 let value = iter 3044 .next() 3045 .ok_or_else(|| "Missing value for --pack".to_string())?; 3046 flags.packs.push(value.to_string()); 3047 } 3048 "--out" => { 3049 flags.out = iter.next().map(|value| value.to_string()); 3050 } 3051 "--sign-key" => { 3052 flags.sign_key = iter.next().map(|value| value.to_string()); 3053 } 3054 "--allow-unsigned" => { 3055 flags.allow_unsigned = true; 3056 } 3057 "--source-date-epoch" => { 3058 let value = iter 3059 .next() 3060 .ok_or_else(|| "Missing value for --source-date-epoch".to_string())?; 3061 flags.source_date_epoch = Some(parse_u64(value, "source-date-epoch")); 3062 } 3063 _ if arg.starts_with("--engine=") => { 3064 flags.engine = Some(arg.trim_start_matches("--engine=").to_string()); 3065 } 3066 _ if arg.starts_with("--pack=") => { 3067 flags 3068 .packs 3069 .push(arg.trim_start_matches("--pack=").to_string()); 3070 } 3071 _ if arg.starts_with("--out=") => { 3072 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 3073 } 3074 _ if arg.starts_with("--sign-key=") => { 3075 flags.sign_key = Some(arg.trim_start_matches("--sign-key=").to_string()); 3076 } 3077 _ if arg.starts_with("--source-date-epoch=") => { 3078 let value = arg.trim_start_matches("--source-date-epoch="); 3079 flags.source_date_epoch = Some(parse_u64(value, "source-date-epoch")); 3080 } 3081 _ if arg.starts_with("--") => { 3082 return Err(format!("Unknown flag {}", arg)); 3083 } 3084 _ => { 3085 return Err(format!("Unexpected arg {}", arg)); 3086 } 3087 } 3088 } 3089 Ok(flags) 3090 } 3091 3092 #[derive(Default)] 3093 struct ReleaseSignFlags { 3094 manifest: Option<String>, 3095 key: Option<String>, 3096 out: Option<String>, 3097 } 3098 3099 fn parse_release_sign_flags(args: &[String]) -> Result<ReleaseSignFlags, String> { 3100 let mut flags = ReleaseSignFlags::default(); 3101 let mut iter = args.iter().peekable(); 3102 while let Some(arg) = iter.next() { 3103 match arg.as_str() { 3104 "--manifest" => { 3105 flags.manifest = iter.next().map(|value| value.to_string()); 3106 } 3107 "--key" => { 3108 flags.key = iter.next().map(|value| value.to_string()); 3109 } 3110 "--out" => { 3111 flags.out = iter.next().map(|value| value.to_string()); 3112 } 3113 _ if arg.starts_with("--manifest=") => { 3114 flags.manifest = Some(arg.trim_start_matches("--manifest=").to_string()); 3115 } 3116 _ if arg.starts_with("--key=") => { 3117 flags.key = Some(arg.trim_start_matches("--key=").to_string()); 3118 } 3119 _ if arg.starts_with("--out=") => { 3120 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 3121 } 3122 _ if arg.starts_with("--") => { 3123 return Err(format!("Unknown flag {}", arg)); 3124 } 3125 _ => { 3126 return Err(format!("Unexpected arg {}", arg)); 3127 } 3128 } 3129 } 3130 Ok(flags) 3131 } 3132 3133 #[derive(Default)] 3134 struct ReleaseVerifyFlags { 3135 manifest: Option<String>, 3136 allow_unsigned: bool, 3137 allow_missing_files: bool, 3138 } 3139 3140 fn parse_release_verify_flags(args: &[String]) -> Result<ReleaseVerifyFlags, String> { 3141 let mut flags = ReleaseVerifyFlags::default(); 3142 let mut iter = args.iter().peekable(); 3143 while let Some(arg) = iter.next() { 3144 match arg.as_str() { 3145 "--manifest" => { 3146 flags.manifest = iter.next().map(|value| value.to_string()); 3147 } 3148 "--allow-unsigned" => { 3149 flags.allow_unsigned = true; 3150 } 3151 "--allow-missing-files" => { 3152 flags.allow_missing_files = true; 3153 } 3154 _ if arg.starts_with("--manifest=") => { 3155 flags.manifest = Some(arg.trim_start_matches("--manifest=").to_string()); 3156 } 3157 _ if arg.starts_with("--") => { 3158 return Err(format!("Unknown flag {}", arg)); 3159 } 3160 _ => { 3161 return Err(format!("Unexpected arg {}", arg)); 3162 } 3163 } 3164 } 3165 Ok(flags) 3166 } 3167 3168 fn handle_release_command(args: Vec<String>) { 3169 if args.is_empty() { 3170 print_release_usage(); 3171 std::process::exit(2); 3172 } 3173 let subcommand = &args[0]; 3174 match subcommand.as_str() { 3175 "manifest" => { 3176 let flags = match parse_release_manifest_flags(&args[1..]) { 3177 Ok(flags) => flags, 3178 Err(err) => { 3179 eprintln!("{}", err); 3180 print_release_usage(); 3181 std::process::exit(2); 3182 } 3183 }; 3184 let out_path = match flags.out { 3185 Some(path) => path, 3186 None => { 3187 eprintln!("Missing --out"); 3188 print_release_usage(); 3189 std::process::exit(2); 3190 } 3191 }; 3192 if flags.engine.is_none() && flags.packs.is_empty() { 3193 eprintln!("Missing --engine or --pack"); 3194 print_release_usage(); 3195 std::process::exit(2); 3196 } 3197 let engine_entry = flags.engine.as_deref().map(|path| { 3198 build_release_engine_entry(path).unwrap_or_else(|err| { 3199 eprintln!("Failed to build engine manifest: {}", err); 3200 std::process::exit(2); 3201 }) 3202 }); 3203 let mut pack_entries: Vec<ReleasePackEntry> = Vec::new(); 3204 for pack_path in &flags.packs { 3205 let entry = build_release_pack_entry(pack_path, flags.allow_unsigned) 3206 .unwrap_or_else(|err| { 3207 eprintln!("Failed to build pack manifest: {}", err); 3208 std::process::exit(2); 3209 }); 3210 pack_entries.push(entry); 3211 } 3212 pack_entries.sort_by(|a, b| a.id.cmp(&b.id)); 3213 let mut manifest = ReleaseManifest { 3214 version: RELEASE_MANIFEST_VERSION, 3215 source_date_epoch: flags.source_date_epoch.or_else(read_source_date_epoch), 3216 engine: engine_entry, 3217 packs: pack_entries, 3218 signature: None, 3219 }; 3220 if let Some(key_path) = flags.sign_key { 3221 let key_data = fs::read_to_string(&key_path).unwrap_or_else(|err| { 3222 eprintln!("Failed to read key {}: {}", key_path, err); 3223 std::process::exit(2); 3224 }); 3225 let payload = release_manifest_payload(&manifest).unwrap_or_else(|err| { 3226 eprintln!("Failed to serialize manifest payload: {}", err); 3227 std::process::exit(2); 3228 }); 3229 let signature = 3230 sign_payload_ed25519(&payload, key_data.trim()).unwrap_or_else(|err| { 3231 eprintln!("Failed to sign manifest: {}", err); 3232 std::process::exit(2); 3233 }); 3234 manifest.signature = Some(signature); 3235 } 3236 let encoded = serde_json::to_string_pretty(&manifest).unwrap_or_else(|err| { 3237 eprintln!("Failed to serialize manifest: {}", err); 3238 std::process::exit(2); 3239 }); 3240 if let Err(err) = fs::write(&out_path, encoded) { 3241 eprintln!("Failed to write {}: {}", out_path, err); 3242 std::process::exit(2); 3243 } 3244 println!("OK: release manifest written to {}", out_path); 3245 } 3246 "sign" => { 3247 let flags = match parse_release_sign_flags(&args[1..]) { 3248 Ok(flags) => flags, 3249 Err(err) => { 3250 eprintln!("{}", err); 3251 print_release_usage(); 3252 std::process::exit(2); 3253 } 3254 }; 3255 let manifest_path = match flags.manifest { 3256 Some(path) => path, 3257 None => { 3258 eprintln!("Missing --manifest"); 3259 print_release_usage(); 3260 std::process::exit(2); 3261 } 3262 }; 3263 let key_path = match flags.key { 3264 Some(path) => path, 3265 None => { 3266 eprintln!("Missing --key"); 3267 print_release_usage(); 3268 std::process::exit(2); 3269 } 3270 }; 3271 let key_data = fs::read_to_string(&key_path).unwrap_or_else(|err| { 3272 eprintln!("Failed to read key {}: {}", key_path, err); 3273 std::process::exit(2); 3274 }); 3275 let mut manifest = load_release_manifest(&manifest_path).unwrap_or_else(|err| { 3276 eprintln!("Failed to load manifest {}: {}", manifest_path, err); 3277 std::process::exit(2); 3278 }); 3279 let payload = release_manifest_payload(&manifest).unwrap_or_else(|err| { 3280 eprintln!("Failed to serialize manifest payload: {}", err); 3281 std::process::exit(2); 3282 }); 3283 let signature = sign_payload_ed25519(&payload, key_data.trim()).unwrap_or_else(|err| { 3284 eprintln!("Failed to sign manifest: {}", err); 3285 std::process::exit(2); 3286 }); 3287 manifest.signature = Some(signature); 3288 let out_path = flags.out.unwrap_or_else(|| manifest_path.clone()); 3289 let encoded = serde_json::to_string_pretty(&manifest).unwrap_or_else(|err| { 3290 eprintln!("Failed to serialize manifest: {}", err); 3291 std::process::exit(2); 3292 }); 3293 if let Err(err) = fs::write(&out_path, encoded) { 3294 eprintln!("Failed to write {}: {}", out_path, err); 3295 std::process::exit(2); 3296 } 3297 println!("OK: release manifest signed at {}", out_path); 3298 } 3299 "verify" => { 3300 let flags = match parse_release_verify_flags(&args[1..]) { 3301 Ok(flags) => flags, 3302 Err(err) => { 3303 eprintln!("{}", err); 3304 print_release_usage(); 3305 std::process::exit(2); 3306 } 3307 }; 3308 let manifest_path = match flags.manifest { 3309 Some(path) => path, 3310 None => { 3311 eprintln!("Missing --manifest"); 3312 print_release_usage(); 3313 std::process::exit(2); 3314 } 3315 }; 3316 let manifest = load_release_manifest(&manifest_path).unwrap_or_else(|err| { 3317 eprintln!("Failed to load manifest {}: {}", manifest_path, err); 3318 std::process::exit(2); 3319 }); 3320 if manifest.version != RELEASE_MANIFEST_VERSION { 3321 eprintln!( 3322 "release manifest version mismatch: expected {}, got {}", 3323 RELEASE_MANIFEST_VERSION, manifest.version 3324 ); 3325 std::process::exit(2); 3326 } 3327 if let Some(signature) = &manifest.signature { 3328 let payload = release_manifest_payload(&manifest).unwrap_or_else(|err| { 3329 eprintln!("Failed to serialize manifest payload: {}", err); 3330 std::process::exit(2); 3331 }); 3332 if signature_status_ed25519(&payload, signature) != PackSignatureStatus::Valid { 3333 eprintln!("release manifest signature invalid"); 3334 std::process::exit(2); 3335 } 3336 } else if !flags.allow_unsigned { 3337 eprintln!("release manifest missing signature"); 3338 std::process::exit(2); 3339 } 3340 if let Some(engine) = &manifest.engine { 3341 if let Err(err) = verify_release_artifact( 3342 &manifest_path, 3343 &engine.artifact, 3344 flags.allow_missing_files, 3345 ) { 3346 eprintln!("engine artifact verification failed: {}", err); 3347 std::process::exit(2); 3348 } 3349 } 3350 for entry in &manifest.packs { 3351 if let Err(err) = verify_release_pack_entry( 3352 &manifest_path, 3353 entry, 3354 flags.allow_unsigned, 3355 flags.allow_missing_files, 3356 ) { 3357 eprintln!("pack verification failed: {}", err); 3358 std::process::exit(2); 3359 } 3360 } 3361 println!("OK: release manifest verified: {}", manifest_path); 3362 } 3363 "keygen" => { 3364 let flags = match parse_pack_keygen_flags(&args[1..]) { 3365 Ok(flags) => flags, 3366 Err(err) => { 3367 eprintln!("{}", err); 3368 print_release_usage(); 3369 std::process::exit(2); 3370 } 3371 }; 3372 let out_public = match flags.out_public { 3373 Some(path) => path, 3374 None => { 3375 eprintln!("Missing --out-public"); 3376 print_release_usage(); 3377 std::process::exit(2); 3378 } 3379 }; 3380 let out_secret = match flags.out_secret { 3381 Some(path) => path, 3382 None => { 3383 eprintln!("Missing --out-secret"); 3384 print_release_usage(); 3385 std::process::exit(2); 3386 } 3387 }; 3388 let (public_key, secret_key) = generate_ed25519_keypair().unwrap_or_else(|err| { 3389 eprintln!("Failed to generate keypair: {}", err); 3390 std::process::exit(2); 3391 }); 3392 if let Err(err) = fs::write(&out_public, public_key) { 3393 eprintln!("Failed to write {}: {}", out_public, err); 3394 std::process::exit(2); 3395 } 3396 if let Err(err) = fs::write(&out_secret, secret_key) { 3397 eprintln!("Failed to write {}: {}", out_secret, err); 3398 std::process::exit(2); 3399 } 3400 println!( 3401 "OK: wrote release keys to {} and {}", 3402 out_public, out_secret 3403 ); 3404 } 3405 _ => { 3406 eprintln!("Unknown release subcommand: {}", subcommand); 3407 print_release_usage(); 3408 std::process::exit(2); 3409 } 3410 } 3411 } 3412 3413 #[derive(Clone, Debug, Deserialize)] 3414 #[serde(untagged)] 3415 enum CampaignStringOrList { 3416 One(String), 3417 Many(Vec<String>), 3418 } 3419 3420 impl CampaignStringOrList { 3421 fn contains(&self, candidate: &str) -> bool { 3422 match self { 3423 CampaignStringOrList::One(value) => value == candidate, 3424 CampaignStringOrList::Many(values) => values.iter().any(|value| value == candidate), 3425 } 3426 } 3427 } 3428 3429 #[derive(Clone, Debug, Deserialize, Default)] 3430 #[serde(default)] 3431 struct CampaignRequirementEntry { 3432 id: String, 3433 quantity: Option<i64>, 3434 at_least: Option<i64>, 3435 min: Option<i64>, 3436 at_most: Option<i64>, 3437 max: Option<i64>, 3438 equals: Option<i64>, 3439 } 3440 3441 #[derive(Clone, Debug, Deserialize)] 3442 #[serde(untagged)] 3443 enum CampaignRequirementSpec { 3444 Id(String), 3445 Entry(CampaignRequirementEntry), 3446 } 3447 3448 #[derive(Clone, Debug, Deserialize, Default)] 3449 #[serde(default)] 3450 struct CampaignRuntimeChoice { 3451 text: String, 3452 requires_flags: Vec<String>, 3453 excludes_flags: Vec<String>, 3454 requires_mission_result: Option<CampaignStringOrList>, 3455 requires_items: Vec<CampaignRequirementSpec>, 3456 requires_resources: Vec<CampaignRequirementSpec>, 3457 set_flags: Vec<String>, 3458 rewards: Vec<Value>, 3459 next: Option<String>, 3460 } 3461 3462 #[derive(Clone, Debug, Deserialize, Default)] 3463 #[serde(default)] 3464 struct CampaignRuntimeDialogue { 3465 id: String, 3466 text: String, 3467 choices: Vec<CampaignRuntimeChoice>, 3468 } 3469 3470 #[derive(Clone, Debug, Deserialize, Default)] 3471 #[serde(default)] 3472 struct CampaignRuntimeNode { 3473 id: String, 3474 name: String, 3475 kind: String, 3476 start: bool, 3477 next: Vec<String>, 3478 requires_nodes: Vec<String>, 3479 requires_flags: Vec<String>, 3480 excludes_flags: Vec<String>, 3481 requires_mission_result: Option<CampaignStringOrList>, 3482 requires_items: Vec<CampaignRequirementSpec>, 3483 requires_resources: Vec<CampaignRequirementSpec>, 3484 rewards: Vec<Value>, 3485 dialogue: Option<String>, 3486 } 3487 3488 #[derive(Clone, Debug, Deserialize, Default)] 3489 #[serde(default)] 3490 struct CampaignRuntimeDefinition { 3491 version: u32, 3492 id: String, 3493 name: String, 3494 nodes: Vec<CampaignRuntimeNode>, 3495 dialogues: Vec<CampaignRuntimeDialogue>, 3496 starting_roster: Vec<PartyMember>, 3497 starting_inventory: Vec<ItemStack>, 3498 starting_flags: BTreeMap<String, bool>, 3499 } 3500 3501 #[derive(Clone, Debug, Default, Serialize, Deserialize)] 3502 #[serde(default)] 3503 struct CampaignHarnessWorld { 3504 mission_result: Option<String>, 3505 resources: BTreeMap<String, i64>, 3506 } 3507 3508 #[derive(Clone, Debug, Serialize, Deserialize)] 3509 #[serde(default)] 3510 struct CampaignHarnessState { 3511 version: u32, 3512 roster: Vec<PartyMember>, 3513 inventory: Vec<ItemStack>, 3514 flags: BTreeMap<String, bool>, 3515 completed_nodes: Vec<String>, 3516 current_node: Option<String>, 3517 active_dialogue: Option<String>, 3518 mission_scars: BTreeMap<String, Vec<MissionTileScar>>, 3519 world: CampaignHarnessWorld, 3520 } 3521 3522 impl Default for CampaignHarnessState { 3523 fn default() -> Self { 3524 Self { 3525 version: dynostic_core::CAMPAIGN_STATE_VERSION, 3526 roster: Vec::new(), 3527 inventory: Vec::new(), 3528 flags: BTreeMap::new(), 3529 completed_nodes: Vec::new(), 3530 current_node: None, 3531 active_dialogue: None, 3532 mission_scars: BTreeMap::new(), 3533 world: CampaignHarnessWorld::default(), 3534 } 3535 } 3536 } 3537 3538 #[derive(Clone, Debug, Default, Deserialize)] 3539 #[serde(default)] 3540 struct CampaignRunNodeEffect { 3541 mission_result: Option<String>, 3542 set_flags: BTreeMap<String, bool>, 3543 add_items: BTreeMap<String, i64>, 3544 add_resources: BTreeMap<String, i64>, 3545 rewards: Vec<Value>, 3546 } 3547 3548 #[derive(Clone, Debug, Default, Deserialize)] 3549 #[serde(default)] 3550 struct CampaignRunScriptFile { 3551 node_order: Vec<String>, 3552 nodes: Vec<String>, 3553 choice_order: Vec<usize>, 3554 choices: Vec<usize>, 3555 node_effects: BTreeMap<String, CampaignRunNodeEffect>, 3556 effects: BTreeMap<String, CampaignRunNodeEffect>, 3557 } 3558 3559 #[derive(Clone, Debug, Default)] 3560 struct CampaignRunScript { 3561 node_order: Vec<String>, 3562 choice_order: Vec<usize>, 3563 node_effects: BTreeMap<String, CampaignRunNodeEffect>, 3564 } 3565 3566 #[derive(Clone, Debug, Serialize)] 3567 struct CampaignRunDialogueTrace { 3568 dialogue_id: String, 3569 choice_index: usize, 3570 choice_text: String, 3571 } 3572 3573 #[derive(Clone, Debug, Serialize)] 3574 struct CampaignRunStepTrace { 3575 step: u32, 3576 node_id: String, 3577 available_before: Vec<String>, 3578 dialogue_choices: Vec<CampaignRunDialogueTrace>, 3579 effect_applied: bool, 3580 mission_result_after: Option<String>, 3581 } 3582 3583 #[derive(Clone, Debug, Serialize)] 3584 struct CampaignRunSnapshot { 3585 campaign_id: String, 3586 campaign_name: String, 3587 completed_nodes: Vec<String>, 3588 remaining_nodes: Vec<String>, 3589 final_state_hash: String, 3590 final_state: CampaignHarnessState, 3591 trace: Vec<CampaignRunStepTrace>, 3592 } 3593 3594 #[derive(Clone, Debug, Serialize)] 3595 struct AssetAuditIssue { 3596 kind: String, 3597 key: String, 3598 path: String, 3599 } 3600 3601 #[derive(Clone, Debug, Serialize)] 3602 struct AssetAuditDuplicatePath { 3603 path: String, 3604 references: Vec<String>, 3605 } 3606 3607 #[derive(Clone, Debug, Serialize)] 3608 struct AssetAuditReport { 3609 pack: String, 3610 checked: usize, 3611 clean: usize, 3612 missing: Vec<AssetAuditIssue>, 3613 placeholders: Vec<AssetAuditIssue>, 3614 duplicate_paths: Vec<AssetAuditDuplicatePath>, 3615 } 3616 3617 fn push_campaign_finding( 3618 findings: &mut Vec<String>, 3619 seen: &mut HashSet<String>, 3620 message: impl Into<String>, 3621 ) { 3622 let message = message.into(); 3623 if seen.insert(message.clone()) { 3624 findings.push(message); 3625 } 3626 } 3627 3628 fn json_integer(value: &Value) -> Option<i64> { 3629 if let Some(signed) = value.as_i64() { 3630 return Some(signed); 3631 } 3632 value 3633 .as_u64() 3634 .and_then(|unsigned| i64::try_from(unsigned).ok()) 3635 } 3636 3637 fn lint_string_array_field( 3638 object: &Map<String, Value>, 3639 field: &str, 3640 prefix: &str, 3641 findings: &mut Vec<String>, 3642 seen: &mut HashSet<String>, 3643 ) { 3644 let Some(value) = object.get(field) else { 3645 return; 3646 }; 3647 let Some(entries) = value.as_array() else { 3648 push_campaign_finding( 3649 findings, 3650 seen, 3651 format!("{}.{} must be an array of strings", prefix, field), 3652 ); 3653 return; 3654 }; 3655 for (index, entry) in entries.iter().enumerate() { 3656 let Some(raw) = entry.as_str() else { 3657 push_campaign_finding( 3658 findings, 3659 seen, 3660 format!("{}.{}[{}] must be a string", prefix, field, index), 3661 ); 3662 continue; 3663 }; 3664 if raw.trim().is_empty() { 3665 push_campaign_finding( 3666 findings, 3667 seen, 3668 format!("{}.{}[{}] cannot be empty", prefix, field, index), 3669 ); 3670 } 3671 } 3672 } 3673 3674 fn lint_mission_result_requirement( 3675 object: &Map<String, Value>, 3676 field: &str, 3677 prefix: &str, 3678 findings: &mut Vec<String>, 3679 seen: &mut HashSet<String>, 3680 ) { 3681 let Some(value) = object.get(field) else { 3682 return; 3683 }; 3684 if let Some(text) = value.as_str() { 3685 if text.trim().is_empty() { 3686 push_campaign_finding( 3687 findings, 3688 seen, 3689 format!("{}.{} cannot be empty", prefix, field), 3690 ); 3691 } 3692 return; 3693 } 3694 let Some(entries) = value.as_array() else { 3695 push_campaign_finding( 3696 findings, 3697 seen, 3698 format!("{}.{} must be a string or array of strings", prefix, field), 3699 ); 3700 return; 3701 }; 3702 if entries.is_empty() { 3703 push_campaign_finding( 3704 findings, 3705 seen, 3706 format!("{}.{} cannot be empty", prefix, field), 3707 ); 3708 } 3709 for (index, entry) in entries.iter().enumerate() { 3710 let Some(raw) = entry.as_str() else { 3711 push_campaign_finding( 3712 findings, 3713 seen, 3714 format!("{}.{}[{}] must be a string", prefix, field, index), 3715 ); 3716 continue; 3717 }; 3718 if raw.trim().is_empty() { 3719 push_campaign_finding( 3720 findings, 3721 seen, 3722 format!("{}.{}[{}] cannot be empty", prefix, field, index), 3723 ); 3724 } 3725 } 3726 } 3727 3728 fn lint_requirement_list_field( 3729 object: &Map<String, Value>, 3730 field: &str, 3731 prefix: &str, 3732 allow_equals: bool, 3733 findings: &mut Vec<String>, 3734 seen: &mut HashSet<String>, 3735 ) { 3736 let Some(value) = object.get(field) else { 3737 return; 3738 }; 3739 let Some(entries) = value.as_array() else { 3740 push_campaign_finding( 3741 findings, 3742 seen, 3743 format!("{}.{} must be an array", prefix, field), 3744 ); 3745 return; 3746 }; 3747 for (index, entry) in entries.iter().enumerate() { 3748 let entry_prefix = format!("{}.{}[{}]", prefix, field, index); 3749 if let Some(item_id) = entry.as_str() { 3750 if item_id.trim().is_empty() { 3751 push_campaign_finding(findings, seen, format!("{} cannot be empty", entry_prefix)); 3752 } 3753 continue; 3754 } 3755 let Some(spec) = entry.as_object() else { 3756 push_campaign_finding( 3757 findings, 3758 seen, 3759 format!("{} must be a string or object", entry_prefix), 3760 ); 3761 continue; 3762 }; 3763 let id = spec 3764 .get("id") 3765 .and_then(|value| value.as_str()) 3766 .unwrap_or("") 3767 .trim() 3768 .to_string(); 3769 if id.is_empty() { 3770 push_campaign_finding( 3771 findings, 3772 seen, 3773 format!("{}.id must be a non-empty string", entry_prefix), 3774 ); 3775 } 3776 let mut min_value: Option<i64> = None; 3777 let mut max_value: Option<i64> = None; 3778 let mut equals_value: Option<i64> = None; 3779 for key in ["quantity", "at_least", "min", "at_most", "max", "equals"] { 3780 let Some(number) = spec.get(key) else { 3781 continue; 3782 }; 3783 let Some(parsed) = json_integer(number) else { 3784 push_campaign_finding( 3785 findings, 3786 seen, 3787 format!("{}.{} must be an integer", entry_prefix, key), 3788 ); 3789 continue; 3790 }; 3791 if parsed < 0 { 3792 push_campaign_finding( 3793 findings, 3794 seen, 3795 format!("{}.{} cannot be negative", entry_prefix, key), 3796 ); 3797 } 3798 match key { 3799 "quantity" | "at_least" | "min" => { 3800 min_value = Some(parsed); 3801 } 3802 "at_most" | "max" => { 3803 max_value = Some(parsed); 3804 } 3805 "equals" => { 3806 equals_value = Some(parsed); 3807 } 3808 _ => {} 3809 } 3810 } 3811 if !allow_equals && spec.contains_key("equals") { 3812 push_campaign_finding( 3813 findings, 3814 seen, 3815 format!( 3816 "{}.equals is only valid for requires_resources", 3817 entry_prefix 3818 ), 3819 ); 3820 } 3821 let min_bound = min_value.unwrap_or(1); 3822 if let Some(max_bound) = max_value { 3823 if max_bound < min_bound { 3824 push_campaign_finding( 3825 findings, 3826 seen, 3827 format!( 3828 "{} has invalid bounds (max {} < min {})", 3829 entry_prefix, max_bound, min_bound 3830 ), 3831 ); 3832 } 3833 } 3834 if let Some(equals) = equals_value { 3835 if min_value.is_some() || max_value.is_some() { 3836 push_campaign_finding( 3837 findings, 3838 seen, 3839 format!("{} cannot mix equals with min/max fields", entry_prefix), 3840 ); 3841 } 3842 if equals < 0 { 3843 push_campaign_finding( 3844 findings, 3845 seen, 3846 format!("{}.equals cannot be negative", entry_prefix), 3847 ); 3848 } 3849 } 3850 } 3851 } 3852 3853 fn lint_campaign_requirements( 3854 campaign_value: &Value, 3855 findings: &mut Vec<String>, 3856 seen: &mut HashSet<String>, 3857 ) { 3858 let Some(root) = campaign_value.as_object() else { 3859 push_campaign_finding(findings, seen, "campaign root must be an object"); 3860 return; 3861 }; 3862 if let Some(nodes) = root.get("nodes").and_then(|value| value.as_array()) { 3863 for (index, node) in nodes.iter().enumerate() { 3864 let Some(node_object) = node.as_object() else { 3865 push_campaign_finding( 3866 findings, 3867 seen, 3868 format!("campaign.nodes[{}] must be an object", index), 3869 ); 3870 continue; 3871 }; 3872 let prefix = format!("campaign.nodes[{}]", index); 3873 lint_string_array_field(node_object, "requires_flags", &prefix, findings, seen); 3874 lint_string_array_field(node_object, "excludes_flags", &prefix, findings, seen); 3875 lint_mission_result_requirement( 3876 node_object, 3877 "requires_mission_result", 3878 &prefix, 3879 findings, 3880 seen, 3881 ); 3882 lint_requirement_list_field( 3883 node_object, 3884 "requires_items", 3885 &prefix, 3886 false, 3887 findings, 3888 seen, 3889 ); 3890 lint_requirement_list_field( 3891 node_object, 3892 "requires_resources", 3893 &prefix, 3894 true, 3895 findings, 3896 seen, 3897 ); 3898 } 3899 } 3900 if let Some(dialogues) = root.get("dialogues").and_then(|value| value.as_array()) { 3901 for (dialogue_index, dialogue) in dialogues.iter().enumerate() { 3902 let Some(dialogue_object) = dialogue.as_object() else { 3903 push_campaign_finding( 3904 findings, 3905 seen, 3906 format!("campaign.dialogues[{}] must be an object", dialogue_index), 3907 ); 3908 continue; 3909 }; 3910 let choices = dialogue_object 3911 .get("choices") 3912 .and_then(|value| value.as_array()); 3913 let Some(choices) = choices else { 3914 continue; 3915 }; 3916 for (choice_index, choice) in choices.iter().enumerate() { 3917 let Some(choice_object) = choice.as_object() else { 3918 push_campaign_finding( 3919 findings, 3920 seen, 3921 format!( 3922 "campaign.dialogues[{}].choices[{}] must be an object", 3923 dialogue_index, choice_index 3924 ), 3925 ); 3926 continue; 3927 }; 3928 let prefix = format!( 3929 "campaign.dialogues[{}].choices[{}]", 3930 dialogue_index, choice_index 3931 ); 3932 lint_string_array_field(choice_object, "requires_flags", &prefix, findings, seen); 3933 lint_string_array_field(choice_object, "excludes_flags", &prefix, findings, seen); 3934 lint_mission_result_requirement( 3935 choice_object, 3936 "requires_mission_result", 3937 &prefix, 3938 findings, 3939 seen, 3940 ); 3941 lint_requirement_list_field( 3942 choice_object, 3943 "requires_items", 3944 &prefix, 3945 false, 3946 findings, 3947 seen, 3948 ); 3949 lint_requirement_list_field( 3950 choice_object, 3951 "requires_resources", 3952 &prefix, 3953 true, 3954 findings, 3955 seen, 3956 ); 3957 } 3958 } 3959 } 3960 } 3961 3962 fn lint_campaign_graph( 3963 campaign: &CampaignRuntimeDefinition, 3964 findings: &mut Vec<String>, 3965 seen: &mut HashSet<String>, 3966 ) { 3967 let node_ids: HashSet<String> = campaign 3968 .nodes 3969 .iter() 3970 .filter_map(|node| { 3971 let id = node.id.trim(); 3972 if id.is_empty() { 3973 None 3974 } else { 3975 Some(id.to_string()) 3976 } 3977 }) 3978 .collect(); 3979 let dialogue_ids: HashSet<String> = campaign 3980 .dialogues 3981 .iter() 3982 .filter_map(|dialogue| { 3983 let id = dialogue.id.trim(); 3984 if id.is_empty() { 3985 None 3986 } else { 3987 Some(id.to_string()) 3988 } 3989 }) 3990 .collect(); 3991 3992 for node in &campaign.nodes { 3993 if node.id.trim().is_empty() { 3994 continue; 3995 } 3996 for next in &node.next { 3997 if next.trim().is_empty() { 3998 push_campaign_finding( 3999 findings, 4000 seen, 4001 format!("campaign node {} has empty next reference", node.id), 4002 ); 4003 continue; 4004 } 4005 if !node_ids.contains(next) { 4006 push_campaign_finding( 4007 findings, 4008 seen, 4009 format!( 4010 "campaign node {} next references unknown node {}", 4011 node.id, next 4012 ), 4013 ); 4014 } 4015 } 4016 for required in &node.requires_nodes { 4017 if required.trim().is_empty() { 4018 push_campaign_finding( 4019 findings, 4020 seen, 4021 format!("campaign node {} has empty requires_nodes entry", node.id), 4022 ); 4023 continue; 4024 } 4025 if !node_ids.contains(required) { 4026 push_campaign_finding( 4027 findings, 4028 seen, 4029 format!( 4030 "campaign node {} requires unknown node {}", 4031 node.id, required 4032 ), 4033 ); 4034 } 4035 } 4036 if let Some(dialogue) = &node.dialogue { 4037 if !dialogue.trim().is_empty() && !dialogue_ids.contains(dialogue) { 4038 push_campaign_finding( 4039 findings, 4040 seen, 4041 format!( 4042 "campaign node {} references unknown dialogue {}", 4043 node.id, dialogue 4044 ), 4045 ); 4046 } 4047 } 4048 } 4049 4050 for dialogue in &campaign.dialogues { 4051 if dialogue.id.trim().is_empty() { 4052 continue; 4053 } 4054 for (choice_index, choice) in dialogue.choices.iter().enumerate() { 4055 if let Some(next) = &choice.next { 4056 if next.trim().is_empty() { 4057 continue; 4058 } 4059 if !dialogue_ids.contains(next) { 4060 push_campaign_finding( 4061 findings, 4062 seen, 4063 format!( 4064 "dialogue {} choice {} references unknown dialogue {}", 4065 dialogue.id, choice_index, next 4066 ), 4067 ); 4068 } 4069 } 4070 } 4071 } 4072 4073 let nodes_by_id: HashMap<String, &CampaignRuntimeNode> = campaign 4074 .nodes 4075 .iter() 4076 .filter_map(|node| { 4077 let id = node.id.trim(); 4078 if id.is_empty() { 4079 None 4080 } else { 4081 Some((id.to_string(), node)) 4082 } 4083 }) 4084 .collect(); 4085 let mut reachable: HashSet<String> = HashSet::new(); 4086 for node in &campaign.nodes { 4087 if node.id.trim().is_empty() { 4088 continue; 4089 } 4090 if node.start || node.requires_nodes.is_empty() { 4091 reachable.insert(node.id.clone()); 4092 } 4093 } 4094 let mut changed = true; 4095 while changed { 4096 changed = false; 4097 for node in &campaign.nodes { 4098 if node.id.trim().is_empty() || reachable.contains(&node.id) { 4099 continue; 4100 } 4101 let requires_reachable = node 4102 .requires_nodes 4103 .iter() 4104 .all(|required| reachable.contains(required)); 4105 let next_reachable = nodes_by_id.values().any(|source| { 4106 reachable.contains(&source.id) 4107 && source.next.iter().any(|target| target == &node.id) 4108 }); 4109 if node.start || requires_reachable || next_reachable { 4110 reachable.insert(node.id.clone()); 4111 changed = true; 4112 } 4113 } 4114 } 4115 4116 let mut unreachable: Vec<String> = campaign 4117 .nodes 4118 .iter() 4119 .filter_map(|node| { 4120 let id = node.id.trim(); 4121 if id.is_empty() || reachable.contains(id) { 4122 None 4123 } else { 4124 Some(id.to_string()) 4125 } 4126 }) 4127 .collect(); 4128 unreachable.sort(); 4129 for node_id in unreachable { 4130 push_campaign_finding(findings, seen, format!("unreachable node {}", node_id)); 4131 } 4132 4133 let mut outgoing: HashMap<String, usize> = HashMap::new(); 4134 for node in &campaign.nodes { 4135 if node.id.trim().is_empty() { 4136 continue; 4137 } 4138 let mut count = 0_usize; 4139 count += node.next.len(); 4140 count += campaign 4141 .nodes 4142 .iter() 4143 .filter(|other| { 4144 other 4145 .requires_nodes 4146 .iter() 4147 .any(|required| required == &node.id) 4148 }) 4149 .count(); 4150 outgoing.insert(node.id.clone(), count); 4151 } 4152 4153 let mut dead_ends = Vec::new(); 4154 for node in &campaign.nodes { 4155 if node.id.trim().is_empty() || !reachable.contains(&node.id) { 4156 continue; 4157 } 4158 let kind = node.kind.trim().to_ascii_lowercase(); 4159 if kind == "mission" || kind == "reward" { 4160 continue; 4161 } 4162 let outgoing_count = *outgoing.get(&node.id).unwrap_or(&0); 4163 if outgoing_count == 0 { 4164 dead_ends.push(node.id.clone()); 4165 } 4166 } 4167 dead_ends.sort(); 4168 for node_id in dead_ends { 4169 push_campaign_finding(findings, seen, format!("dead-end branch node {}", node_id)); 4170 } 4171 } 4172 4173 struct CampaignAnalysis { 4174 findings: Vec<String>, 4175 runtime: Option<CampaignRuntimeDefinition>, 4176 } 4177 4178 fn analyze_campaign(campaign_path: &str) -> Result<CampaignAnalysis, String> { 4179 let data = fs::read_to_string(campaign_path).map_err(|err| err.to_string())?; 4180 let campaign_value: Value = serde_json::from_str(&data).map_err(|err| err.to_string())?; 4181 let mut findings = Vec::new(); 4182 let mut seen = HashSet::new(); 4183 4184 match serde_json::from_value::<CampaignDefinition>(campaign_value.clone()) { 4185 Ok(campaign) => { 4186 if let Err(errors) = campaign.validate() { 4187 for error in errors { 4188 push_campaign_finding(&mut findings, &mut seen, error); 4189 } 4190 } 4191 } 4192 Err(err) => { 4193 push_campaign_finding( 4194 &mut findings, 4195 &mut seen, 4196 format!("campaign schema parse failed: {}", err), 4197 ); 4198 } 4199 } 4200 4201 lint_campaign_requirements(&campaign_value, &mut findings, &mut seen); 4202 4203 let runtime = match serde_json::from_value::<CampaignRuntimeDefinition>(campaign_value) { 4204 Ok(runtime) => { 4205 lint_campaign_graph(&runtime, &mut findings, &mut seen); 4206 Some(runtime) 4207 } 4208 Err(err) => { 4209 push_campaign_finding( 4210 &mut findings, 4211 &mut seen, 4212 format!("campaign runtime parse failed: {}", err), 4213 ); 4214 None 4215 } 4216 }; 4217 4218 findings.sort(); 4219 findings.dedup(); 4220 Ok(CampaignAnalysis { findings, runtime }) 4221 } 4222 4223 fn find_member_mut<'a>( 4224 state: &'a mut CampaignHarnessState, 4225 member_id: &str, 4226 ) -> Option<&'a mut PartyMember> { 4227 state 4228 .roster 4229 .iter_mut() 4230 .find(|member| member.id == member_id) 4231 } 4232 4233 fn inventory_quantity(state: &CampaignHarnessState, item_id: &str) -> i64 { 4234 state 4235 .inventory 4236 .iter() 4237 .filter(|item| item.id == item_id) 4238 .map(|item| i64::from(item.quantity)) 4239 .sum() 4240 } 4241 4242 fn add_inventory_item(state: &mut CampaignHarnessState, item_id: &str, delta: i64) { 4243 if item_id.trim().is_empty() || delta <= 0 { 4244 return; 4245 } 4246 let delta_u32 = u32::try_from(delta).unwrap_or(u32::MAX); 4247 if let Some(stack) = state.inventory.iter_mut().find(|item| item.id == item_id) { 4248 stack.quantity = stack.quantity.saturating_add(delta_u32); 4249 } else { 4250 state.inventory.push(ItemStack { 4251 id: item_id.to_string(), 4252 quantity: delta_u32, 4253 }); 4254 } 4255 } 4256 4257 fn remove_inventory_item(state: &mut CampaignHarnessState, item_id: &str, delta: i64) { 4258 if item_id.trim().is_empty() || delta <= 0 { 4259 return; 4260 } 4261 let mut remaining = delta; 4262 let mut next_inventory = Vec::new(); 4263 for stack in &state.inventory { 4264 if stack.id != item_id || remaining <= 0 { 4265 if stack.quantity > 0 { 4266 next_inventory.push(stack.clone()); 4267 } 4268 continue; 4269 } 4270 let qty = i64::from(stack.quantity); 4271 if qty > remaining { 4272 let keep = qty - remaining; 4273 if let Ok(keep_u32) = u32::try_from(keep) { 4274 if keep_u32 > 0 { 4275 next_inventory.push(ItemStack { 4276 id: stack.id.clone(), 4277 quantity: keep_u32, 4278 }); 4279 } 4280 } 4281 remaining = 0; 4282 } else { 4283 remaining -= qty; 4284 } 4285 } 4286 state.inventory = next_inventory; 4287 } 4288 4289 fn add_resource(state: &mut CampaignHarnessState, resource_id: &str, delta: i64) { 4290 if resource_id.trim().is_empty() || delta == 0 { 4291 return; 4292 } 4293 let current = state.world.resources.get(resource_id).copied().unwrap_or(0); 4294 let next = current.saturating_add(delta); 4295 if next == 0 { 4296 state.world.resources.remove(resource_id); 4297 } else { 4298 state.world.resources.insert(resource_id.to_string(), next); 4299 } 4300 } 4301 4302 fn apply_reward_value(state: &mut CampaignHarnessState, reward: &Value) { 4303 let Some(object) = reward.as_object() else { 4304 return; 4305 }; 4306 let reward_type = object 4307 .get("type") 4308 .and_then(|value| value.as_str()) 4309 .unwrap_or("") 4310 .trim() 4311 .to_ascii_lowercase(); 4312 match reward_type.as_str() { 4313 "item" => { 4314 let Some(item_id) = object.get("id").and_then(|value| value.as_str()) else { 4315 return; 4316 }; 4317 let quantity = object 4318 .get("quantity") 4319 .and_then(json_integer) 4320 .unwrap_or(1) 4321 .max(1); 4322 add_inventory_item(state, item_id, quantity); 4323 } 4324 "flag" => { 4325 let Some(flag) = object.get("flag").and_then(|value| value.as_str()) else { 4326 return; 4327 }; 4328 let value = object 4329 .get("value") 4330 .and_then(|value| value.as_bool()) 4331 .unwrap_or(true); 4332 state.flags.insert(flag.to_string(), value); 4333 } 4334 "ability" => { 4335 let Some(ability_id_u64) = object.get("ability_id").and_then(|value| value.as_u64()) 4336 else { 4337 return; 4338 }; 4339 let Ok(ability_id) = u32::try_from(ability_id_u64) else { 4340 return; 4341 }; 4342 let Some(target_member) = object.get("target_member").and_then(|value| value.as_str()) 4343 else { 4344 return; 4345 }; 4346 if let Some(member) = find_member_mut(state, target_member) { 4347 if !member.abilities.iter().any(|value| *value == ability_id) { 4348 member.abilities.push(ability_id); 4349 } 4350 } 4351 } 4352 "upgrade" => { 4353 let Some(target_member) = object.get("target_member").and_then(|value| value.as_str()) 4354 else { 4355 return; 4356 }; 4357 let Some(upgrade) = object.get("upgrade").and_then(|value| value.as_str()) else { 4358 return; 4359 }; 4360 if let Some(member) = find_member_mut(state, target_member) { 4361 if !member.upgrades.iter().any(|value| value == upgrade) { 4362 member.upgrades.push(upgrade.to_string()); 4363 } 4364 } 4365 } 4366 "add_member" => { 4367 let Some(member_value) = object.get("member") else { 4368 return; 4369 }; 4370 let Ok(member) = serde_json::from_value::<PartyMember>(member_value.clone()) else { 4371 return; 4372 }; 4373 if member.id.trim().is_empty() { 4374 return; 4375 } 4376 if !state.roster.iter().any(|existing| existing.id == member.id) { 4377 state.roster.push(member); 4378 } 4379 } 4380 "heal_party" => { 4381 let amount = object.get("amount").and_then(json_integer).unwrap_or(0) as i32; 4382 for member in &mut state.roster { 4383 let next_hp = member.hp.saturating_add(amount); 4384 member.hp = next_hp.clamp(0, member.max_hp); 4385 } 4386 } 4387 "resource" => { 4388 let resource_id = object 4389 .get("resource") 4390 .or_else(|| object.get("id")) 4391 .and_then(|value| value.as_str()); 4392 let Some(resource_id) = resource_id else { 4393 return; 4394 }; 4395 let amount = object.get("amount").and_then(json_integer).unwrap_or(0); 4396 add_resource(state, resource_id, amount); 4397 } 4398 "mission_result" => { 4399 let Some(value) = object.get("value").and_then(|value| value.as_str()) else { 4400 return; 4401 }; 4402 if value.trim().is_empty() { 4403 return; 4404 } 4405 state.world.mission_result = Some(value.to_string()); 4406 } 4407 _ => {} 4408 } 4409 } 4410 4411 fn normalize_campaign_harness_state(state: &mut CampaignHarnessState) { 4412 state.version = dynostic_core::CAMPAIGN_STATE_VERSION; 4413 let mut deduped_flags = BTreeMap::new(); 4414 for (key, value) in &state.flags { 4415 if !key.trim().is_empty() { 4416 deduped_flags.insert(key.clone(), *value); 4417 } 4418 } 4419 state.flags = deduped_flags; 4420 4421 let mut deduped_completed = state.completed_nodes.clone(); 4422 deduped_completed.retain(|id| !id.trim().is_empty()); 4423 deduped_completed.sort(); 4424 deduped_completed.dedup(); 4425 state.completed_nodes = deduped_completed; 4426 4427 let mut inventory_map: BTreeMap<String, u64> = BTreeMap::new(); 4428 for stack in &state.inventory { 4429 if stack.id.trim().is_empty() || stack.quantity == 0 { 4430 continue; 4431 } 4432 let entry = inventory_map.entry(stack.id.clone()).or_insert(0); 4433 *entry = entry.saturating_add(u64::from(stack.quantity)); 4434 } 4435 state.inventory = inventory_map 4436 .into_iter() 4437 .map(|(id, quantity)| ItemStack { 4438 id, 4439 quantity: u32::try_from(quantity).unwrap_or(u32::MAX), 4440 }) 4441 .collect(); 4442 4443 state.roster.sort_by(|a, b| a.id.cmp(&b.id)); 4444 state.roster.dedup_by(|a, b| a.id == b.id); 4445 for member in &mut state.roster { 4446 member.abilities.sort_unstable(); 4447 member.abilities.dedup(); 4448 member.upgrades.sort(); 4449 member.upgrades.dedup(); 4450 member.hp = member.hp.clamp(0, member.max_hp); 4451 } 4452 4453 for scars in state.mission_scars.values_mut() { 4454 scars.sort_by_key(|scar| (scar.y, scar.x, scar.kind as u8)); 4455 scars.dedup_by_key(|scar| (scar.x, scar.y, scar.kind as u8)); 4456 } 4457 4458 state 4459 .world 4460 .resources 4461 .retain(|key, value| !key.trim().is_empty() && *value != 0); 4462 if state 4463 .world 4464 .mission_result 4465 .as_deref() 4466 .map(|value| value.trim().is_empty()) 4467 .unwrap_or(false) 4468 { 4469 state.world.mission_result = None; 4470 } 4471 } 4472 4473 fn campaign_requirement_id(spec: &CampaignRequirementSpec) -> Option<&str> { 4474 match spec { 4475 CampaignRequirementSpec::Id(id) => { 4476 let trimmed = id.trim(); 4477 if trimmed.is_empty() { 4478 None 4479 } else { 4480 Some(trimmed) 4481 } 4482 } 4483 CampaignRequirementSpec::Entry(entry) => { 4484 let trimmed = entry.id.trim(); 4485 if trimmed.is_empty() { 4486 None 4487 } else { 4488 Some(trimmed) 4489 } 4490 } 4491 } 4492 } 4493 4494 fn campaign_requirement_min_max( 4495 spec: &CampaignRequirementSpec, 4496 default_min: i64, 4497 ) -> (i64, Option<i64>, Option<i64>) { 4498 match spec { 4499 CampaignRequirementSpec::Id(_) => (default_min, None, None), 4500 CampaignRequirementSpec::Entry(entry) => { 4501 let min = entry 4502 .quantity 4503 .or(entry.at_least) 4504 .or(entry.min) 4505 .unwrap_or(default_min); 4506 let max = entry.at_most.or(entry.max); 4507 let equals = entry.equals; 4508 (min, max, equals) 4509 } 4510 } 4511 } 4512 4513 fn campaign_requirements_met( 4514 state: &CampaignHarnessState, 4515 requires_flags: &[String], 4516 excludes_flags: &[String], 4517 requires_mission_result: &Option<CampaignStringOrList>, 4518 requires_items: &[CampaignRequirementSpec], 4519 requires_resources: &[CampaignRequirementSpec], 4520 ) -> bool { 4521 if requires_flags 4522 .iter() 4523 .any(|flag| !state.flags.get(flag).copied().unwrap_or(false)) 4524 { 4525 return false; 4526 } 4527 if excludes_flags 4528 .iter() 4529 .any(|flag| state.flags.get(flag).copied().unwrap_or(false)) 4530 { 4531 return false; 4532 } 4533 if let Some(required_result) = requires_mission_result { 4534 let current = state.world.mission_result.as_deref(); 4535 if current.is_none() || !required_result.contains(current.unwrap()) { 4536 return false; 4537 } 4538 } 4539 for requirement in requires_items { 4540 let Some(item_id) = campaign_requirement_id(requirement) else { 4541 return false; 4542 }; 4543 let (min, max, equals) = campaign_requirement_min_max(requirement, 1); 4544 let have = inventory_quantity(state, item_id); 4545 if let Some(equals) = equals { 4546 if have != equals { 4547 return false; 4548 } 4549 continue; 4550 } 4551 if have < min { 4552 return false; 4553 } 4554 if let Some(max) = max { 4555 if have > max { 4556 return false; 4557 } 4558 } 4559 } 4560 for requirement in requires_resources { 4561 let Some(resource_id) = campaign_requirement_id(requirement) else { 4562 return false; 4563 }; 4564 let (min, max, equals) = campaign_requirement_min_max(requirement, 1); 4565 let have = state.world.resources.get(resource_id).copied().unwrap_or(0); 4566 if let Some(equals) = equals { 4567 if have != equals { 4568 return false; 4569 } 4570 continue; 4571 } 4572 if have < min { 4573 return false; 4574 } 4575 if let Some(max) = max { 4576 if have > max { 4577 return false; 4578 } 4579 } 4580 } 4581 true 4582 } 4583 4584 fn campaign_node_available(node: &CampaignRuntimeNode, state: &CampaignHarnessState) -> bool { 4585 if state 4586 .completed_nodes 4587 .iter() 4588 .any(|completed| completed == &node.id) 4589 { 4590 return false; 4591 } 4592 if !node.start 4593 && node.requires_nodes.iter().any(|required| { 4594 !state 4595 .completed_nodes 4596 .iter() 4597 .any(|completed| completed == required) 4598 }) 4599 { 4600 return false; 4601 } 4602 campaign_requirements_met( 4603 state, 4604 &node.requires_flags, 4605 &node.excludes_flags, 4606 &node.requires_mission_result, 4607 &node.requires_items, 4608 &node.requires_resources, 4609 ) 4610 } 4611 4612 fn campaign_available_nodes<'a>( 4613 campaign: &'a CampaignRuntimeDefinition, 4614 state: &CampaignHarnessState, 4615 ) -> Vec<&'a CampaignRuntimeNode> { 4616 let mut nodes = campaign 4617 .nodes 4618 .iter() 4619 .filter(|node| !node.id.trim().is_empty() && campaign_node_available(node, state)) 4620 .collect::<Vec<_>>(); 4621 nodes.sort_by(|a, b| a.id.cmp(&b.id)); 4622 nodes 4623 } 4624 4625 fn campaign_dialogue_choices( 4626 dialogue: &CampaignRuntimeDialogue, 4627 state: &CampaignHarnessState, 4628 ) -> Vec<usize> { 4629 dialogue 4630 .choices 4631 .iter() 4632 .enumerate() 4633 .filter_map(|(index, choice)| { 4634 if campaign_requirements_met( 4635 state, 4636 &choice.requires_flags, 4637 &choice.excludes_flags, 4638 &choice.requires_mission_result, 4639 &choice.requires_items, 4640 &choice.requires_resources, 4641 ) { 4642 Some(index) 4643 } else { 4644 None 4645 } 4646 }) 4647 .collect() 4648 } 4649 4650 fn campaign_select_node( 4651 campaign: &CampaignRuntimeDefinition, 4652 state: &mut CampaignHarnessState, 4653 node_id: &str, 4654 ) -> Result<(), String> { 4655 let available = campaign_available_nodes(campaign, state); 4656 if !available.iter().any(|node| node.id == node_id) { 4657 let available_ids: Vec<String> = available.iter().map(|node| node.id.clone()).collect(); 4658 return Err(format!( 4659 "node {} is not available (available: {})", 4660 node_id, 4661 available_ids.join(", ") 4662 )); 4663 } 4664 let node = campaign 4665 .nodes 4666 .iter() 4667 .find(|node| node.id == node_id) 4668 .ok_or_else(|| format!("node {} not found", node_id))?; 4669 state.current_node = Some(node.id.clone()); 4670 state.active_dialogue = node 4671 .dialogue 4672 .as_ref() 4673 .filter(|dialogue| !dialogue.trim().is_empty()) 4674 .cloned(); 4675 Ok(()) 4676 } 4677 4678 fn campaign_choose_dialogue( 4679 campaign: &CampaignRuntimeDefinition, 4680 state: &mut CampaignHarnessState, 4681 choice_index: usize, 4682 ) -> Result<CampaignRunDialogueTrace, String> { 4683 let dialogue_id = state 4684 .active_dialogue 4685 .clone() 4686 .ok_or_else(|| "no active dialogue".to_string())?; 4687 let dialogue = campaign 4688 .dialogues 4689 .iter() 4690 .find(|dialogue| dialogue.id == dialogue_id) 4691 .ok_or_else(|| format!("dialogue {} not found", dialogue_id))?; 4692 let available = campaign_dialogue_choices(dialogue, state); 4693 if !available.iter().any(|index| *index == choice_index) { 4694 return Err(format!( 4695 "dialogue {} choice {} is not available", 4696 dialogue_id, choice_index 4697 )); 4698 } 4699 let choice = dialogue 4700 .choices 4701 .get(choice_index) 4702 .ok_or_else(|| format!("dialogue {} choice {} missing", dialogue_id, choice_index))?; 4703 for flag in &choice.set_flags { 4704 if !flag.trim().is_empty() { 4705 state.flags.insert(flag.clone(), true); 4706 } 4707 } 4708 for reward in &choice.rewards { 4709 apply_reward_value(state, reward); 4710 } 4711 state.active_dialogue = choice 4712 .next 4713 .as_ref() 4714 .filter(|next| !next.trim().is_empty()) 4715 .cloned(); 4716 normalize_campaign_harness_state(state); 4717 Ok(CampaignRunDialogueTrace { 4718 dialogue_id, 4719 choice_index, 4720 choice_text: choice.text.clone(), 4721 }) 4722 } 4723 4724 fn campaign_complete_current_node( 4725 campaign: &CampaignRuntimeDefinition, 4726 state: &mut CampaignHarnessState, 4727 ) -> Result<(), String> { 4728 let node_id = state 4729 .current_node 4730 .clone() 4731 .ok_or_else(|| "no active node".to_string())?; 4732 if state.active_dialogue.is_some() { 4733 return Err(format!( 4734 "cannot complete node {} while dialogue is active", 4735 node_id 4736 )); 4737 } 4738 let node = campaign 4739 .nodes 4740 .iter() 4741 .find(|node| node.id == node_id) 4742 .ok_or_else(|| format!("node {} not found", node_id))?; 4743 for reward in &node.rewards { 4744 apply_reward_value(state, reward); 4745 } 4746 if !state 4747 .completed_nodes 4748 .iter() 4749 .any(|completed| completed == &node_id) 4750 { 4751 state.completed_nodes.push(node_id); 4752 } 4753 state.current_node = None; 4754 normalize_campaign_harness_state(state); 4755 Ok(()) 4756 } 4757 4758 fn apply_campaign_node_effect(state: &mut CampaignHarnessState, effect: &CampaignRunNodeEffect) { 4759 if let Some(result) = &effect.mission_result { 4760 if result.trim().is_empty() { 4761 state.world.mission_result = None; 4762 } else { 4763 state.world.mission_result = Some(result.clone()); 4764 } 4765 } 4766 for (flag, value) in &effect.set_flags { 4767 if !flag.trim().is_empty() { 4768 state.flags.insert(flag.clone(), *value); 4769 } 4770 } 4771 for (item_id, amount) in &effect.add_items { 4772 if *amount > 0 { 4773 add_inventory_item(state, item_id, *amount); 4774 } else if *amount < 0 { 4775 remove_inventory_item(state, item_id, amount.abs()); 4776 } 4777 } 4778 for (resource_id, amount) in &effect.add_resources { 4779 add_resource(state, resource_id, *amount); 4780 } 4781 for reward in &effect.rewards { 4782 apply_reward_value(state, reward); 4783 } 4784 normalize_campaign_harness_state(state); 4785 } 4786 4787 fn campaign_initial_state(campaign: &CampaignRuntimeDefinition) -> CampaignHarnessState { 4788 let mut state = CampaignHarnessState { 4789 version: dynostic_core::CAMPAIGN_STATE_VERSION, 4790 roster: campaign.starting_roster.clone(), 4791 inventory: campaign.starting_inventory.clone(), 4792 flags: campaign.starting_flags.clone(), 4793 completed_nodes: Vec::new(), 4794 current_node: None, 4795 active_dialogue: None, 4796 mission_scars: BTreeMap::new(), 4797 world: CampaignHarnessWorld::default(), 4798 }; 4799 normalize_campaign_harness_state(&mut state); 4800 state 4801 } 4802 4803 fn load_campaign_run_script(path: &str) -> Result<CampaignRunScript, String> { 4804 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 4805 if let Ok(node_order) = serde_json::from_str::<Vec<String>>(&data) { 4806 return Ok(CampaignRunScript { 4807 node_order, 4808 ..CampaignRunScript::default() 4809 }); 4810 } 4811 let parsed: CampaignRunScriptFile = 4812 serde_json::from_str(&data).map_err(|err| err.to_string())?; 4813 let node_order = if parsed.node_order.is_empty() { 4814 parsed.nodes 4815 } else { 4816 parsed.node_order 4817 }; 4818 let choice_order = if parsed.choice_order.is_empty() { 4819 parsed.choices 4820 } else { 4821 parsed.choice_order 4822 }; 4823 let mut node_effects = parsed.effects; 4824 for (node_id, effect) in parsed.node_effects { 4825 node_effects.insert(node_id, effect); 4826 } 4827 Ok(CampaignRunScript { 4828 node_order, 4829 choice_order, 4830 node_effects, 4831 }) 4832 } 4833 4834 fn run_campaign_harness( 4835 campaign: &CampaignRuntimeDefinition, 4836 mut state: CampaignHarnessState, 4837 script: &CampaignRunScript, 4838 ) -> Result<CampaignRunSnapshot, String> { 4839 let mut trace = Vec::new(); 4840 let mut step: u32 = 0; 4841 let mut node_cursor = 0_usize; 4842 let mut choice_cursor = 0_usize; 4843 let mut safeguard = campaign.nodes.len().saturating_mul(8).max(32); 4844 4845 loop { 4846 let available = campaign_available_nodes(campaign, &state); 4847 if available.is_empty() { 4848 break; 4849 } 4850 let available_ids: Vec<String> = available.iter().map(|node| node.id.clone()).collect(); 4851 let node_id = if script.node_order.is_empty() { 4852 available_ids 4853 .first() 4854 .cloned() 4855 .ok_or_else(|| "no available campaign nodes".to_string())? 4856 } else if node_cursor < script.node_order.len() { 4857 let candidate = script.node_order[node_cursor].clone(); 4858 node_cursor += 1; 4859 candidate 4860 } else { 4861 break; 4862 }; 4863 4864 campaign_select_node(campaign, &mut state, &node_id)?; 4865 4866 let mut dialogue_trace = Vec::new(); 4867 while state.active_dialogue.is_some() { 4868 let dialogue_id = state.active_dialogue.clone().unwrap_or_default(); 4869 let dialogue = campaign 4870 .dialogues 4871 .iter() 4872 .find(|dialogue| dialogue.id == dialogue_id) 4873 .ok_or_else(|| format!("dialogue {} not found", dialogue_id))?; 4874 let available_choices = campaign_dialogue_choices(dialogue, &state); 4875 if available_choices.is_empty() { 4876 return Err(format!("dialogue {} has no available choices", dialogue_id)); 4877 } 4878 4879 let choice_index = if choice_cursor < script.choice_order.len() { 4880 let scripted = script.choice_order[choice_cursor]; 4881 choice_cursor += 1; 4882 if !available_choices.iter().any(|value| *value == scripted) { 4883 return Err(format!( 4884 "dialogue {} scripted choice {} is unavailable (available: {:?})", 4885 dialogue_id, scripted, available_choices 4886 )); 4887 } 4888 scripted 4889 } else { 4890 *available_choices.first().unwrap_or(&0) 4891 }; 4892 let trace_entry = campaign_choose_dialogue(campaign, &mut state, choice_index)?; 4893 dialogue_trace.push(trace_entry); 4894 } 4895 4896 campaign_complete_current_node(campaign, &mut state)?; 4897 let effect_applied = if let Some(effect) = script.node_effects.get(&node_id) { 4898 apply_campaign_node_effect(&mut state, effect); 4899 true 4900 } else { 4901 false 4902 }; 4903 4904 step = step.saturating_add(1); 4905 trace.push(CampaignRunStepTrace { 4906 step, 4907 node_id, 4908 available_before: available_ids, 4909 dialogue_choices: dialogue_trace, 4910 effect_applied, 4911 mission_result_after: state.world.mission_result.clone(), 4912 }); 4913 4914 safeguard = safeguard.saturating_sub(1); 4915 if safeguard == 0 { 4916 return Err("campaign harness exceeded step budget".to_string()); 4917 } 4918 } 4919 4920 if !script.node_order.is_empty() && node_cursor < script.node_order.len() { 4921 return Err(format!( 4922 "script ended early: {} node(s) were not executed", 4923 script.node_order.len() - node_cursor 4924 )); 4925 } 4926 4927 let remaining_nodes: Vec<String> = campaign_available_nodes(campaign, &state) 4928 .into_iter() 4929 .map(|node| node.id.clone()) 4930 .collect(); 4931 if !script.node_order.is_empty() && !remaining_nodes.is_empty() { 4932 return Err(format!( 4933 "script completed but campaign still has available nodes: {}", 4934 remaining_nodes.join(", ") 4935 )); 4936 } 4937 4938 normalize_campaign_harness_state(&mut state); 4939 let final_state_bytes = serde_json::to_vec(&state).map_err(|err| err.to_string())?; 4940 let final_state_hash = sha256_hex(&final_state_bytes); 4941 Ok(CampaignRunSnapshot { 4942 campaign_id: campaign.id.clone(), 4943 campaign_name: campaign.name.clone(), 4944 completed_nodes: state.completed_nodes.clone(), 4945 remaining_nodes, 4946 final_state_hash, 4947 final_state: state, 4948 trace, 4949 }) 4950 } 4951 4952 #[derive(Default)] 4953 struct CampaignFlags { 4954 campaign: Option<String>, 4955 pack: Option<String>, 4956 script: Option<String>, 4957 state_in: Option<String>, 4958 out: Option<String>, 4959 } 4960 4961 fn print_campaign_usage() { 4962 eprintln!( 4963 "Usage: dynostic_cli campaign <lint|run> [options]\n\ 4964 lint (--campaign PATH | --pack PATH)\n\ 4965 run (--campaign PATH | --pack PATH) --script PATH [--state-in PATH] [--out PATH]" 4966 ); 4967 } 4968 4969 fn parse_campaign_flags(args: &[String]) -> Result<CampaignFlags, String> { 4970 let mut flags = CampaignFlags::default(); 4971 let mut positional = Vec::new(); 4972 let mut iter = args.iter().peekable(); 4973 while let Some(arg) = iter.next() { 4974 match arg.as_str() { 4975 "--campaign" => { 4976 flags.campaign = iter.next().map(|value| value.to_string()); 4977 } 4978 "--pack" => { 4979 flags.pack = iter.next().map(|value| value.to_string()); 4980 } 4981 "--script" => { 4982 flags.script = iter.next().map(|value| value.to_string()); 4983 } 4984 "--state-in" => { 4985 flags.state_in = iter.next().map(|value| value.to_string()); 4986 } 4987 "--out" => { 4988 flags.out = iter.next().map(|value| value.to_string()); 4989 } 4990 _ if arg.starts_with("--campaign=") => { 4991 flags.campaign = Some(arg.trim_start_matches("--campaign=").to_string()); 4992 } 4993 _ if arg.starts_with("--pack=") => { 4994 flags.pack = Some(arg.trim_start_matches("--pack=").to_string()); 4995 } 4996 _ if arg.starts_with("--script=") => { 4997 flags.script = Some(arg.trim_start_matches("--script=").to_string()); 4998 } 4999 _ if arg.starts_with("--state-in=") => { 5000 flags.state_in = Some(arg.trim_start_matches("--state-in=").to_string()); 5001 } 5002 _ if arg.starts_with("--out=") => { 5003 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 5004 } 5005 _ if arg.starts_with("--") => { 5006 return Err(format!("Unknown flag {}", arg)); 5007 } 5008 _ => { 5009 positional.push(arg.to_string()); 5010 } 5011 } 5012 } 5013 if flags.campaign.is_none() && !positional.is_empty() { 5014 flags.campaign = Some(positional.remove(0)); 5015 } 5016 if flags.script.is_none() && !positional.is_empty() { 5017 flags.script = Some(positional.remove(0)); 5018 } 5019 if !positional.is_empty() { 5020 return Err(format!("Unexpected argument {}", positional[0])); 5021 } 5022 Ok(flags) 5023 } 5024 5025 fn resolve_campaign_path(flags: &CampaignFlags) -> Result<String, String> { 5026 if let Some(path) = flags.campaign.as_deref() { 5027 return Ok(path.to_string()); 5028 } 5029 let Some(pack_path) = flags.pack.as_deref() else { 5030 return Err("Missing --campaign or --pack".to_string()); 5031 }; 5032 let pack = load_pack_manifest(pack_path)?; 5033 let Some(campaign_rel) = pack.campaign.as_deref() else { 5034 return Err(format!("Pack {} has no campaign entry", pack_path)); 5035 }; 5036 let resolved = resolve_relative(pack_path, campaign_rel); 5037 Ok(resolved.to_string_lossy().to_string()) 5038 } 5039 5040 fn handle_campaign_command(args: Vec<String>) { 5041 if args.is_empty() { 5042 print_campaign_usage(); 5043 std::process::exit(2); 5044 } 5045 let subcommand = &args[0]; 5046 let flags = match parse_campaign_flags(&args[1..]) { 5047 Ok(flags) => flags, 5048 Err(err) => { 5049 eprintln!("{}", err); 5050 print_campaign_usage(); 5051 std::process::exit(2); 5052 } 5053 }; 5054 let campaign_path = resolve_campaign_path(&flags).unwrap_or_else(|err| { 5055 eprintln!("{}", err); 5056 print_campaign_usage(); 5057 std::process::exit(2); 5058 }); 5059 5060 match subcommand.as_str() { 5061 "lint" => { 5062 let analysis = analyze_campaign(&campaign_path).unwrap_or_else(|err| { 5063 eprintln!("Failed to analyze campaign {}: {}", campaign_path, err); 5064 std::process::exit(2); 5065 }); 5066 if analysis.findings.is_empty() { 5067 println!("OK: campaign lint clean: {}", campaign_path); 5068 } else { 5069 for finding in analysis.findings { 5070 eprintln!("campaign lint: {}", finding); 5071 } 5072 std::process::exit(2); 5073 } 5074 } 5075 "run" => { 5076 let script_path = flags.script.as_deref().unwrap_or_else(|| { 5077 eprintln!("Missing --script"); 5078 print_campaign_usage(); 5079 std::process::exit(2); 5080 }); 5081 let analysis = analyze_campaign(&campaign_path).unwrap_or_else(|err| { 5082 eprintln!("Failed to analyze campaign {}: {}", campaign_path, err); 5083 std::process::exit(2); 5084 }); 5085 if !analysis.findings.is_empty() { 5086 for finding in &analysis.findings { 5087 eprintln!("campaign lint: {}", finding); 5088 } 5089 eprintln!("campaign run aborted: lint findings present"); 5090 std::process::exit(2); 5091 } 5092 let campaign = analysis.runtime.unwrap_or_else(|| { 5093 eprintln!("campaign run aborted: runtime campaign unavailable"); 5094 std::process::exit(2); 5095 }); 5096 let script = load_campaign_run_script(script_path).unwrap_or_else(|err| { 5097 eprintln!("Failed to parse campaign script {}: {}", script_path, err); 5098 std::process::exit(2); 5099 }); 5100 let state = if let Some(state_path) = flags.state_in.as_deref() { 5101 let data = fs::read_to_string(state_path).unwrap_or_else(|err| { 5102 eprintln!("Failed to read campaign state {}: {}", state_path, err); 5103 std::process::exit(2); 5104 }); 5105 let mut parsed: CampaignHarnessState = 5106 serde_json::from_str(&data).unwrap_or_else(|err| { 5107 eprintln!("Failed to parse campaign state {}: {}", state_path, err); 5108 std::process::exit(2); 5109 }); 5110 normalize_campaign_harness_state(&mut parsed); 5111 parsed 5112 } else { 5113 campaign_initial_state(&campaign) 5114 }; 5115 let snapshot = run_campaign_harness(&campaign, state, &script).unwrap_or_else(|err| { 5116 eprintln!("campaign run failed: {}", err); 5117 std::process::exit(2); 5118 }); 5119 let encoded = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|err| { 5120 eprintln!("Failed to serialize campaign snapshot: {}", err); 5121 std::process::exit(2); 5122 }); 5123 if let Some(out_path) = flags.out.as_deref() { 5124 if let Err(err) = fs::write(out_path, encoded) { 5125 eprintln!("Failed to write {}: {}", out_path, err); 5126 std::process::exit(2); 5127 } 5128 println!("OK: campaign snapshot written: {}", out_path); 5129 } else { 5130 println!("{}", encoded); 5131 } 5132 } 5133 _ => { 5134 eprintln!("Unknown campaign subcommand: {}", subcommand); 5135 print_campaign_usage(); 5136 std::process::exit(2); 5137 } 5138 } 5139 } 5140 5141 #[derive(Default)] 5142 struct AssetFlags { 5143 pack: Option<String>, 5144 out: Option<String>, 5145 strict: bool, 5146 } 5147 5148 fn print_asset_usage() { 5149 eprintln!("Usage: dynostic_cli asset audit --pack PATH [--out PATH] [--strict]"); 5150 } 5151 5152 fn parse_asset_flags(args: &[String]) -> Result<AssetFlags, String> { 5153 let mut flags = AssetFlags::default(); 5154 let mut positional = Vec::new(); 5155 let mut iter = args.iter().peekable(); 5156 while let Some(arg) = iter.next() { 5157 match arg.as_str() { 5158 "--pack" => { 5159 flags.pack = iter.next().map(|value| value.to_string()); 5160 } 5161 "--out" => { 5162 flags.out = iter.next().map(|value| value.to_string()); 5163 } 5164 "--strict" => { 5165 flags.strict = true; 5166 } 5167 _ if arg.starts_with("--pack=") => { 5168 flags.pack = Some(arg.trim_start_matches("--pack=").to_string()); 5169 } 5170 _ if arg.starts_with("--out=") => { 5171 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 5172 } 5173 _ if arg.starts_with("--") => { 5174 return Err(format!("Unknown flag {}", arg)); 5175 } 5176 _ => positional.push(arg.to_string()), 5177 } 5178 } 5179 if flags.pack.is_none() && !positional.is_empty() { 5180 flags.pack = Some(positional.remove(0)); 5181 } 5182 if !positional.is_empty() { 5183 return Err(format!("Unexpected argument {}", positional[0])); 5184 } 5185 Ok(flags) 5186 } 5187 5188 fn looks_placeholder_token(value: &str) -> bool { 5189 let lower = value.to_ascii_lowercase(); 5190 ["placeholder", "todo", "tbd", "dummy", "temp", "stub", "wip"] 5191 .iter() 5192 .any(|token| lower.contains(token)) 5193 } 5194 5195 fn build_asset_audit_report(pack_path: &str) -> Result<AssetAuditReport, String> { 5196 let ctx = build_pack_context(pack_path)?; 5197 let mut missing = Vec::new(); 5198 let mut placeholders = Vec::new(); 5199 let mut by_path: BTreeMap<String, Vec<String>> = BTreeMap::new(); 5200 let mut issues_by_ref = HashSet::new(); 5201 5202 for warning in &ctx.warnings { 5203 let Some(rest) = warning.strip_prefix("missing asset ") else { 5204 continue; 5205 }; 5206 let Some((key, path)) = rest.split_once(": ") else { 5207 continue; 5208 }; 5209 let kind = if ctx.assets.sprites.contains_key(key) { 5210 "sprite" 5211 } else if ctx.assets.audio.contains_key(key) { 5212 "audio" 5213 } else { 5214 "asset" 5215 }; 5216 missing.push(AssetAuditIssue { 5217 kind: kind.to_string(), 5218 key: key.to_string(), 5219 path: path.to_string(), 5220 }); 5221 issues_by_ref.insert(format!("{}:{}", kind, key)); 5222 } 5223 5224 for (kind, assets) in [ 5225 ("sprite", &ctx.assets.sprites), 5226 ("audio", &ctx.assets.audio), 5227 ] { 5228 for (key, path) in assets { 5229 if looks_placeholder_token(key) || looks_placeholder_token(path) { 5230 placeholders.push(AssetAuditIssue { 5231 kind: kind.to_string(), 5232 key: key.clone(), 5233 path: path.clone(), 5234 }); 5235 issues_by_ref.insert(format!("{}:{}", kind, key)); 5236 } 5237 by_path 5238 .entry(path.clone()) 5239 .or_default() 5240 .push(format!("{}:{}", kind, key)); 5241 } 5242 } 5243 5244 let mut duplicate_paths = Vec::new(); 5245 for (path, mut references) in by_path { 5246 if references.len() <= 1 { 5247 continue; 5248 } 5249 references.sort(); 5250 references.dedup(); 5251 for reference in &references { 5252 issues_by_ref.insert(reference.clone()); 5253 } 5254 duplicate_paths.push(AssetAuditDuplicatePath { path, references }); 5255 } 5256 duplicate_paths.sort_by(|a, b| a.path.cmp(&b.path)); 5257 5258 placeholders 5259 .sort_by(|a, b| (a.kind.as_str(), a.key.as_str()).cmp(&(b.kind.as_str(), b.key.as_str()))); 5260 missing 5261 .sort_by(|a, b| (a.kind.as_str(), a.key.as_str()).cmp(&(b.kind.as_str(), b.key.as_str()))); 5262 5263 let checked = ctx.assets.sprites.len() + ctx.assets.audio.len(); 5264 let clean = checked.saturating_sub(issues_by_ref.len()); 5265 Ok(AssetAuditReport { 5266 pack: pack_path.to_string(), 5267 checked, 5268 clean, 5269 missing, 5270 placeholders, 5271 duplicate_paths, 5272 }) 5273 } 5274 5275 fn handle_asset_command(args: Vec<String>) { 5276 if args.is_empty() { 5277 print_asset_usage(); 5278 std::process::exit(2); 5279 } 5280 let subcommand = &args[0]; 5281 match subcommand.as_str() { 5282 "audit" => { 5283 let flags = parse_asset_flags(&args[1..]).unwrap_or_else(|err| { 5284 eprintln!("{}", err); 5285 print_asset_usage(); 5286 std::process::exit(2); 5287 }); 5288 let pack_path = flags.pack.as_deref().unwrap_or_else(|| { 5289 eprintln!("Missing --pack"); 5290 print_asset_usage(); 5291 std::process::exit(2); 5292 }); 5293 let report = build_asset_audit_report(pack_path).unwrap_or_else(|err| { 5294 eprintln!("asset audit failed: {}", err); 5295 std::process::exit(2); 5296 }); 5297 let has_issues = !report.missing.is_empty() 5298 || !report.placeholders.is_empty() 5299 || !report.duplicate_paths.is_empty(); 5300 let encoded = serde_json::to_string_pretty(&report).unwrap_or_else(|err| { 5301 eprintln!("Failed to serialize asset audit report: {}", err); 5302 std::process::exit(2); 5303 }); 5304 if let Some(out_path) = flags.out.as_deref() { 5305 if let Err(err) = fs::write(out_path, encoded) { 5306 eprintln!("Failed to write {}: {}", out_path, err); 5307 std::process::exit(2); 5308 } 5309 println!("OK: asset audit report written: {}", out_path); 5310 } else { 5311 println!("{}", encoded); 5312 } 5313 if has_issues { 5314 eprintln!( 5315 "asset audit: {} missing, {} placeholder, {} duplicate path(s)", 5316 report.missing.len(), 5317 report.placeholders.len(), 5318 report.duplicate_paths.len() 5319 ); 5320 if flags.strict { 5321 std::process::exit(2); 5322 } 5323 } 5324 } 5325 _ => { 5326 eprintln!("Unknown asset subcommand: {}", subcommand); 5327 print_asset_usage(); 5328 std::process::exit(2); 5329 } 5330 } 5331 } 5332 5333 fn print_mod_usage() { 5334 eprintln!( 5335 "Usage: dynostic_cli mod <list|add|enable|disable|remove|publish> [options]\n\ 5336 list [--manifest PATH]\n\ 5337 add --id ID [--path PATH] [--enable|--disable] [--allow-unsigned] [--allow-scripts] [--manifest PATH]\n\ 5338 enable ID [--manifest PATH]\n\ 5339 disable ID [--manifest PATH]\n\ 5340 remove ID [--manifest PATH]\n\ 5341 publish --pack PATH [--registry PATH]" 5342 ); 5343 } 5344 5345 fn handle_mod_command(args: Vec<String>) { 5346 if args.is_empty() { 5347 print_mod_usage(); 5348 std::process::exit(2); 5349 } 5350 let subcommand = &args[0]; 5351 match subcommand.as_str() { 5352 "list" => { 5353 let mut manifest_path = mods_manifest_path(); 5354 let mut iter = args[1..].iter().peekable(); 5355 while let Some(arg) = iter.next() { 5356 match arg.as_str() { 5357 "--manifest" => { 5358 let value = iter 5359 .next() 5360 .unwrap_or_else(|| { 5361 eprintln!("Missing value for --manifest"); 5362 std::process::exit(2); 5363 }) 5364 .to_string(); 5365 manifest_path = PathBuf::from(value); 5366 } 5367 _ if arg.starts_with("--manifest=") => { 5368 manifest_path = PathBuf::from(arg.trim_start_matches("--manifest=")); 5369 } 5370 _ => { 5371 eprintln!("Unknown flag {}", arg); 5372 print_mod_usage(); 5373 std::process::exit(2); 5374 } 5375 } 5376 } 5377 let manifest = load_mod_manifest(&manifest_path).unwrap_or_else(|err| { 5378 eprintln!("Failed to load mods manifest: {}", err); 5379 std::process::exit(2); 5380 }); 5381 if manifest.mods.is_empty() { 5382 println!("No mods configured."); 5383 return; 5384 } 5385 for entry in manifest.mods { 5386 println!( 5387 "{} {} (scripts={}, unsigned={}) {}", 5388 if entry.enabled { "[on]" } else { "[off]" }, 5389 entry.id, 5390 entry.allow_scripts, 5391 entry.allow_unsigned, 5392 entry.path.unwrap_or_else(|| "-".to_string()) 5393 ); 5394 } 5395 } 5396 "add" => { 5397 let mut manifest_path = mods_manifest_path(); 5398 let mut entry = ModEntry { 5399 enabled: true, 5400 ..ModEntry::default() 5401 }; 5402 let mut id: Option<String> = None; 5403 let mut iter = args[1..].iter().peekable(); 5404 while let Some(arg) = iter.next() { 5405 match arg.as_str() { 5406 "--id" => { 5407 id = iter.next().map(|value| value.to_string()); 5408 } 5409 "--path" => { 5410 entry.path = iter.next().map(|value| value.to_string()); 5411 } 5412 "--enable" => { 5413 entry.enabled = true; 5414 } 5415 "--disable" => { 5416 entry.enabled = false; 5417 } 5418 "--allow-unsigned" => { 5419 entry.allow_unsigned = true; 5420 } 5421 "--allow-scripts" => { 5422 entry.allow_scripts = true; 5423 } 5424 "--manifest" => { 5425 let value = iter 5426 .next() 5427 .unwrap_or_else(|| { 5428 eprintln!("Missing value for --manifest"); 5429 std::process::exit(2); 5430 }) 5431 .to_string(); 5432 manifest_path = PathBuf::from(value); 5433 } 5434 _ if arg.starts_with("--id=") => { 5435 id = Some(arg.trim_start_matches("--id=").to_string()); 5436 } 5437 _ if arg.starts_with("--path=") => { 5438 entry.path = Some(arg.trim_start_matches("--path=").to_string()); 5439 } 5440 _ if arg.starts_with("--manifest=") => { 5441 manifest_path = PathBuf::from(arg.trim_start_matches("--manifest=")); 5442 } 5443 _ => { 5444 eprintln!("Unknown flag {}", arg); 5445 print_mod_usage(); 5446 std::process::exit(2); 5447 } 5448 } 5449 } 5450 let Some(id) = id else { 5451 eprintln!("Missing --id for mod add"); 5452 print_mod_usage(); 5453 std::process::exit(2); 5454 }; 5455 entry.id = id.clone(); 5456 let mut manifest = load_mod_manifest(&manifest_path).unwrap_or_else(|err| { 5457 eprintln!("Failed to load mods manifest: {}", err); 5458 std::process::exit(2); 5459 }); 5460 manifest.version = MODS_MANIFEST_VERSION; 5461 if let Some(existing) = manifest.mods.iter_mut().find(|m| m.id == id) { 5462 *existing = entry; 5463 } else { 5464 manifest.mods.push(entry); 5465 } 5466 if let Err(err) = save_mod_manifest(&manifest_path, &manifest) { 5467 eprintln!("Failed to save mods manifest: {}", err); 5468 std::process::exit(2); 5469 } 5470 println!("OK: mod entry saved to {}", manifest_path.display()); 5471 } 5472 "enable" | "disable" | "remove" => { 5473 let mut manifest_path = mods_manifest_path(); 5474 let mut positional: Vec<String> = Vec::new(); 5475 let mut iter = args[1..].iter().peekable(); 5476 while let Some(arg) = iter.next() { 5477 match arg.as_str() { 5478 "--manifest" => { 5479 let value = iter 5480 .next() 5481 .unwrap_or_else(|| { 5482 eprintln!("Missing value for --manifest"); 5483 std::process::exit(2); 5484 }) 5485 .to_string(); 5486 manifest_path = PathBuf::from(value); 5487 } 5488 _ if arg.starts_with("--manifest=") => { 5489 manifest_path = PathBuf::from(arg.trim_start_matches("--manifest=")); 5490 } 5491 _ if arg.starts_with("--") => { 5492 eprintln!("Unknown flag {}", arg); 5493 print_mod_usage(); 5494 std::process::exit(2); 5495 } 5496 _ => positional.push(arg.to_string()), 5497 } 5498 } 5499 let Some(id) = positional.first() else { 5500 eprintln!("Missing mod id"); 5501 print_mod_usage(); 5502 std::process::exit(2); 5503 }; 5504 let mut manifest = load_mod_manifest(&manifest_path).unwrap_or_else(|err| { 5505 eprintln!("Failed to load mods manifest: {}", err); 5506 std::process::exit(2); 5507 }); 5508 manifest.version = MODS_MANIFEST_VERSION; 5509 match subcommand.as_str() { 5510 "enable" => { 5511 if let Some(entry) = manifest.mods.iter_mut().find(|m| m.id == *id) { 5512 entry.enabled = true; 5513 } else { 5514 eprintln!("Unknown mod {}", id); 5515 std::process::exit(2); 5516 } 5517 } 5518 "disable" => { 5519 if let Some(entry) = manifest.mods.iter_mut().find(|m| m.id == *id) { 5520 entry.enabled = false; 5521 } else { 5522 eprintln!("Unknown mod {}", id); 5523 std::process::exit(2); 5524 } 5525 } 5526 "remove" => { 5527 let before = manifest.mods.len(); 5528 manifest.mods.retain(|m| m.id != *id); 5529 if manifest.mods.len() == before { 5530 eprintln!("Unknown mod {}", id); 5531 std::process::exit(2); 5532 } 5533 } 5534 _ => {} 5535 } 5536 if let Err(err) = save_mod_manifest(&manifest_path, &manifest) { 5537 eprintln!("Failed to save mods manifest: {}", err); 5538 std::process::exit(2); 5539 } 5540 println!("OK: mod {} updated", id); 5541 } 5542 "publish" => { 5543 let mut pack_path: Option<String> = None; 5544 let mut registry_path: Option<PathBuf> = None; 5545 let mut iter = args[1..].iter().peekable(); 5546 while let Some(arg) = iter.next() { 5547 match arg.as_str() { 5548 "--pack" => { 5549 pack_path = iter.next().map(|value| value.to_string()); 5550 } 5551 "--registry" => { 5552 registry_path = iter.next().map(PathBuf::from); 5553 } 5554 _ if arg.starts_with("--pack=") => { 5555 pack_path = Some(arg.trim_start_matches("--pack=").to_string()); 5556 } 5557 _ if arg.starts_with("--registry=") => { 5558 registry_path = Some(PathBuf::from(arg.trim_start_matches("--registry="))); 5559 } 5560 _ => { 5561 eprintln!("Unknown flag {}", arg); 5562 print_mod_usage(); 5563 std::process::exit(2); 5564 } 5565 } 5566 } 5567 let Some(pack_path) = pack_path else { 5568 eprintln!("Missing --pack for mod publish"); 5569 print_mod_usage(); 5570 std::process::exit(2); 5571 }; 5572 let registry_path = registry_path.unwrap_or_else(mod_registry_path); 5573 let pack = load_pack_manifest(&pack_path).unwrap_or_else(|err| { 5574 eprintln!("Failed to load pack: {}", err); 5575 std::process::exit(2); 5576 }); 5577 let data = fs::read(&pack_path).unwrap_or_else(|err| { 5578 eprintln!("Failed to read pack: {}", err); 5579 std::process::exit(2); 5580 }); 5581 let entry = ModRegistryEntry { 5582 id: pack.id.clone(), 5583 name: pack.name.clone(), 5584 version: pack.version, 5585 path: pack_path.clone(), 5586 sha256: sha256_hex(&data), 5587 signature: pack.signature.clone(), 5588 }; 5589 let mut registry = load_mod_registry(®istry_path).unwrap_or_else(|err| { 5590 eprintln!("Failed to load registry: {}", err); 5591 std::process::exit(2); 5592 }); 5593 registry.version = MOD_REGISTRY_VERSION; 5594 if let Some(existing) = registry.mods.iter_mut().find(|m| m.id == entry.id) { 5595 *existing = entry; 5596 } else { 5597 registry.mods.push(entry); 5598 } 5599 if let Err(err) = save_mod_registry(®istry_path, ®istry) { 5600 eprintln!("Failed to save registry: {}", err); 5601 std::process::exit(2); 5602 } 5603 println!("OK: mod published to {}", registry_path.display()); 5604 } 5605 _ => { 5606 eprintln!("Unknown mod subcommand: {}", subcommand); 5607 print_mod_usage(); 5608 std::process::exit(2); 5609 } 5610 } 5611 } 5612 5613 const BALANCE_REPORT_VERSION: u32 = 1; 5614 5615 #[derive(Clone, Debug, Default, Deserialize)] 5616 #[serde(default)] 5617 struct EncounterOverrides { 5618 width: Option<i32>, 5619 height: Option<i32>, 5620 wall_density: Option<u8>, 5621 hazard_count: Option<u32>, 5622 team_a: Option<u32>, 5623 team_b: Option<u32>, 5624 max_hp: Option<i32>, 5625 armor: Option<i32>, 5626 } 5627 5628 impl EncounterOverrides { 5629 fn apply(&self, mut base: EncounterSpec) -> EncounterSpec { 5630 if let Some(width) = self.width { 5631 base.width = width; 5632 } 5633 if let Some(height) = self.height { 5634 base.height = height; 5635 } 5636 if let Some(wall_density) = self.wall_density { 5637 base.wall_density = wall_density; 5638 } 5639 if let Some(hazard_count) = self.hazard_count { 5640 base.hazard_count = hazard_count; 5641 } 5642 if let Some(team_a) = self.team_a { 5643 base.team_a = team_a; 5644 } 5645 if let Some(team_b) = self.team_b { 5646 base.team_b = team_b; 5647 } 5648 if let Some(max_hp) = self.max_hp { 5649 base.max_hp = max_hp; 5650 } 5651 if let Some(armor) = self.armor { 5652 base.armor = armor; 5653 } 5654 base.clamped() 5655 } 5656 } 5657 5658 #[derive(Clone, Debug, Default, Deserialize)] 5659 #[serde(default)] 5660 struct AiOverrides { 5661 aggression: Option<i32>, 5662 risk_tolerance: Option<i32>, 5663 focus_fire: Option<i32>, 5664 vision_range: Option<u32>, 5665 } 5666 5667 impl AiOverrides { 5668 fn apply(&self, mut base: AiConfig) -> AiConfig { 5669 if let Some(value) = self.aggression { 5670 base.aggression = value; 5671 } 5672 if let Some(value) = self.risk_tolerance { 5673 base.risk_tolerance = value; 5674 } 5675 if let Some(value) = self.focus_fire { 5676 base.focus_fire = value; 5677 } 5678 if let Some(value) = self.vision_range { 5679 base.vision_range = value; 5680 } 5681 base.aggression = base.aggression.clamp(0, 100); 5682 base.risk_tolerance = base.risk_tolerance.clamp(0, 100); 5683 base.focus_fire = base.focus_fire.clamp(0, 100); 5684 base.vision_range = base.vision_range.max(1); 5685 base 5686 } 5687 } 5688 5689 #[derive(Clone, Debug, Deserialize)] 5690 #[serde(default)] 5691 struct BalanceConfig { 5692 seed_start: u64, 5693 seed_count: u32, 5694 max_turns: u32, 5695 max_ticks_per_turn: u32, 5696 pack: Option<String>, 5697 abilities: Option<String>, 5698 ai: AiOverrides, 5699 encounter: EncounterOverrides, 5700 scenarios: Vec<BalanceScenario>, 5701 matchups: Vec<BalanceMatchup>, 5702 gates: Option<BalanceGates>, 5703 } 5704 5705 impl Default for BalanceConfig { 5706 fn default() -> Self { 5707 Self { 5708 seed_start: 1, 5709 seed_count: 20, 5710 max_turns: 20, 5711 max_ticks_per_turn: 2000, 5712 pack: None, 5713 abilities: None, 5714 ai: AiOverrides::default(), 5715 encounter: EncounterOverrides::default(), 5716 scenarios: Vec::new(), 5717 matchups: Vec::new(), 5718 gates: None, 5719 } 5720 } 5721 } 5722 5723 #[derive(Clone, Debug, Default, Deserialize)] 5724 #[serde(default)] 5725 struct BalanceScenario { 5726 name: Option<String>, 5727 seed_start: Option<u64>, 5728 seed_count: Option<u32>, 5729 encounter: EncounterOverrides, 5730 ai: AiOverrides, 5731 script: Option<String>, 5732 } 5733 5734 #[derive(Clone, Debug, Default, Deserialize)] 5735 #[serde(default)] 5736 struct BalanceMatchup { 5737 name: Option<String>, 5738 encounter: EncounterOverrides, 5739 ai: AiOverrides, 5740 } 5741 5742 #[derive(Clone, Debug, Deserialize)] 5743 #[serde(default)] 5744 struct BalanceGates { 5745 baseline: String, 5746 max_win_rate_delta: f64, 5747 } 5748 5749 impl Default for BalanceGates { 5750 fn default() -> Self { 5751 Self { 5752 baseline: String::new(), 5753 max_win_rate_delta: 0.05, 5754 } 5755 } 5756 } 5757 5758 #[derive(Clone, Debug, Default, Deserialize)] 5759 #[serde(default)] 5760 struct ScenarioScript { 5761 ops: Vec<ScenarioOp>, 5762 } 5763 5764 #[derive(Clone, Debug, Deserialize)] 5765 #[serde(tag = "op", rename_all = "snake_case")] 5766 enum ScenarioOp { 5767 SetTile { 5768 x: i32, 5769 y: i32, 5770 kind: TileKind, 5771 }, 5772 SetHazard { 5773 x: i32, 5774 y: i32, 5775 kind: HazardKind, 5776 duration: u32, 5777 }, 5778 ClearHazard { 5779 x: i32, 5780 y: i32, 5781 }, 5782 Move { 5783 entity_id: u32, 5784 x: i32, 5785 y: i32, 5786 }, 5787 SetHp { 5788 entity_id: u32, 5789 hp: i32, 5790 }, 5791 SetArmor { 5792 entity_id: u32, 5793 armor: i32, 5794 }, 5795 SetTeam { 5796 entity_id: u32, 5797 team: u8, 5798 }, 5799 Remove { 5800 entity_id: u32, 5801 }, 5802 Spawn { 5803 x: i32, 5804 y: i32, 5805 team: u8, 5806 max_hp: i32, 5807 armor: i32, 5808 }, 5809 } 5810 5811 #[derive(Serialize, Deserialize)] 5812 struct BalanceReport { 5813 version: u32, 5814 engine: SemVer, 5815 seed_start: u64, 5816 seed_count: u32, 5817 max_turns: u32, 5818 max_ticks_per_turn: u32, 5819 scenarios: Vec<ScenarioReport>, 5820 } 5821 5822 #[derive(Serialize, Deserialize)] 5823 struct ScenarioReport { 5824 name: String, 5825 matchups: Vec<MatchupReport>, 5826 } 5827 5828 #[derive(Serialize, Deserialize)] 5829 struct MatchupReport { 5830 name: String, 5831 encounter: EncounterSpec, 5832 seeds: SeedRangeReport, 5833 outcomes: OutcomeReport, 5834 damage: DamageReport, 5835 status_uptime: StatusUptimeReport, 5836 ap_usage: ApUsageReport, 5837 } 5838 5839 #[derive(Serialize, Deserialize)] 5840 struct SeedRangeReport { 5841 seed_start: u64, 5842 seed_count: u32, 5843 } 5844 5845 #[derive(Serialize, Deserialize)] 5846 struct OutcomeReport { 5847 total_runs: u32, 5848 wins: BTreeMap<String, u32>, 5849 win_rate: BTreeMap<String, f64>, 5850 avg_turns: f64, 5851 avg_ticks: f64, 5852 } 5853 5854 #[derive(Serialize, Deserialize)] 5855 struct DamageReport { 5856 overall: DamageStats, 5857 by_team: BTreeMap<String, DamageStats>, 5858 } 5859 5860 #[derive(Serialize, Deserialize)] 5861 struct DamageStats { 5862 total: i64, 5863 count: u64, 5864 min: i32, 5865 max: i32, 5866 mean: f64, 5867 histogram: BTreeMap<i32, u64>, 5868 } 5869 5870 #[derive(Serialize, Deserialize)] 5871 struct StatusUptimeReport { 5872 total: BTreeMap<String, u64>, 5873 per_team: BTreeMap<String, BTreeMap<String, u64>>, 5874 } 5875 5876 #[derive(Serialize, Deserialize)] 5877 struct ApUsageReport { 5878 total_intents: BTreeMap<String, u64>, 5879 avg_intents_per_turn: BTreeMap<String, f64>, 5880 note: String, 5881 } 5882 5883 #[derive(Default)] 5884 struct DamageAccumulator { 5885 total: i64, 5886 count: u64, 5887 min: Option<i32>, 5888 max: Option<i32>, 5889 histogram: BTreeMap<i32, u64>, 5890 } 5891 5892 impl DamageAccumulator { 5893 fn record(&mut self, amount: i32) { 5894 self.total += amount as i64; 5895 self.count += 1; 5896 self.min = Some(self.min.map_or(amount, |min| min.min(amount))); 5897 self.max = Some(self.max.map_or(amount, |max| max.max(amount))); 5898 *self.histogram.entry(amount).or_insert(0) += 1; 5899 } 5900 5901 fn finalize(&self) -> DamageStats { 5902 let mean = if self.count > 0 { 5903 round_f64(self.total as f64 / self.count as f64) 5904 } else { 5905 0.0 5906 }; 5907 DamageStats { 5908 total: self.total, 5909 count: self.count, 5910 min: self.min.unwrap_or(0), 5911 max: self.max.unwrap_or(0), 5912 mean, 5913 histogram: self.histogram.clone(), 5914 } 5915 } 5916 } 5917 5918 #[derive(Default)] 5919 struct StatusAccumulator { 5920 per_team: BTreeMap<u8, BTreeMap<u8, u64>>, 5921 } 5922 5923 impl StatusAccumulator { 5924 fn record_world(&mut self, sim: &Engine) { 5925 for entity in sim.world().entities() { 5926 if !entity.is_alive() { 5927 continue; 5928 } 5929 let team = entity.team(); 5930 for status in entity.statuses() { 5931 let kind = status.kind().as_u8(); 5932 let entry = self 5933 .per_team 5934 .entry(team) 5935 .or_default() 5936 .entry(kind) 5937 .or_insert(0); 5938 *entry += 1; 5939 } 5940 } 5941 } 5942 } 5943 5944 #[derive(Default)] 5945 struct MatchupAccumulator { 5946 total_runs: u32, 5947 wins: BTreeMap<u8, u32>, 5948 draws: u32, 5949 total_turns: u64, 5950 total_ticks: u64, 5951 intents_by_team: BTreeMap<u8, u64>, 5952 damage_overall: DamageAccumulator, 5953 damage_by_source: BTreeMap<Option<u8>, DamageAccumulator>, 5954 status_uptime: StatusAccumulator, 5955 } 5956 5957 fn round_f64(value: f64) -> f64 { 5958 let scale = 1_000_000.0; 5959 (value * scale).round() / scale 5960 } 5961 5962 fn team_key(team: u8) -> String { 5963 format!("team_{}", team) 5964 } 5965 5966 fn status_key(kind: u8) -> &'static str { 5967 match kind { 5968 1 => "poison", 5969 2 => "burn", 5970 3 => "stun", 5971 4 => "shield", 5972 _ => "unknown", 5973 } 5974 } 5975 5976 fn load_balance_config(path: &str) -> Result<BalanceConfig, String> { 5977 let value = load_json_value(Path::new(path))?; 5978 serde_json::from_value(value).map_err(|err| err.to_string()) 5979 } 5980 5981 fn normalize_balance_config(mut config: BalanceConfig) -> Result<BalanceConfig, String> { 5982 if config.seed_count == 0 { 5983 return Err("seed_count must be > 0".to_string()); 5984 } 5985 if config.max_turns == 0 { 5986 return Err("max_turns must be > 0".to_string()); 5987 } 5988 if config.max_ticks_per_turn == 0 { 5989 return Err("max_ticks_per_turn must be > 0".to_string()); 5990 } 5991 if config.scenarios.is_empty() { 5992 config.scenarios.push(BalanceScenario { 5993 name: Some("default".to_string()), 5994 ..BalanceScenario::default() 5995 }); 5996 } 5997 if config.matchups.is_empty() { 5998 config.matchups.push(BalanceMatchup { 5999 name: Some("default".to_string()), 6000 ..BalanceMatchup::default() 6001 }); 6002 } 6003 Ok(config) 6004 } 6005 6006 fn load_scenario_script(path: &Path) -> Result<ScenarioScript, String> { 6007 let mut value = load_json_value(path)?; 6008 if value.is_array() { 6009 let mut map = serde_json::Map::new(); 6010 map.insert("ops".to_string(), value); 6011 value = Value::Object(map); 6012 } 6013 serde_json::from_value(value).map_err(|err| err.to_string()) 6014 } 6015 6016 fn apply_scenario_script(sim: &mut Engine, script: &ScenarioScript) -> Result<(), String> { 6017 for (index, op) in script.ops.iter().enumerate() { 6018 let ok = match op { 6019 ScenarioOp::SetTile { x, y, kind } => sim.world_mut().set_tile_kind(*x, *y, *kind), 6020 ScenarioOp::SetHazard { 6021 x, 6022 y, 6023 kind, 6024 duration, 6025 } => sim.world_mut().set_hazard_kind(*x, *y, *kind, *duration), 6026 ScenarioOp::ClearHazard { x, y } => sim.world_mut().clear_hazard(*x, *y), 6027 ScenarioOp::Move { entity_id, x, y } => { 6028 sim.world_mut().move_entity(*entity_id, Pos::new(*x, *y)) 6029 } 6030 ScenarioOp::SetHp { entity_id, hp } => sim.world_mut().set_entity_hp(*entity_id, *hp), 6031 ScenarioOp::SetArmor { entity_id, armor } => { 6032 sim.world_mut().set_entity_armor(*entity_id, *armor) 6033 } 6034 ScenarioOp::SetTeam { entity_id, team } => { 6035 sim.world_mut().set_entity_team(*entity_id, *team) 6036 } 6037 ScenarioOp::Remove { entity_id } => sim.world_mut().remove_entity(*entity_id), 6038 ScenarioOp::Spawn { 6039 x, 6040 y, 6041 team, 6042 max_hp, 6043 armor, 6044 } => { 6045 sim.world_mut() 6046 .spawn_entity(Pos::new(*x, *y), *team, *max_hp, *armor) 6047 != 0 6048 } 6049 }; 6050 if !ok { 6051 return Err(format!("scenario op {} failed", index)); 6052 } 6053 } 6054 Ok(()) 6055 } 6056 6057 fn winner_for_sim(sim: &Engine) -> Option<u8> { 6058 let mut alive_by_team: BTreeMap<u8, u32> = BTreeMap::new(); 6059 for entity in sim.world().entities() { 6060 if !entity.is_alive() { 6061 continue; 6062 } 6063 *alive_by_team.entry(entity.team()).or_insert(0) += 1; 6064 } 6065 let mut alive_teams = alive_by_team.into_iter().filter(|(_, count)| *count > 0); 6066 let first = alive_teams.next()?; 6067 if alive_teams.next().is_some() { 6068 return None; 6069 } 6070 Some(first.0) 6071 } 6072 6073 fn record_intents( 6074 sim: &Engine, 6075 intents: &[dynostic_core::Intent], 6076 intents_by_team: &mut BTreeMap<u8, u64>, 6077 ) { 6078 let mut teams = HashMap::new(); 6079 for entity in sim.world().entities() { 6080 teams.insert(entity.id(), entity.team()); 6081 } 6082 for intent in intents { 6083 if let Some(team) = teams.get(&intent.entity_id()) { 6084 *intents_by_team.entry(*team).or_insert(0) += 1; 6085 } 6086 } 6087 } 6088 6089 fn record_damage_events( 6090 sim: &Engine, 6091 acc: &mut DamageAccumulator, 6092 by_source: &mut BTreeMap<Option<u8>, DamageAccumulator>, 6093 ) { 6094 for event in sim.events() { 6095 if event.kind != dynostic_core::ev::DAMAGE { 6096 continue; 6097 } 6098 let amount = event.value; 6099 acc.record(amount); 6100 let source_team = if event.b == 0 { 6101 None 6102 } else { 6103 sim.world() 6104 .entity_by_id(event.b) 6105 .map(|entity| entity.team()) 6106 }; 6107 by_source.entry(source_team).or_default().record(amount); 6108 } 6109 } 6110 6111 fn finalize_outcomes( 6112 acc: &MatchupAccumulator, 6113 teams: &[u8], 6114 ) -> ( 6115 OutcomeReport, 6116 ApUsageReport, 6117 StatusUptimeReport, 6118 DamageReport, 6119 ) { 6120 let mut wins = BTreeMap::new(); 6121 let mut win_rate = BTreeMap::new(); 6122 let total_runs = acc.total_runs; 6123 let draws = acc.draws; 6124 for team in teams { 6125 let count = acc.wins.get(team).copied().unwrap_or(0); 6126 wins.insert(team_key(*team), count); 6127 } 6128 wins.insert("draw".to_string(), draws); 6129 for (key, count) in &wins { 6130 let rate = if total_runs > 0 { 6131 round_f64(*count as f64 / total_runs as f64) 6132 } else { 6133 0.0 6134 }; 6135 win_rate.insert(key.clone(), rate); 6136 } 6137 6138 let avg_turns = if total_runs > 0 { 6139 round_f64(acc.total_turns as f64 / total_runs as f64) 6140 } else { 6141 0.0 6142 }; 6143 let avg_ticks = if total_runs > 0 { 6144 round_f64(acc.total_ticks as f64 / total_runs as f64) 6145 } else { 6146 0.0 6147 }; 6148 let outcome = OutcomeReport { 6149 total_runs, 6150 wins, 6151 win_rate, 6152 avg_turns, 6153 avg_ticks, 6154 }; 6155 6156 let mut ap_total = BTreeMap::new(); 6157 let mut ap_avg = BTreeMap::new(); 6158 for team in teams { 6159 let total = acc.intents_by_team.get(team).copied().unwrap_or(0); 6160 ap_total.insert(team_key(*team), total); 6161 let avg = if acc.total_turns > 0 { 6162 round_f64(total as f64 / acc.total_turns as f64) 6163 } else { 6164 0.0 6165 }; 6166 ap_avg.insert(team_key(*team), avg); 6167 } 6168 let ap_usage = ApUsageReport { 6169 total_intents: ap_total, 6170 avg_intents_per_turn: ap_avg, 6171 note: "intent counts per turn (AP system not implemented)".to_string(), 6172 }; 6173 6174 let mut status_total: BTreeMap<String, u64> = BTreeMap::new(); 6175 let mut status_per_team: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new(); 6176 for (team, statuses) in &acc.status_uptime.per_team { 6177 let mut team_map = BTreeMap::new(); 6178 for (kind, count) in statuses { 6179 let key = status_key(*kind).to_string(); 6180 team_map.insert(key.clone(), *count); 6181 *status_total.entry(key).or_insert(0) += *count; 6182 } 6183 status_per_team.insert(team_key(*team), team_map); 6184 } 6185 let status_uptime = StatusUptimeReport { 6186 total: status_total, 6187 per_team: status_per_team, 6188 }; 6189 6190 let mut damage_by_team = BTreeMap::new(); 6191 for (source, dmg) in &acc.damage_by_source { 6192 let key = match source { 6193 Some(team) => team_key(*team), 6194 None => "env".to_string(), 6195 }; 6196 damage_by_team.insert(key, dmg.finalize()); 6197 } 6198 let damage = DamageReport { 6199 overall: acc.damage_overall.finalize(), 6200 by_team: damage_by_team, 6201 }; 6202 6203 (outcome, ap_usage, status_uptime, damage) 6204 } 6205 6206 #[allow(clippy::too_many_arguments)] 6207 fn run_matchup( 6208 encounter: EncounterSpec, 6209 ai_config: AiConfig, 6210 abilities: Option<&AbilitySet>, 6211 script: Option<&ScenarioScript>, 6212 seed_start: u64, 6213 seed_count: u32, 6214 max_turns: u32, 6215 max_ticks_per_turn: u32, 6216 ) -> Result<MatchupAccumulator, String> { 6217 let mut acc = MatchupAccumulator::default(); 6218 let teams = [0_u8, 1_u8]; 6219 for idx in 0..seed_count { 6220 let seed = seed_start.saturating_add(idx as u64); 6221 let mut sim = Engine::new(seed); 6222 if let Some(abilities) = abilities { 6223 sim.set_abilities(abilities.clone()) 6224 .map_err(|errors| errors.join("; "))?; 6225 } 6226 sim.set_ai_config(ai_config); 6227 sim.generate_encounter(seed, encounter); 6228 if let Some(script) = script { 6229 apply_scenario_script(&mut sim, script)?; 6230 } 6231 6232 let mut turns = 0_u32; 6233 let mut ticks = 0_u32; 6234 let mut resolved = false; 6235 let mut draw = false; 6236 6237 loop { 6238 if turns >= max_turns { 6239 draw = true; 6240 break; 6241 } 6242 if let Some(winner) = winner_for_sim(&sim) { 6243 *acc.wins.entry(winner).or_insert(0) += 1; 6244 resolved = true; 6245 break; 6246 } 6247 sim.clear_plans(); 6248 for team in teams { 6249 sim.auto_plan_ai(team); 6250 } 6251 let intents = sim.world().planned_intents().to_vec(); 6252 if intents.is_empty() { 6253 draw = true; 6254 break; 6255 } 6256 record_intents(&sim, &intents, &mut acc.intents_by_team); 6257 if sim.commit() == 0 { 6258 draw = true; 6259 break; 6260 } 6261 turns += 1; 6262 let mut turn_ticks = 0_u32; 6263 while sim.phase() != Phase::Plan { 6264 if turn_ticks >= max_ticks_per_turn { 6265 draw = true; 6266 break; 6267 } 6268 sim.step(1); 6269 ticks += 1; 6270 turn_ticks += 1; 6271 record_damage_events(&sim, &mut acc.damage_overall, &mut acc.damage_by_source); 6272 acc.status_uptime.record_world(&sim); 6273 } 6274 if draw { 6275 break; 6276 } 6277 } 6278 6279 acc.total_runs += 1; 6280 acc.total_turns += turns as u64; 6281 acc.total_ticks += ticks as u64; 6282 if draw && !resolved { 6283 acc.draws += 1; 6284 } 6285 } 6286 Ok(acc) 6287 } 6288 6289 fn run_balance_report(config: BalanceConfig, config_path: &str) -> Result<BalanceReport, String> { 6290 let config = normalize_balance_config(config)?; 6291 let base_ai = config.ai.apply(AiConfig::default()); 6292 let base_encounter = config.encounter.apply(EncounterSpec::default()); 6293 6294 let abilities = if let Some(pack_path) = config.pack.as_deref() { 6295 let resolved = resolve_relative(config_path, pack_path); 6296 let resolved = resolved.to_string_lossy().to_string(); 6297 let ctx = build_pack_context(&resolved)?; 6298 for warning in &ctx.warnings { 6299 eprintln!("warning: {}", warning); 6300 } 6301 Some(ctx.abilities) 6302 } else if let Some(abilities_path) = config.abilities.as_deref() { 6303 let resolved = resolve_relative(config_path, abilities_path); 6304 let resolved = resolved.to_string_lossy().to_string(); 6305 Some(load_abilities(&resolved)?) 6306 } else { 6307 None 6308 }; 6309 6310 let mut scenarios_out = Vec::new(); 6311 for (scenario_index, scenario) in config.scenarios.iter().enumerate() { 6312 let name = scenario 6313 .name 6314 .clone() 6315 .filter(|value| !value.trim().is_empty()) 6316 .unwrap_or_else(|| format!("scenario-{}", scenario_index + 1)); 6317 let seed_start = scenario.seed_start.unwrap_or(config.seed_start); 6318 let seed_count = scenario.seed_count.unwrap_or(config.seed_count); 6319 if seed_count == 0 { 6320 return Err(format!("scenario {} seed_count must be > 0", name)); 6321 } 6322 6323 let scenario_ai = scenario.ai.apply(base_ai); 6324 let scenario_encounter = scenario.encounter.apply(base_encounter); 6325 let script = match scenario.script.as_deref() { 6326 Some(path) => { 6327 let resolved = resolve_relative(config_path, path); 6328 Some(load_scenario_script(&resolved)?) 6329 } 6330 None => None, 6331 }; 6332 6333 let mut matchups_out = Vec::new(); 6334 for (matchup_index, matchup) in config.matchups.iter().enumerate() { 6335 let matchup_name = matchup 6336 .name 6337 .clone() 6338 .filter(|value| !value.trim().is_empty()) 6339 .unwrap_or_else(|| format!("matchup-{}", matchup_index + 1)); 6340 let matchup_encounter = matchup.encounter.apply(scenario_encounter); 6341 let matchup_ai = matchup.ai.apply(scenario_ai); 6342 6343 let acc = run_matchup( 6344 matchup_encounter, 6345 matchup_ai, 6346 abilities.as_ref(), 6347 script.as_ref(), 6348 seed_start, 6349 seed_count, 6350 config.max_turns, 6351 config.max_ticks_per_turn, 6352 )?; 6353 let teams = [0_u8, 1_u8]; 6354 let (outcomes, ap_usage, status_uptime, damage) = finalize_outcomes(&acc, &teams); 6355 matchups_out.push(MatchupReport { 6356 name: matchup_name, 6357 encounter: matchup_encounter, 6358 seeds: SeedRangeReport { 6359 seed_start, 6360 seed_count, 6361 }, 6362 outcomes, 6363 damage, 6364 status_uptime, 6365 ap_usage, 6366 }); 6367 } 6368 6369 scenarios_out.push(ScenarioReport { 6370 name, 6371 matchups: matchups_out, 6372 }); 6373 } 6374 6375 Ok(BalanceReport { 6376 version: BALANCE_REPORT_VERSION, 6377 engine: SemVer::current(), 6378 seed_start: config.seed_start, 6379 seed_count: config.seed_count, 6380 max_turns: config.max_turns, 6381 max_ticks_per_turn: config.max_ticks_per_turn, 6382 scenarios: scenarios_out, 6383 }) 6384 } 6385 6386 fn load_balance_report(path: &str) -> Result<BalanceReport, String> { 6387 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 6388 serde_json::from_str(&data).map_err(|err| err.to_string()) 6389 } 6390 6391 fn check_balance_gates( 6392 report: &BalanceReport, 6393 baseline: &BalanceReport, 6394 max_win_rate_delta: f64, 6395 ) -> Vec<String> { 6396 let mut issues = Vec::new(); 6397 if max_win_rate_delta <= 0.0 { 6398 issues.push("max_win_rate_delta must be > 0".to_string()); 6399 return issues; 6400 } 6401 let mut baseline_map: BTreeMap<String, &MatchupReport> = BTreeMap::new(); 6402 for scenario in &baseline.scenarios { 6403 for matchup in &scenario.matchups { 6404 let key = format!("{}::{}", scenario.name, matchup.name); 6405 baseline_map.insert(key, matchup); 6406 } 6407 } 6408 6409 for scenario in &report.scenarios { 6410 for matchup in &scenario.matchups { 6411 let key = format!("{}::{}", scenario.name, matchup.name); 6412 let Some(base_matchup) = baseline_map.get(&key) else { 6413 issues.push(format!("missing baseline for {}", key)); 6414 continue; 6415 }; 6416 for (team_key, rate) in &matchup.outcomes.win_rate { 6417 if !team_key.starts_with("team_") { 6418 continue; 6419 } 6420 let Some(base_rate) = base_matchup.outcomes.win_rate.get(team_key) else { 6421 issues.push(format!( 6422 "missing baseline win rate for {} {}", 6423 key, team_key 6424 )); 6425 continue; 6426 }; 6427 let delta = (rate - base_rate).abs(); 6428 if delta > max_win_rate_delta { 6429 issues.push(format!( 6430 "{} {} win rate delta {:.4} exceeds {:.4}", 6431 key, team_key, delta, max_win_rate_delta 6432 )); 6433 } 6434 } 6435 } 6436 } 6437 6438 issues 6439 } 6440 6441 fn print_balance_usage() { 6442 eprintln!( 6443 "Usage: dynostic_cli balance --config PATH [--out PATH] [--baseline PATH] [--max-win-rate-delta N]" 6444 ); 6445 } 6446 6447 #[derive(Default)] 6448 struct BalanceFlags { 6449 config: Option<String>, 6450 out: Option<String>, 6451 baseline: Option<String>, 6452 max_win_rate_delta: Option<f64>, 6453 } 6454 6455 fn parse_balance_flags(args: &[String]) -> Result<BalanceFlags, String> { 6456 let mut flags = BalanceFlags::default(); 6457 let mut positional: Vec<String> = Vec::new(); 6458 let mut iter = args.iter().peekable(); 6459 while let Some(arg) = iter.next() { 6460 match arg.as_str() { 6461 "--config" => { 6462 let value = iter 6463 .next() 6464 .ok_or_else(|| "Missing value for --config".to_string())?; 6465 flags.config = Some(value.clone()); 6466 } 6467 "--out" => { 6468 let value = iter 6469 .next() 6470 .ok_or_else(|| "Missing value for --out".to_string())?; 6471 flags.out = Some(value.clone()); 6472 } 6473 "--baseline" => { 6474 let value = iter 6475 .next() 6476 .ok_or_else(|| "Missing value for --baseline".to_string())?; 6477 flags.baseline = Some(value.clone()); 6478 } 6479 "--max-win-rate-delta" => { 6480 let value = iter 6481 .next() 6482 .ok_or_else(|| "Missing value for --max-win-rate-delta".to_string())?; 6483 flags.max_win_rate_delta = Some(parse_f64(value, "max-win-rate-delta")); 6484 } 6485 _ if arg.starts_with("--config=") => { 6486 flags.config = Some(arg.trim_start_matches("--config=").to_string()); 6487 } 6488 _ if arg.starts_with("--out=") => { 6489 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 6490 } 6491 _ if arg.starts_with("--baseline=") => { 6492 flags.baseline = Some(arg.trim_start_matches("--baseline=").to_string()); 6493 } 6494 _ if arg.starts_with("--max-win-rate-delta=") => { 6495 let value = arg.trim_start_matches("--max-win-rate-delta="); 6496 flags.max_win_rate_delta = Some(parse_f64(value, "max-win-rate-delta")); 6497 } 6498 _ if arg.starts_with("--") => { 6499 return Err(format!("Unknown flag {}", arg)); 6500 } 6501 _ => { 6502 positional.push(arg.to_string()); 6503 } 6504 } 6505 } 6506 if flags.config.is_none() && !positional.is_empty() { 6507 flags.config = Some(positional.remove(0)); 6508 } 6509 Ok(flags) 6510 } 6511 6512 fn handle_balance_command(args: Vec<String>) { 6513 let flags = match parse_balance_flags(&args) { 6514 Ok(flags) => flags, 6515 Err(err) => { 6516 eprintln!("{}", err); 6517 print_balance_usage(); 6518 std::process::exit(2); 6519 } 6520 }; 6521 let config_path = match flags.config { 6522 Some(path) => path, 6523 None => { 6524 eprintln!("Missing --config"); 6525 print_balance_usage(); 6526 std::process::exit(2); 6527 } 6528 }; 6529 let config = match load_balance_config(&config_path) { 6530 Ok(config) => config, 6531 Err(err) => { 6532 eprintln!("Failed to load balance config: {}", err); 6533 std::process::exit(2); 6534 } 6535 }; 6536 let gates = config.gates.clone(); 6537 let report = match run_balance_report(config, &config_path) { 6538 Ok(report) => report, 6539 Err(err) => { 6540 eprintln!("Balance run failed: {}", err); 6541 std::process::exit(2); 6542 } 6543 }; 6544 let encoded = serde_json::to_string_pretty(&report).unwrap_or_else(|err| { 6545 eprintln!("Failed to serialize balance report: {}", err); 6546 std::process::exit(2); 6547 }); 6548 if let Some(out_path) = flags.out { 6549 if let Err(err) = fs::write(&out_path, encoded) { 6550 eprintln!("Failed to write {}: {}", out_path, err); 6551 std::process::exit(2); 6552 } 6553 } else { 6554 println!("{}", encoded); 6555 } 6556 6557 let baseline_path = flags 6558 .baseline 6559 .or_else(|| gates.as_ref().map(|g| g.baseline.clone())) 6560 .filter(|value| !value.trim().is_empty()); 6561 let max_delta = flags 6562 .max_win_rate_delta 6563 .or_else(|| gates.as_ref().map(|g| g.max_win_rate_delta)); 6564 if let (Some(baseline_path), Some(max_delta)) = (baseline_path, max_delta) { 6565 let baseline = match load_balance_report(&baseline_path) { 6566 Ok(report) => report, 6567 Err(err) => { 6568 eprintln!("Failed to load baseline report: {}", err); 6569 std::process::exit(2); 6570 } 6571 }; 6572 let issues = check_balance_gates(&report, &baseline, max_delta); 6573 if !issues.is_empty() { 6574 for issue in issues { 6575 eprintln!("gate: {}", issue); 6576 } 6577 std::process::exit(2); 6578 } 6579 } 6580 } 6581 6582 const COMPAT_MANIFEST_VERSION: u32 = 1; 6583 const GOLDEN_MANIFEST_VERSION: u32 = 1; 6584 const GOLDEN_REPLAY_BUNDLE_VERSION: u32 = 1; 6585 const GOLDEN_CINE_BUNDLE_VERSION: u32 = 1; 6586 const SNAPSHOT_VERSION: u32 = 1; 6587 const REPRO_BUNDLE_VERSION: u32 = 1; 6588 const REPRO_MAX_TICKS_PER_TURN: u32 = 2000; 6589 const DEFAULT_REPRO_EVENTS: usize = 64; 6590 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; 6591 const FNV_PRIME: u64 = 0x100000001b3; 6592 6593 fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 { 6594 for byte in bytes { 6595 hash ^= *byte as u64; 6596 hash = hash.wrapping_mul(FNV_PRIME); 6597 } 6598 hash 6599 } 6600 6601 fn hash_world(world: &World) -> u64 { 6602 let Ok(data) = serde_json::to_vec(world) else { 6603 return 0; 6604 }; 6605 fnv1a_update(FNV_OFFSET_BASIS, &data) 6606 } 6607 6608 fn format_hash(hash: u64) -> String { 6609 format!("0x{:016x}", hash) 6610 } 6611 6612 fn parse_hash(value: &str) -> Result<u64, String> { 6613 let trimmed = value.trim(); 6614 if let Some(rest) = trimmed 6615 .strip_prefix("0x") 6616 .or_else(|| trimmed.strip_prefix("0X")) 6617 { 6618 return u64::from_str_radix(rest, 16).map_err(|_| format!("invalid hash {}", value)); 6619 } 6620 trimmed 6621 .parse::<u64>() 6622 .map_err(|_| format!("invalid hash {}", value)) 6623 } 6624 6625 fn normalize_numeric_value(value: &mut Value) { 6626 match value { 6627 Value::Number(num) => { 6628 if num.is_f64() { 6629 if let Some(f) = num.as_f64() { 6630 if f.is_finite() && (f.fract() == 0.0) { 6631 if f >= 0.0 && f <= u64::MAX as f64 { 6632 *value = Value::Number(serde_json::Number::from(f as u64)); 6633 } else if f >= i64::MIN as f64 && f <= i64::MAX as f64 { 6634 *value = Value::Number(serde_json::Number::from(f as i64)); 6635 } 6636 } 6637 } 6638 } 6639 } 6640 Value::Array(items) => { 6641 for item in items { 6642 normalize_numeric_value(item); 6643 } 6644 } 6645 Value::Object(map) => { 6646 for (_, item) in map.iter_mut() { 6647 normalize_numeric_value(item); 6648 } 6649 } 6650 _ => {} 6651 } 6652 } 6653 6654 #[derive(Clone, Debug, Serialize, Deserialize)] 6655 struct SnapshotEnvelope { 6656 version: u32, 6657 engine: SemVer, 6658 world: World, 6659 } 6660 6661 fn load_snapshot_world(path: &str) -> Result<(World, Option<SemVer>), String> { 6662 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 6663 if let Ok(envelope) = serde_json::from_str::<SnapshotEnvelope>(&data) { 6664 if envelope.version != SNAPSHOT_VERSION { 6665 return Err(format!( 6666 "snapshot version mismatch: expected {}, got {}", 6667 SNAPSHOT_VERSION, envelope.version 6668 )); 6669 } 6670 return Ok((envelope.world, Some(envelope.engine))); 6671 } 6672 let value: Value = serde_json::from_str(&data).map_err(|err| err.to_string())?; 6673 if let Some(world_json) = value.get("world_json").and_then(|val| val.as_str()) { 6674 let mut world_value: Value = 6675 serde_json::from_str(world_json).map_err(|err| err.to_string())?; 6676 normalize_numeric_value(&mut world_value); 6677 let world: World = serde_json::from_value(world_value).map_err(|err| err.to_string())?; 6678 let engine = value 6679 .get("engine") 6680 .and_then(|val| serde_json::from_value::<SemVer>(val.clone()).ok()); 6681 return Ok((world, engine)); 6682 } 6683 if let Some(world_value) = value.get("world") { 6684 let mut world_value = world_value.clone(); 6685 normalize_numeric_value(&mut world_value); 6686 let world: World = serde_json::from_value(world_value).map_err(|err| err.to_string())?; 6687 let engine = value 6688 .get("engine") 6689 .and_then(|val| serde_json::from_value::<SemVer>(val.clone()).ok()); 6690 return Ok((world, engine)); 6691 } 6692 let mut world_value: Value = serde_json::from_str(&data).map_err(|err| err.to_string())?; 6693 normalize_numeric_value(&mut world_value); 6694 let world: World = serde_json::from_value(world_value).map_err(|err| err.to_string())?; 6695 Ok((world, None)) 6696 } 6697 6698 #[derive(Clone, Debug, Serialize, Deserialize, Default)] 6699 #[serde(default)] 6700 struct CompatManifest { 6701 version: u32, 6702 replays: Vec<CompatReplayEntry>, 6703 snapshots: Vec<CompatSnapshotEntry>, 6704 } 6705 6706 #[derive(Clone, Debug, Serialize, Deserialize, Default)] 6707 #[serde(default)] 6708 struct CompatReplayEntry { 6709 path: String, 6710 final_world_hash: Option<String>, 6711 } 6712 6713 #[derive(Clone, Debug, Serialize, Deserialize)] 6714 struct CompatSnapshotEntry { 6715 path: String, 6716 world_hash: String, 6717 } 6718 6719 fn load_compat_manifest(path: &str) -> Result<CompatManifest, String> { 6720 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 6721 let mut manifest: CompatManifest = 6722 serde_json::from_str(&data).map_err(|err| err.to_string())?; 6723 if manifest.version == 0 { 6724 manifest.version = COMPAT_MANIFEST_VERSION; 6725 } 6726 Ok(manifest) 6727 } 6728 6729 fn replay_final_world_hash(replay: &Replay) -> Result<u64, String> { 6730 replay 6731 .turns 6732 .last() 6733 .map(|turn| turn.world_hash) 6734 .ok_or_else(|| "replay has no turns".to_string()) 6735 } 6736 6737 fn verify_replay(path: &str, expected_hash: Option<&str>) -> Result<(), String> { 6738 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 6739 let replay = Replay::from_json(&data).map_err(|err| err.to_string())?; 6740 let report = replay.verify().map_err(|err| err.message)?; 6741 if !report.ok { 6742 return Err(format!("replay mismatch {}", path)); 6743 } 6744 if let Some(expected) = expected_hash { 6745 let expected = parse_hash(expected)?; 6746 let final_hash = replay_final_world_hash(&replay)?; 6747 if final_hash != expected { 6748 return Err(format!( 6749 "replay hash mismatch {}: {} != {}", 6750 path, 6751 format_hash(final_hash), 6752 format_hash(expected) 6753 )); 6754 } 6755 } 6756 Ok(()) 6757 } 6758 6759 fn print_compat_usage() { 6760 eprintln!( 6761 "Usage: dynostic_cli compat build --out PATH [--replay PATH ...] [--snapshot PATH ...]\n\ 6762 dynostic_cli compat verify --manifest PATH" 6763 ); 6764 } 6765 6766 #[derive(Default)] 6767 struct CompatBuildFlags { 6768 out: Option<String>, 6769 replays: Vec<String>, 6770 snapshots: Vec<String>, 6771 } 6772 6773 fn parse_compat_build_flags(args: &[String]) -> Result<CompatBuildFlags, String> { 6774 let mut flags = CompatBuildFlags::default(); 6775 let mut iter = args.iter().peekable(); 6776 while let Some(arg) = iter.next() { 6777 match arg.as_str() { 6778 "--out" => { 6779 let value = iter 6780 .next() 6781 .ok_or_else(|| "Missing value for --out".to_string())?; 6782 flags.out = Some(value.clone()); 6783 } 6784 "--replay" => { 6785 let value = iter 6786 .next() 6787 .ok_or_else(|| "Missing value for --replay".to_string())?; 6788 flags.replays.push(value.clone()); 6789 } 6790 "--snapshot" => { 6791 let value = iter 6792 .next() 6793 .ok_or_else(|| "Missing value for --snapshot".to_string())?; 6794 flags.snapshots.push(value.clone()); 6795 } 6796 _ if arg.starts_with("--out=") => { 6797 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 6798 } 6799 _ if arg.starts_with("--replay=") => { 6800 flags 6801 .replays 6802 .push(arg.trim_start_matches("--replay=").to_string()); 6803 } 6804 _ if arg.starts_with("--snapshot=") => { 6805 flags 6806 .snapshots 6807 .push(arg.trim_start_matches("--snapshot=").to_string()); 6808 } 6809 _ if arg.starts_with("--") => { 6810 return Err(format!("Unknown flag {}", arg)); 6811 } 6812 _ => { 6813 return Err(format!("Unexpected arg {}", arg)); 6814 } 6815 } 6816 } 6817 Ok(flags) 6818 } 6819 6820 #[derive(Default)] 6821 struct CompatVerifyFlags { 6822 manifest: Option<String>, 6823 } 6824 6825 fn parse_compat_verify_flags(args: &[String]) -> Result<CompatVerifyFlags, String> { 6826 let mut flags = CompatVerifyFlags::default(); 6827 let mut iter = args.iter().peekable(); 6828 while let Some(arg) = iter.next() { 6829 match arg.as_str() { 6830 "--manifest" => { 6831 let value = iter 6832 .next() 6833 .ok_or_else(|| "Missing value for --manifest".to_string())?; 6834 flags.manifest = Some(value.clone()); 6835 } 6836 _ if arg.starts_with("--manifest=") => { 6837 flags.manifest = Some(arg.trim_start_matches("--manifest=").to_string()); 6838 } 6839 _ if arg.starts_with("--") => { 6840 return Err(format!("Unknown flag {}", arg)); 6841 } 6842 _ => { 6843 return Err(format!("Unexpected arg {}", arg)); 6844 } 6845 } 6846 } 6847 Ok(flags) 6848 } 6849 6850 fn handle_compat_command(args: Vec<String>) { 6851 if args.is_empty() { 6852 print_compat_usage(); 6853 std::process::exit(2); 6854 } 6855 let subcommand = &args[0]; 6856 match subcommand.as_str() { 6857 "build" => { 6858 let flags = match parse_compat_build_flags(&args[1..]) { 6859 Ok(flags) => flags, 6860 Err(err) => { 6861 eprintln!("{}", err); 6862 print_compat_usage(); 6863 std::process::exit(2); 6864 } 6865 }; 6866 let out_path = match flags.out { 6867 Some(path) => path, 6868 None => { 6869 eprintln!("Missing --out"); 6870 print_compat_usage(); 6871 std::process::exit(2); 6872 } 6873 }; 6874 let mut replays = flags.replays.clone(); 6875 let mut snapshots = flags.snapshots.clone(); 6876 replays.sort(); 6877 snapshots.sort(); 6878 6879 let mut replay_entries = Vec::new(); 6880 for path in replays { 6881 verify_replay(&path, None).unwrap_or_else(|err| { 6882 eprintln!("compat build replay failed: {}", err); 6883 std::process::exit(2); 6884 }); 6885 let data = fs::read_to_string(&path).unwrap_or_else(|err| { 6886 eprintln!("Failed to read replay {}: {}", path, err); 6887 std::process::exit(2); 6888 }); 6889 let replay = Replay::from_json(&data).unwrap_or_else(|err| { 6890 eprintln!("Failed to parse replay {}: {}", path, err); 6891 std::process::exit(2); 6892 }); 6893 let final_hash = replay_final_world_hash(&replay).unwrap_or_else(|err| { 6894 eprintln!("Failed to read replay hash {}: {}", path, err); 6895 std::process::exit(2); 6896 }); 6897 replay_entries.push(CompatReplayEntry { 6898 path, 6899 final_world_hash: Some(format_hash(final_hash)), 6900 }); 6901 } 6902 6903 let mut snapshot_entries = Vec::new(); 6904 for path in snapshots { 6905 let (world, _) = load_snapshot_world(&path).unwrap_or_else(|err| { 6906 eprintln!("Failed to load snapshot {}: {}", path, err); 6907 std::process::exit(2); 6908 }); 6909 let world_hash = hash_world(&world); 6910 snapshot_entries.push(CompatSnapshotEntry { 6911 path, 6912 world_hash: format_hash(world_hash), 6913 }); 6914 } 6915 6916 let manifest = CompatManifest { 6917 version: COMPAT_MANIFEST_VERSION, 6918 replays: replay_entries, 6919 snapshots: snapshot_entries, 6920 }; 6921 let encoded = serde_json::to_string_pretty(&manifest).unwrap_or_else(|err| { 6922 eprintln!("Failed to serialize manifest: {}", err); 6923 std::process::exit(2); 6924 }); 6925 if let Err(err) = fs::write(&out_path, encoded) { 6926 eprintln!("Failed to write {}: {}", out_path, err); 6927 std::process::exit(2); 6928 } 6929 } 6930 "verify" => { 6931 let flags = match parse_compat_verify_flags(&args[1..]) { 6932 Ok(flags) => flags, 6933 Err(err) => { 6934 eprintln!("{}", err); 6935 print_compat_usage(); 6936 std::process::exit(2); 6937 } 6938 }; 6939 let manifest_path = match flags.manifest { 6940 Some(path) => path, 6941 None => { 6942 eprintln!("Missing --manifest"); 6943 print_compat_usage(); 6944 std::process::exit(2); 6945 } 6946 }; 6947 let manifest = load_compat_manifest(&manifest_path).unwrap_or_else(|err| { 6948 eprintln!("Failed to load manifest {}: {}", manifest_path, err); 6949 std::process::exit(2); 6950 }); 6951 if manifest.version != COMPAT_MANIFEST_VERSION { 6952 eprintln!( 6953 "compat manifest version mismatch: expected {}, got {}", 6954 COMPAT_MANIFEST_VERSION, manifest.version 6955 ); 6956 std::process::exit(2); 6957 } 6958 let mut failed = false; 6959 for entry in manifest.replays { 6960 if let Err(err) = verify_replay(&entry.path, entry.final_world_hash.as_deref()) { 6961 eprintln!("compat replay failed: {}", err); 6962 failed = true; 6963 } 6964 } 6965 for entry in manifest.snapshots { 6966 let (world, engine) = match load_snapshot_world(&entry.path) { 6967 Ok(value) => value, 6968 Err(err) => { 6969 eprintln!("compat snapshot failed: {}", err); 6970 failed = true; 6971 continue; 6972 } 6973 }; 6974 if let Some(engine) = engine { 6975 let current = SemVer::current(); 6976 if engine > current { 6977 eprintln!( 6978 "compat snapshot failed: {} created by newer engine {}.{}.{}", 6979 entry.path, engine.major, engine.minor, engine.patch 6980 ); 6981 failed = true; 6982 continue; 6983 } 6984 } 6985 let expected = match parse_hash(&entry.world_hash) { 6986 Ok(value) => value, 6987 Err(err) => { 6988 eprintln!("compat snapshot failed: {}", err); 6989 failed = true; 6990 continue; 6991 } 6992 }; 6993 let actual = hash_world(&world); 6994 if actual != expected { 6995 eprintln!( 6996 "compat snapshot hash mismatch {}: {} != {}", 6997 entry.path, 6998 format_hash(actual), 6999 format_hash(expected) 7000 ); 7001 failed = true; 7002 } 7003 } 7004 if failed { 7005 std::process::exit(2); 7006 } 7007 } 7008 _ => { 7009 eprintln!("Unknown compat subcommand: {}", subcommand); 7010 print_compat_usage(); 7011 std::process::exit(2); 7012 } 7013 } 7014 } 7015 7016 #[derive(Clone, Debug, Serialize, Deserialize, Default)] 7017 #[serde(default)] 7018 struct GoldenManifest { 7019 version: u32, 7020 bundles: Vec<GoldenBundleEntry>, 7021 } 7022 7023 #[derive(Clone, Debug, Serialize, Deserialize, Default)] 7024 #[serde(default)] 7025 struct GoldenBundleEntry { 7026 path: String, 7027 label: Option<String>, 7028 tags: Vec<String>, 7029 } 7030 7031 #[derive(Clone, Debug, Serialize, Deserialize)] 7032 #[allow(clippy::large_enum_variant)] 7033 #[serde(tag = "kind", rename_all = "snake_case")] 7034 enum GoldenBundle { 7035 Replay(GoldenReplayBundle), 7036 Cine(GoldenCineBundle), 7037 } 7038 7039 #[derive(Clone, Debug, Serialize, Deserialize)] 7040 struct GoldenReplayBundle { 7041 version: u32, 7042 name: String, 7043 engine: SemVer, 7044 replay: Replay, 7045 #[serde(default)] 7046 initial_world: Option<World>, 7047 ticks: Vec<GoldenTick>, 7048 } 7049 7050 #[derive(Clone, Debug, Serialize, Deserialize)] 7051 struct GoldenCineBundle { 7052 version: u32, 7053 name: String, 7054 engine: SemVer, 7055 timeline: CineTimeline, 7056 #[serde(default)] 7057 timeline_path: Option<String>, 7058 #[serde(default)] 7059 choices: Vec<usize>, 7060 expected_event_hash: String, 7061 #[serde(default)] 7062 expected_choices: Vec<CineChoiceRecord>, 7063 } 7064 7065 #[derive(Clone, Debug, Serialize, Deserialize)] 7066 struct GoldenTick { 7067 turn: u32, 7068 tick_in_turn: u32, 7069 tick: u64, 7070 phase: Phase, 7071 events: Vec<GoldenEvent>, 7072 world_hash: String, 7073 entities: Vec<GoldenEntity>, 7074 } 7075 7076 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 7077 struct GoldenEvent { 7078 kind: u16, 7079 a: u32, 7080 b: u32, 7081 x: i32, 7082 y: i32, 7083 value: i32, 7084 } 7085 7086 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 7087 struct GoldenStatus { 7088 kind: u8, 7089 duration: u32, 7090 } 7091 7092 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 7093 struct GoldenEntity { 7094 id: u32, 7095 team: u8, 7096 x: i32, 7097 y: i32, 7098 hp: i32, 7099 max_hp: i32, 7100 armor: i32, 7101 statuses: Vec<GoldenStatus>, 7102 } 7103 7104 struct GoldenTickSnapshot { 7105 turn: u32, 7106 tick_in_turn: u32, 7107 tick: u64, 7108 phase: Phase, 7109 events: Vec<GoldenEvent>, 7110 world_hash: u64, 7111 entities: Vec<GoldenEntity>, 7112 } 7113 7114 fn load_golden_manifest(path: &str) -> Result<GoldenManifest, String> { 7115 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 7116 let mut manifest: GoldenManifest = 7117 serde_json::from_str(&data).map_err(|err| err.to_string())?; 7118 if manifest.version == 0 { 7119 manifest.version = GOLDEN_MANIFEST_VERSION; 7120 } 7121 Ok(manifest) 7122 } 7123 7124 fn print_golden_usage() { 7125 eprintln!( 7126 "Usage: dynostic_cli golden build --replay PATH --out PATH [--script PATH] [--name NAME]\n\ 7127 dynostic_cli golden build --cine PATH --out PATH [--choices PATH] [--name NAME]\n\ 7128 dynostic_cli golden verify --manifest PATH" 7129 ); 7130 } 7131 7132 #[derive(Default)] 7133 struct GoldenBuildFlags { 7134 out: Option<String>, 7135 replay: Option<String>, 7136 cine: Option<String>, 7137 choices: Option<String>, 7138 name: Option<String>, 7139 script: Option<String>, 7140 } 7141 7142 fn parse_golden_build_flags(args: &[String]) -> Result<GoldenBuildFlags, String> { 7143 let mut flags = GoldenBuildFlags::default(); 7144 let mut iter = args.iter().peekable(); 7145 while let Some(arg) = iter.next() { 7146 match arg.as_str() { 7147 "--out" => { 7148 let value = iter 7149 .next() 7150 .ok_or_else(|| "Missing value for --out".to_string())?; 7151 flags.out = Some(value.clone()); 7152 } 7153 "--replay" => { 7154 let value = iter 7155 .next() 7156 .ok_or_else(|| "Missing value for --replay".to_string())?; 7157 flags.replay = Some(value.clone()); 7158 } 7159 "--cine" | "--timeline" => { 7160 let value = iter 7161 .next() 7162 .ok_or_else(|| "Missing value for --cine".to_string())?; 7163 flags.cine = Some(value.clone()); 7164 } 7165 "--choices" => { 7166 let value = iter 7167 .next() 7168 .ok_or_else(|| "Missing value for --choices".to_string())?; 7169 flags.choices = Some(value.clone()); 7170 } 7171 "--name" => { 7172 let value = iter 7173 .next() 7174 .ok_or_else(|| "Missing value for --name".to_string())?; 7175 flags.name = Some(value.clone()); 7176 } 7177 "--script" => { 7178 let value = iter 7179 .next() 7180 .ok_or_else(|| "Missing value for --script".to_string())?; 7181 flags.script = Some(value.clone()); 7182 } 7183 _ if arg.starts_with("--out=") => { 7184 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 7185 } 7186 _ if arg.starts_with("--replay=") => { 7187 flags.replay = Some(arg.trim_start_matches("--replay=").to_string()); 7188 } 7189 _ if arg.starts_with("--cine=") => { 7190 flags.cine = Some(arg.trim_start_matches("--cine=").to_string()); 7191 } 7192 _ if arg.starts_with("--timeline=") => { 7193 flags.cine = Some(arg.trim_start_matches("--timeline=").to_string()); 7194 } 7195 _ if arg.starts_with("--choices=") => { 7196 flags.choices = Some(arg.trim_start_matches("--choices=").to_string()); 7197 } 7198 _ if arg.starts_with("--name=") => { 7199 flags.name = Some(arg.trim_start_matches("--name=").to_string()); 7200 } 7201 _ if arg.starts_with("--script=") => { 7202 flags.script = Some(arg.trim_start_matches("--script=").to_string()); 7203 } 7204 _ if arg.starts_with("--") => { 7205 return Err(format!("Unknown flag {}", arg)); 7206 } 7207 _ => { 7208 return Err(format!("Unexpected arg {}", arg)); 7209 } 7210 } 7211 } 7212 Ok(flags) 7213 } 7214 7215 #[derive(Default)] 7216 struct GoldenVerifyFlags { 7217 manifest: Option<String>, 7218 } 7219 7220 fn parse_golden_verify_flags(args: &[String]) -> Result<GoldenVerifyFlags, String> { 7221 let mut flags = GoldenVerifyFlags::default(); 7222 let mut iter = args.iter().peekable(); 7223 while let Some(arg) = iter.next() { 7224 match arg.as_str() { 7225 "--manifest" => { 7226 let value = iter 7227 .next() 7228 .ok_or_else(|| "Missing value for --manifest".to_string())?; 7229 flags.manifest = Some(value.clone()); 7230 } 7231 _ if arg.starts_with("--manifest=") => { 7232 flags.manifest = Some(arg.trim_start_matches("--manifest=").to_string()); 7233 } 7234 _ if arg.starts_with("--") => { 7235 return Err(format!("Unknown flag {}", arg)); 7236 } 7237 _ => { 7238 return Err(format!("Unexpected arg {}", arg)); 7239 } 7240 } 7241 } 7242 Ok(flags) 7243 } 7244 7245 fn golden_event_from(event: &DynosticEvent) -> GoldenEvent { 7246 GoldenEvent { 7247 kind: event.kind, 7248 a: event.a, 7249 b: event.b, 7250 x: event.x, 7251 y: event.y, 7252 value: event.value, 7253 } 7254 } 7255 7256 fn snapshot_entities(sim: &Engine) -> Vec<GoldenEntity> { 7257 let mut entities: Vec<GoldenEntity> = sim 7258 .world() 7259 .entities() 7260 .iter() 7261 .map(|entity| GoldenEntity { 7262 id: entity.id(), 7263 team: entity.team(), 7264 x: entity.pos_struct().x, 7265 y: entity.pos_struct().y, 7266 hp: entity.hp(), 7267 max_hp: entity.max_hp(), 7268 armor: entity.armor(), 7269 statuses: entity 7270 .statuses() 7271 .iter() 7272 .map(|status| GoldenStatus { 7273 kind: status.kind().as_u8(), 7274 duration: status.duration(), 7275 }) 7276 .collect(), 7277 }) 7278 .collect(); 7279 entities.sort_by_key(|entity| entity.id); 7280 entities 7281 } 7282 7283 fn snapshot_tick(sim: &Engine, turn: u32, tick_in_turn: u32) -> GoldenTickSnapshot { 7284 GoldenTickSnapshot { 7285 turn, 7286 tick_in_turn, 7287 tick: sim.tick(), 7288 phase: sim.phase(), 7289 events: sim.events().iter().map(golden_event_from).collect(), 7290 world_hash: hash_world(sim.world()), 7291 entities: snapshot_entities(sim), 7292 } 7293 } 7294 7295 fn status_label(kind: u8) -> &'static str { 7296 match kind { 7297 1 => "poison", 7298 2 => "burn", 7299 3 => "stun", 7300 4 => "shield", 7301 _ => "status", 7302 } 7303 } 7304 7305 fn format_statuses(list: &[GoldenStatus]) -> String { 7306 let mut parts = Vec::new(); 7307 for status in list { 7308 parts.push(format!("{}:{}", status_label(status.kind), status.duration)); 7309 } 7310 parts.join(",") 7311 } 7312 7313 fn format_event(event: &GoldenEvent) -> String { 7314 format!( 7315 "kind {} a {} b {} x {} y {} val {}", 7316 event.kind, event.a, event.b, event.x, event.y, event.value 7317 ) 7318 } 7319 7320 fn diff_entities(expected: &[GoldenEntity], actual: &[GoldenEntity]) -> Vec<String> { 7321 let mut expected_map: HashMap<u32, &GoldenEntity> = HashMap::new(); 7322 let mut actual_map: HashMap<u32, &GoldenEntity> = HashMap::new(); 7323 for entity in expected { 7324 expected_map.insert(entity.id, entity); 7325 } 7326 for entity in actual { 7327 actual_map.insert(entity.id, entity); 7328 } 7329 let mut ids: Vec<u32> = expected_map 7330 .keys() 7331 .chain(actual_map.keys()) 7332 .copied() 7333 .collect(); 7334 ids.sort_unstable(); 7335 ids.dedup(); 7336 7337 let mut deltas = Vec::new(); 7338 for id in ids { 7339 match (expected_map.get(&id), actual_map.get(&id)) { 7340 (None, Some(actual_entity)) => { 7341 deltas.push(format!( 7342 "entity {} missing expected (team {} at {},{} hp {}/{})", 7343 id, 7344 actual_entity.team, 7345 actual_entity.x, 7346 actual_entity.y, 7347 actual_entity.hp, 7348 actual_entity.max_hp 7349 )); 7350 } 7351 (Some(expected_entity), None) => { 7352 deltas.push(format!( 7353 "entity {} missing actual (team {} at {},{} hp {}/{})", 7354 id, 7355 expected_entity.team, 7356 expected_entity.x, 7357 expected_entity.y, 7358 expected_entity.hp, 7359 expected_entity.max_hp 7360 )); 7361 } 7362 (Some(expected_entity), Some(actual_entity)) => { 7363 let mut changes = Vec::new(); 7364 if expected_entity.team != actual_entity.team { 7365 changes.push(format!( 7366 "team {} -> {}", 7367 expected_entity.team, actual_entity.team 7368 )); 7369 } 7370 if expected_entity.x != actual_entity.x || expected_entity.y != actual_entity.y { 7371 changes.push(format!( 7372 "pos ({},{}) -> ({},{})", 7373 expected_entity.x, expected_entity.y, actual_entity.x, actual_entity.y 7374 )); 7375 } 7376 if expected_entity.hp != actual_entity.hp 7377 || expected_entity.max_hp != actual_entity.max_hp 7378 { 7379 changes.push(format!( 7380 "hp {}/{} -> {}/{}", 7381 expected_entity.hp, 7382 expected_entity.max_hp, 7383 actual_entity.hp, 7384 actual_entity.max_hp 7385 )); 7386 } 7387 if expected_entity.armor != actual_entity.armor { 7388 changes.push(format!( 7389 "armor {} -> {}", 7390 expected_entity.armor, actual_entity.armor 7391 )); 7392 } 7393 if expected_entity.statuses != actual_entity.statuses { 7394 changes.push(format!( 7395 "status [{}] -> [{}]", 7396 format_statuses(&expected_entity.statuses), 7397 format_statuses(&actual_entity.statuses) 7398 )); 7399 } 7400 if !changes.is_empty() { 7401 deltas.push(format!("entity {} {}", id, changes.join(" | "))); 7402 } 7403 } 7404 (None, None) => {} 7405 } 7406 } 7407 deltas 7408 } 7409 7410 fn compare_events(expected: &[GoldenEvent], actual: &[GoldenEvent]) -> Option<usize> { 7411 let min_len = expected.len().min(actual.len()); 7412 for idx in 0..min_len { 7413 if expected[idx] != actual[idx] { 7414 return Some(idx); 7415 } 7416 } 7417 if expected.len() != actual.len() { 7418 return Some(min_len); 7419 } 7420 None 7421 } 7422 7423 fn collect_golden_ticks(sim: &mut Engine, replay: &Replay) -> Result<Vec<GoldenTick>, String> { 7424 let mut ticks = Vec::new(); 7425 for (turn_index, turn) in replay.turns.iter().enumerate() { 7426 sim.clear_plans(); 7427 for intent in &turn.intents { 7428 if !sim.apply_intent(intent.clone()) { 7429 return Err(format!("failed to apply intent at turn {}", turn_index)); 7430 } 7431 } 7432 if sim.commit() == 0 { 7433 return Err(format!("commit failed at turn {}", turn_index)); 7434 } 7435 let mut tick_in_turn = 0_u32; 7436 while sim.phase() != Phase::Plan { 7437 sim.step(1); 7438 tick_in_turn += 1; 7439 let snapshot = snapshot_tick(sim, turn_index as u32 + 1, tick_in_turn); 7440 ticks.push(GoldenTick { 7441 turn: snapshot.turn, 7442 tick_in_turn: snapshot.tick_in_turn, 7443 tick: snapshot.tick, 7444 phase: snapshot.phase, 7445 events: snapshot.events, 7446 world_hash: format_hash(snapshot.world_hash), 7447 entities: snapshot.entities, 7448 }); 7449 } 7450 } 7451 Ok(ticks) 7452 } 7453 7454 fn build_golden_replay_bundle( 7455 replay: Replay, 7456 name: String, 7457 script: Option<&ScenarioScript>, 7458 ) -> Result<GoldenReplayBundle, String> { 7459 if replay.turns.is_empty() { 7460 return Err("replay has no turns".to_string()); 7461 } 7462 match replay.verify() { 7463 Ok(report) => { 7464 if !report.ok { 7465 eprintln!("warning: replay verification mismatch; using intents anyway"); 7466 } 7467 } 7468 Err(err) => { 7469 eprintln!( 7470 "warning: replay verification failed: {}; using intents anyway", 7471 err.message 7472 ); 7473 } 7474 } 7475 let mut sim = Engine::new(replay.seed); 7476 sim.set_metric_profile(replay.metric_profile); 7477 sim.set_abilities(replay.abilities.clone()) 7478 .map_err(|errors| errors.join("; "))?; 7479 sim.set_reactions(replay.reactions.clone()) 7480 .map_err(|errors| errors.join("; "))?; 7481 sim.set_director_config(replay.director.clone()) 7482 .map_err(|errors| errors.join("; "))?; 7483 if let Some(script) = script { 7484 apply_scenario_script(&mut sim, script)?; 7485 } 7486 let initial_world = if script.is_some() { 7487 Some(sim.world().clone()) 7488 } else { 7489 None 7490 }; 7491 let ticks = collect_golden_ticks(&mut sim, &replay)?; 7492 Ok(GoldenReplayBundle { 7493 version: GOLDEN_REPLAY_BUNDLE_VERSION, 7494 name, 7495 engine: SemVer::current(), 7496 replay, 7497 initial_world, 7498 ticks, 7499 }) 7500 } 7501 7502 fn build_golden_cine_bundle( 7503 timeline: CineTimeline, 7504 choices: Vec<usize>, 7505 name: String, 7506 timeline_path: Option<String>, 7507 ) -> Result<GoldenCineBundle, String> { 7508 let mut player = if choices.is_empty() { 7509 CinePlayer::new(timeline.clone()).map_err(|err| err.message)? 7510 } else { 7511 CinePlayer::with_choices(timeline.clone(), choices.clone()).map_err(|err| err.message)? 7512 }; 7513 let report = player.run_to_end(); 7514 Ok(GoldenCineBundle { 7515 version: GOLDEN_CINE_BUNDLE_VERSION, 7516 name, 7517 engine: SemVer::current(), 7518 timeline, 7519 timeline_path, 7520 choices, 7521 expected_event_hash: format_hash(report.event_hash), 7522 expected_choices: report.choices, 7523 }) 7524 } 7525 7526 fn verify_golden_replay_bundle(path: &str, bundle: &GoldenReplayBundle) -> Result<(), String> { 7527 if bundle.version != GOLDEN_REPLAY_BUNDLE_VERSION { 7528 return Err(format!( 7529 "golden bundle version mismatch {}: expected {}, got {}", 7530 path, GOLDEN_REPLAY_BUNDLE_VERSION, bundle.version 7531 )); 7532 } 7533 if bundle.engine > SemVer::current() { 7534 return Err(format!( 7535 "golden bundle {} created by newer engine {}.{}.{}", 7536 path, bundle.engine.major, bundle.engine.minor, bundle.engine.patch 7537 )); 7538 } 7539 let replay = &bundle.replay; 7540 let mut sim = Engine::new(replay.seed); 7541 sim.set_metric_profile(replay.metric_profile); 7542 sim.set_abilities(replay.abilities.clone()) 7543 .map_err(|errors| errors.join("; "))?; 7544 sim.set_reactions(replay.reactions.clone()) 7545 .map_err(|errors| errors.join("; "))?; 7546 sim.set_director_config(replay.director.clone()) 7547 .map_err(|errors| errors.join("; "))?; 7548 if let Some(world) = bundle.initial_world.clone() { 7549 sim.set_world(world); 7550 } 7551 7552 let mut expected_index = 0_usize; 7553 let mut global_event_index = 0_usize; 7554 for (turn_index, turn) in replay.turns.iter().enumerate() { 7555 sim.clear_plans(); 7556 for intent in &turn.intents { 7557 if !sim.apply_intent(intent.clone()) { 7558 return Err(format!( 7559 "golden replay {} failed to apply intent at turn {}", 7560 path, turn_index 7561 )); 7562 } 7563 } 7564 if sim.commit() == 0 { 7565 return Err(format!( 7566 "golden replay {} commit failed at turn {}", 7567 path, turn_index 7568 )); 7569 } 7570 let mut tick_in_turn = 0_u32; 7571 while sim.phase() != Phase::Plan { 7572 sim.step(1); 7573 tick_in_turn += 1; 7574 let expected = bundle.ticks.get(expected_index).ok_or_else(|| { 7575 format!( 7576 "golden replay {} missing expected tick entry at turn {} tick {}", 7577 path, 7578 turn_index + 1, 7579 tick_in_turn 7580 ) 7581 })?; 7582 let actual = snapshot_tick(&sim, turn_index as u32 + 1, tick_in_turn); 7583 expected_index += 1; 7584 let actual_world_hash = format_hash(actual.world_hash); 7585 7586 let mut mismatch = false; 7587 if expected.turn != actual.turn || expected.tick_in_turn != actual.tick_in_turn { 7588 mismatch = true; 7589 eprintln!( 7590 "golden replay {} tick index mismatch: expected turn {} tick {}, got turn {} tick {}", 7591 path, expected.turn, expected.tick_in_turn, actual.turn, actual.tick_in_turn 7592 ); 7593 } 7594 if expected.tick != actual.tick { 7595 mismatch = true; 7596 eprintln!( 7597 "golden replay {} tick value mismatch: expected {}, got {}", 7598 path, expected.tick, actual.tick 7599 ); 7600 } 7601 if expected.phase != actual.phase { 7602 mismatch = true; 7603 eprintln!( 7604 "golden replay {} phase mismatch at turn {} tick {}: {:?} vs {:?}", 7605 path, expected.turn, expected.tick_in_turn, expected.phase, actual.phase 7606 ); 7607 } 7608 7609 let event_mismatch = compare_events(&expected.events, &actual.events); 7610 if let Some(offset) = event_mismatch { 7611 mismatch = true; 7612 let expected_ev = expected.events.get(offset); 7613 let actual_ev = actual.events.get(offset); 7614 let global_index = global_event_index + offset; 7615 eprintln!( 7616 "golden replay {} event mismatch at turn {} tick {} (event #{})", 7617 path, expected.turn, expected.tick_in_turn, global_index 7618 ); 7619 match (expected_ev, actual_ev) { 7620 (Some(exp), Some(act)) => { 7621 eprintln!(" expected: {}", format_event(exp)); 7622 eprintln!(" actual: {}", format_event(act)); 7623 } 7624 (Some(exp), None) => { 7625 eprintln!(" expected: {}", format_event(exp)); 7626 eprintln!(" actual: <missing>"); 7627 } 7628 (None, Some(act)) => { 7629 eprintln!(" expected: <missing>"); 7630 eprintln!(" actual: {}", format_event(act)); 7631 } 7632 (None, None) => {} 7633 } 7634 } 7635 7636 if expected.world_hash != actual_world_hash { 7637 mismatch = true; 7638 eprintln!( 7639 "golden replay {} world hash mismatch at turn {} tick {}: {} != {}", 7640 path, 7641 expected.turn, 7642 expected.tick_in_turn, 7643 expected.world_hash, 7644 actual_world_hash 7645 ); 7646 } 7647 7648 if expected.entities != actual.entities { 7649 mismatch = true; 7650 let deltas = diff_entities(&expected.entities, &actual.entities); 7651 let cap = 8_usize; 7652 eprintln!( 7653 "golden replay {} entity mismatch at turn {} tick {} ({} diffs)", 7654 path, 7655 expected.turn, 7656 expected.tick_in_turn, 7657 deltas.len() 7658 ); 7659 for (idx, delta) in deltas.iter().take(cap).enumerate() { 7660 eprintln!(" {}. {}", idx + 1, delta); 7661 } 7662 if deltas.len() > cap { 7663 eprintln!(" ... {} more", deltas.len() - cap); 7664 } 7665 } 7666 7667 if mismatch { 7668 return Err(format!( 7669 "golden replay {} mismatch at turn {} tick {}", 7670 path, expected.turn, expected.tick_in_turn 7671 )); 7672 } 7673 7674 global_event_index += expected.events.len(); 7675 } 7676 } 7677 if expected_index != bundle.ticks.len() { 7678 return Err(format!( 7679 "golden replay {} expected {} ticks but simulated {}", 7680 path, 7681 bundle.ticks.len(), 7682 expected_index 7683 )); 7684 } 7685 Ok(()) 7686 } 7687 7688 fn verify_golden_cine_bundle(path: &str, bundle: &GoldenCineBundle) -> Result<(), String> { 7689 if bundle.version != GOLDEN_CINE_BUNDLE_VERSION { 7690 return Err(format!( 7691 "golden cine bundle version mismatch {}: expected {}, got {}", 7692 path, GOLDEN_CINE_BUNDLE_VERSION, bundle.version 7693 )); 7694 } 7695 if bundle.engine > SemVer::current() { 7696 return Err(format!( 7697 "golden cine bundle {} created by newer engine {}.{}.{}", 7698 path, bundle.engine.major, bundle.engine.minor, bundle.engine.patch 7699 )); 7700 } 7701 let mut player = if bundle.choices.is_empty() { 7702 CinePlayer::new(bundle.timeline.clone()).map_err(|err| err.message)? 7703 } else { 7704 CinePlayer::with_choices(bundle.timeline.clone(), bundle.choices.clone()) 7705 .map_err(|err| err.message)? 7706 }; 7707 let report = player.run_to_end(); 7708 let actual_hash = format_hash(report.event_hash); 7709 if actual_hash != bundle.expected_event_hash { 7710 return Err(format!( 7711 "golden cine {} hash mismatch: {} != {}", 7712 path, actual_hash, bundle.expected_event_hash 7713 )); 7714 } 7715 if !bundle.expected_choices.is_empty() && bundle.expected_choices != report.choices { 7716 return Err(format!("golden cine {} choices mismatch", path)); 7717 } 7718 Ok(()) 7719 } 7720 7721 fn handle_golden_command(args: Vec<String>) { 7722 if args.is_empty() { 7723 print_golden_usage(); 7724 std::process::exit(2); 7725 } 7726 let subcommand = &args[0]; 7727 match subcommand.as_str() { 7728 "build" => { 7729 let flags = match parse_golden_build_flags(&args[1..]) { 7730 Ok(flags) => flags, 7731 Err(err) => { 7732 eprintln!("{}", err); 7733 print_golden_usage(); 7734 std::process::exit(2); 7735 } 7736 }; 7737 let GoldenBuildFlags { 7738 out, 7739 replay, 7740 cine, 7741 choices, 7742 name, 7743 script, 7744 } = flags; 7745 let out_path = match out { 7746 Some(path) => path, 7747 None => { 7748 eprintln!("Missing --out"); 7749 print_golden_usage(); 7750 std::process::exit(2); 7751 } 7752 }; 7753 if replay.is_some() && cine.is_some() { 7754 eprintln!("Use either --replay or --cine"); 7755 print_golden_usage(); 7756 std::process::exit(2); 7757 } 7758 if script.is_some() && cine.is_some() { 7759 eprintln!("--script is only supported with --replay"); 7760 print_golden_usage(); 7761 std::process::exit(2); 7762 } 7763 if let Some(replay_path) = replay { 7764 let data = fs::read_to_string(&replay_path).unwrap_or_else(|err| { 7765 eprintln!("Failed to read replay {}: {}", replay_path, err); 7766 std::process::exit(2); 7767 }); 7768 let replay = Replay::from_json(&data).unwrap_or_else(|err| { 7769 eprintln!("Failed to parse replay {}: {}", replay_path, err); 7770 std::process::exit(2); 7771 }); 7772 let script = script 7773 .as_deref() 7774 .map(|path| load_scenario_script(Path::new(path))) 7775 .transpose() 7776 .unwrap_or_else(|err| { 7777 eprintln!("Failed to read script: {}", err); 7778 std::process::exit(2); 7779 }); 7780 let name = name.unwrap_or_else(|| { 7781 Path::new(&replay_path) 7782 .file_stem() 7783 .and_then(|stem| stem.to_str()) 7784 .unwrap_or("replay") 7785 .to_string() 7786 }); 7787 let bundle = build_golden_replay_bundle(replay, name, script.as_ref()) 7788 .unwrap_or_else(|err| { 7789 eprintln!("Failed to build golden replay: {}", err); 7790 std::process::exit(2); 7791 }); 7792 let encoded = serde_json::to_string_pretty(&GoldenBundle::Replay(bundle)) 7793 .unwrap_or_else(|err| { 7794 eprintln!("Failed to serialize golden bundle: {}", err); 7795 std::process::exit(2); 7796 }); 7797 if let Err(err) = fs::write(&out_path, encoded) { 7798 eprintln!("Failed to write {}: {}", out_path, err); 7799 std::process::exit(2); 7800 } 7801 } else if let Some(timeline_path) = cine { 7802 let data = fs::read_to_string(&timeline_path).unwrap_or_else(|err| { 7803 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 7804 std::process::exit(2); 7805 }); 7806 let timeline = CineTimeline::from_json(&data).unwrap_or_else(|err| { 7807 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 7808 std::process::exit(2); 7809 }); 7810 let choices = choices 7811 .as_deref() 7812 .map(load_cine_choices) 7813 .unwrap_or_default(); 7814 let name = name.unwrap_or_else(|| { 7815 Path::new(&timeline_path) 7816 .file_stem() 7817 .and_then(|stem| stem.to_str()) 7818 .unwrap_or("cutscene") 7819 .to_string() 7820 }); 7821 let bundle = build_golden_cine_bundle(timeline, choices, name, Some(timeline_path)) 7822 .unwrap_or_else(|err| { 7823 eprintln!("Failed to build golden cine: {}", err); 7824 std::process::exit(2); 7825 }); 7826 let encoded = serde_json::to_string_pretty(&GoldenBundle::Cine(bundle)) 7827 .unwrap_or_else(|err| { 7828 eprintln!("Failed to serialize golden bundle: {}", err); 7829 std::process::exit(2); 7830 }); 7831 if let Err(err) = fs::write(&out_path, encoded) { 7832 eprintln!("Failed to write {}: {}", out_path, err); 7833 std::process::exit(2); 7834 } 7835 } else { 7836 eprintln!("Missing --replay or --cine"); 7837 print_golden_usage(); 7838 std::process::exit(2); 7839 } 7840 } 7841 "verify" => { 7842 let flags = match parse_golden_verify_flags(&args[1..]) { 7843 Ok(flags) => flags, 7844 Err(err) => { 7845 eprintln!("{}", err); 7846 print_golden_usage(); 7847 std::process::exit(2); 7848 } 7849 }; 7850 let manifest_path = match flags.manifest { 7851 Some(path) => path, 7852 None => { 7853 eprintln!("Missing --manifest"); 7854 print_golden_usage(); 7855 std::process::exit(2); 7856 } 7857 }; 7858 let manifest = load_golden_manifest(&manifest_path).unwrap_or_else(|err| { 7859 eprintln!("Failed to load manifest {}: {}", manifest_path, err); 7860 std::process::exit(2); 7861 }); 7862 if manifest.version != GOLDEN_MANIFEST_VERSION { 7863 eprintln!( 7864 "golden manifest version mismatch: expected {}, got {}", 7865 GOLDEN_MANIFEST_VERSION, manifest.version 7866 ); 7867 std::process::exit(2); 7868 } 7869 let mut failed = false; 7870 for entry in manifest.bundles { 7871 let data = match fs::read_to_string(&entry.path) { 7872 Ok(data) => data, 7873 Err(err) => { 7874 eprintln!("golden bundle missing {}: {}", entry.path, err); 7875 failed = true; 7876 continue; 7877 } 7878 }; 7879 let bundle: GoldenBundle = match serde_json::from_str(&data) { 7880 Ok(bundle) => bundle, 7881 Err(err) => { 7882 eprintln!("golden bundle parse failed {}: {}", entry.path, err); 7883 failed = true; 7884 continue; 7885 } 7886 }; 7887 let result = match bundle { 7888 GoldenBundle::Replay(bundle) => { 7889 verify_golden_replay_bundle(&entry.path, &bundle) 7890 } 7891 GoldenBundle::Cine(bundle) => verify_golden_cine_bundle(&entry.path, &bundle), 7892 }; 7893 if let Err(err) = result { 7894 eprintln!("golden verify failed {}: {}", entry.path, err); 7895 failed = true; 7896 } 7897 } 7898 if failed { 7899 std::process::exit(2); 7900 } 7901 } 7902 _ => { 7903 eprintln!("Unknown golden subcommand: {}", subcommand); 7904 print_golden_usage(); 7905 std::process::exit(2); 7906 } 7907 } 7908 } 7909 7910 const EPISODE_MANIFEST_VERSION: u32 = 1; 7911 7912 /// Episodic story manifest describing ordered episode flow and unlock rules. 7913 #[derive(Clone, Debug, Default, Deserialize)] 7914 #[serde(default)] 7915 struct EpisodeManifest { 7916 version: u32, 7917 id: String, 7918 name: String, 7919 episodes: Vec<EpisodeEntry>, 7920 } 7921 7922 /// Episode entry within an episodic manifest. 7923 #[derive(Clone, Debug, Default, Deserialize)] 7924 #[serde(default)] 7925 struct EpisodeEntry { 7926 id: String, 7927 name: String, 7928 summary: Option<String>, 7929 timeline: Option<String>, 7930 requires_episodes: Vec<String>, 7931 requires_flags: Vec<String>, 7932 } 7933 7934 /// Load an episodic story manifest from disk. 7935 fn load_episode_manifest(path: &str) -> Result<EpisodeManifest, String> { 7936 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 7937 serde_json::from_str(&data).map_err(|err| err.to_string()) 7938 } 7939 7940 /// Validate an episodic story manifest structure and references. 7941 fn validate_episode_manifest(manifest: &EpisodeManifest) -> Vec<String> { 7942 fn is_blank(value: &str) -> bool { 7943 value.trim().is_empty() 7944 } 7945 7946 fn has_dependency_cycle( 7947 episode_id: &str, 7948 requires_by_episode: &HashMap<String, Vec<String>>, 7949 visiting: &mut HashSet<String>, 7950 visited: &mut HashSet<String>, 7951 ) -> bool { 7952 if visited.contains(episode_id) { 7953 return false; 7954 } 7955 if !visiting.insert(episode_id.to_string()) { 7956 return true; 7957 } 7958 if let Some(requires) = requires_by_episode.get(episode_id) { 7959 for required_id in requires { 7960 if !requires_by_episode.contains_key(required_id) { 7961 continue; 7962 } 7963 if has_dependency_cycle(required_id, requires_by_episode, visiting, visited) { 7964 return true; 7965 } 7966 } 7967 } 7968 visiting.remove(episode_id); 7969 visited.insert(episode_id.to_string()); 7970 false 7971 } 7972 7973 let mut errors = Vec::new(); 7974 if manifest.version != EPISODE_MANIFEST_VERSION { 7975 errors.push(format!( 7976 "episode manifest version mismatch (expected {}, got {})", 7977 EPISODE_MANIFEST_VERSION, manifest.version 7978 )); 7979 } 7980 if is_blank(&manifest.id) { 7981 errors.push("episode manifest.id is empty".to_string()); 7982 } 7983 if is_blank(&manifest.name) { 7984 errors.push("episode manifest.name is empty".to_string()); 7985 } 7986 if manifest.episodes.is_empty() { 7987 errors.push("episode manifest.episodes is empty".to_string()); 7988 } 7989 7990 let mut ids = HashSet::new(); 7991 let mut episode_order = HashMap::new(); 7992 let mut requires_by_episode: HashMap<String, Vec<String>> = HashMap::new(); 7993 for (index, episode) in manifest.episodes.iter().enumerate() { 7994 let prefix = format!("episodes[{}]", index); 7995 if is_blank(&episode.id) { 7996 errors.push(format!("{}.id is empty", prefix)); 7997 } else if !ids.insert(episode.id.clone()) { 7998 errors.push(format!("{}.id duplicated", prefix)); 7999 } else { 8000 episode_order.insert(episode.id.clone(), index); 8001 requires_by_episode.insert(episode.id.clone(), episode.requires_episodes.clone()); 8002 } 8003 if is_blank(&episode.name) { 8004 errors.push(format!("{}.name is empty", prefix)); 8005 } 8006 if let Some(timeline) = &episode.timeline { 8007 if is_blank(timeline) { 8008 errors.push(format!("{}.timeline is empty", prefix)); 8009 } 8010 } 8011 if let Some(summary) = &episode.summary { 8012 if is_blank(summary) { 8013 errors.push(format!("{}.summary is empty", prefix)); 8014 } 8015 } 8016 for (req_index, entry) in episode.requires_episodes.iter().enumerate() { 8017 if is_blank(entry) { 8018 errors.push(format!( 8019 "{}.requires_episodes[{}] is empty", 8020 prefix, req_index 8021 )); 8022 } 8023 } 8024 let mut seen_requires_episodes = HashSet::new(); 8025 for (req_index, entry) in episode.requires_episodes.iter().enumerate() { 8026 if is_blank(entry) { 8027 continue; 8028 } 8029 if !seen_requires_episodes.insert(entry.clone()) { 8030 errors.push(format!( 8031 "{}.requires_episodes[{}] duplicated ({})", 8032 prefix, req_index, entry 8033 )); 8034 } 8035 } 8036 for (req_index, entry) in episode.requires_flags.iter().enumerate() { 8037 if is_blank(entry) { 8038 errors.push(format!("{}.requires_flags[{}] is empty", prefix, req_index)); 8039 } 8040 } 8041 let mut seen_requires_flags = HashSet::new(); 8042 for (req_index, entry) in episode.requires_flags.iter().enumerate() { 8043 if is_blank(entry) { 8044 continue; 8045 } 8046 if !seen_requires_flags.insert(entry.clone()) { 8047 errors.push(format!( 8048 "{}.requires_flags[{}] duplicated ({})", 8049 prefix, req_index, entry 8050 )); 8051 } 8052 } 8053 } 8054 8055 for (index, episode) in manifest.episodes.iter().enumerate() { 8056 let prefix = format!("episodes[{}]", index); 8057 let episode_id = episode.id.as_str(); 8058 let Some(current_order) = episode_order.get(episode_id).copied() else { 8059 continue; 8060 }; 8061 for entry in &episode.requires_episodes { 8062 if is_blank(entry) { 8063 continue; 8064 } 8065 if entry == episode_id && !is_blank(episode_id) { 8066 errors.push(format!("{}.requires_episodes references itself", prefix)); 8067 continue; 8068 } 8069 if !ids.contains(entry) { 8070 errors.push(format!( 8071 "{}.requires_episodes references missing episode {}", 8072 prefix, entry 8073 )); 8074 continue; 8075 } 8076 if let Some(required_order) = episode_order.get(entry).copied() { 8077 if required_order >= current_order { 8078 errors.push(format!( 8079 "{}.requires_episodes must reference an earlier episode in ordered flow ({})", 8080 prefix, entry 8081 )); 8082 } 8083 } 8084 } 8085 } 8086 8087 let mut visiting = HashSet::new(); 8088 let mut visited = HashSet::new(); 8089 for episode_id in requires_by_episode.keys() { 8090 if has_dependency_cycle( 8091 episode_id, 8092 &requires_by_episode, 8093 &mut visiting, 8094 &mut visited, 8095 ) { 8096 errors.push("episode manifest flow contains a dependency cycle".to_string()); 8097 break; 8098 } 8099 } 8100 errors 8101 } 8102 8103 struct EpisodeValidateFlags { 8104 manifests: Vec<String>, 8105 dir: Option<String>, 8106 } 8107 8108 /// Print usage instructions for episode commands. 8109 fn print_episode_usage() { 8110 eprintln!( 8111 "Usage: dynostic_cli episode validate --manifest PATH\n\ 8112 dynostic_cli episode validate --dir PATH" 8113 ); 8114 } 8115 8116 /// Parse CLI flags for episode validation. 8117 fn parse_episode_validate_flags(args: &[String]) -> Result<EpisodeValidateFlags, String> { 8118 let mut flags = EpisodeValidateFlags { 8119 manifests: Vec::new(), 8120 dir: None, 8121 }; 8122 let mut i = 0; 8123 while i < args.len() { 8124 match args[i].as_str() { 8125 "--manifest" => { 8126 i += 1; 8127 let value = args 8128 .get(i) 8129 .ok_or_else(|| "Missing value for --manifest".to_string())?; 8130 flags.manifests.push(value.clone()); 8131 } 8132 "--dir" => { 8133 i += 1; 8134 let value = args 8135 .get(i) 8136 .ok_or_else(|| "Missing value for --dir".to_string())?; 8137 flags.dir = Some(value.clone()); 8138 } 8139 _ if args[i].starts_with("--manifest=") => { 8140 let value = args[i].trim_start_matches("--manifest="); 8141 flags.manifests.push(value.to_string()); 8142 } 8143 _ if args[i].starts_with("--dir=") => { 8144 let value = args[i].trim_start_matches("--dir="); 8145 flags.dir = Some(value.to_string()); 8146 } 8147 value => { 8148 return Err(format!("Unknown episode validate flag: {}", value)); 8149 } 8150 } 8151 i += 1; 8152 } 8153 Ok(flags) 8154 } 8155 8156 /// Collect episode manifest JSON files from a directory. 8157 fn collect_episode_manifest_paths(dir: &Path) -> Result<Vec<String>, String> { 8158 let mut paths = Vec::new(); 8159 let entries = fs::read_dir(dir).map_err(|err| err.to_string())?; 8160 for entry in entries { 8161 let entry = entry.map_err(|err| err.to_string())?; 8162 let file_type = entry.file_type().map_err(|err| err.to_string())?; 8163 if !file_type.is_file() { 8164 continue; 8165 } 8166 let path = entry.path(); 8167 let is_json = path 8168 .extension() 8169 .and_then(|ext| ext.to_str()) 8170 .map(|ext| ext.eq_ignore_ascii_case("json")) 8171 .unwrap_or(false); 8172 if is_json { 8173 paths.push(path.to_string_lossy().to_string()); 8174 } 8175 } 8176 paths.sort(); 8177 paths.dedup(); 8178 Ok(paths) 8179 } 8180 8181 /// Handle episode CLI subcommands. 8182 fn handle_episode_command(args: Vec<String>) { 8183 if args.is_empty() { 8184 print_episode_usage(); 8185 std::process::exit(2); 8186 } 8187 let subcommand = &args[0]; 8188 match subcommand.as_str() { 8189 "validate" => { 8190 let flags = match parse_episode_validate_flags(&args[1..]) { 8191 Ok(flags) => flags, 8192 Err(err) => { 8193 eprintln!("{}", err); 8194 print_episode_usage(); 8195 std::process::exit(2); 8196 } 8197 }; 8198 let mut manifest_paths = flags.manifests.clone(); 8199 if let Some(dir) = flags.dir.as_deref() { 8200 match collect_episode_manifest_paths(Path::new(dir)) { 8201 Ok(mut paths) => manifest_paths.append(&mut paths), 8202 Err(err) => { 8203 eprintln!("Failed to read episode manifests from {}: {}", dir, err); 8204 std::process::exit(2); 8205 } 8206 } 8207 } 8208 if manifest_paths.is_empty() { 8209 eprintln!("Missing --manifest or --dir"); 8210 print_episode_usage(); 8211 std::process::exit(2); 8212 } 8213 manifest_paths.sort(); 8214 manifest_paths.dedup(); 8215 let mut failed = false; 8216 for path in manifest_paths { 8217 let manifest = match load_episode_manifest(&path) { 8218 Ok(manifest) => manifest, 8219 Err(err) => { 8220 eprintln!("episode manifest load failed {}: {}", path, err); 8221 failed = true; 8222 continue; 8223 } 8224 }; 8225 let errors = validate_episode_manifest(&manifest); 8226 if !errors.is_empty() { 8227 for err in errors { 8228 eprintln!("episode manifest {}: {}", path, err); 8229 } 8230 failed = true; 8231 } 8232 } 8233 if failed { 8234 std::process::exit(2); 8235 } 8236 println!("OK: episode manifests validated"); 8237 } 8238 _ => { 8239 eprintln!("Unknown episode subcommand: {}", subcommand); 8240 print_episode_usage(); 8241 std::process::exit(2); 8242 } 8243 } 8244 } 8245 8246 const TUTORIAL_MANIFEST_VERSION: u32 = 1; 8247 8248 #[derive(Deserialize)] 8249 struct TutorialManifest { 8250 version: u32, 8251 tutorials: Vec<TutorialManifestEntry>, 8252 } 8253 8254 #[derive(Deserialize)] 8255 struct TutorialManifestEntry { 8256 id: String, 8257 pack: String, 8258 replay: String, 8259 } 8260 8261 #[derive(Clone, Debug, Default, Deserialize)] 8262 #[serde(default)] 8263 struct TutorialConfig { 8264 #[allow(dead_code)] 8265 version: u32, 8266 steps: Vec<TutorialStep>, 8267 } 8268 8269 #[derive(Clone, Debug, Default, Deserialize)] 8270 #[serde(default)] 8271 struct TutorialStep { 8272 id: String, 8273 trigger: Option<Value>, 8274 complete: Option<Value>, 8275 fail: Option<Value>, 8276 min_ticks: Option<u64>, 8277 } 8278 8279 #[derive(Default)] 8280 struct TutorialProgress { 8281 step_index: usize, 8282 step_started: bool, 8283 step_start_tick: u64, 8284 completed: bool, 8285 } 8286 8287 #[derive(Clone, Debug, Default, Deserialize)] 8288 #[serde(default)] 8289 struct TutorialIntentScript { 8290 seed: Option<u64>, 8291 turns: Vec<TutorialIntentTurn>, 8292 } 8293 8294 #[derive(Clone, Debug, Default, Deserialize)] 8295 #[serde(default)] 8296 struct TutorialIntentTurn { 8297 intents: Vec<Intent>, 8298 } 8299 8300 fn load_tutorial_manifest(path: &str) -> Result<TutorialManifest, String> { 8301 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 8302 serde_json::from_str(&data).map_err(|err| err.to_string()) 8303 } 8304 8305 fn load_tutorial_intents(path: &Path) -> Result<TutorialIntentScript, String> { 8306 let mut value = load_json_value(path)?; 8307 if let Value::Array(items) = value { 8308 let mut turns = Vec::new(); 8309 for entry in items { 8310 if let Value::Array(intents) = entry { 8311 let mut obj = Map::new(); 8312 obj.insert("intents".to_string(), Value::Array(intents)); 8313 turns.push(Value::Object(obj)); 8314 } else { 8315 turns.push(entry); 8316 } 8317 } 8318 let mut obj = Map::new(); 8319 obj.insert("turns".to_string(), Value::Array(turns)); 8320 value = Value::Object(obj); 8321 } 8322 serde_json::from_value(value).map_err(|err| err.to_string()) 8323 } 8324 8325 fn load_replay_from_path(path: &str) -> Result<Replay, String> { 8326 let data = fs::read_to_string(path).map_err(|err| err.to_string())?; 8327 let mut value: Value = serde_json::from_str(&data).map_err(|err| err.to_string())?; 8328 if value 8329 .get("kind") 8330 .and_then(|value| value.as_str()) 8331 .map(|value| value.eq_ignore_ascii_case("replay")) 8332 .unwrap_or(false) 8333 { 8334 if let Some(replay) = value.get("replay") { 8335 value = replay.clone(); 8336 } 8337 } 8338 serde_json::from_value(value).map_err(|err| err.to_string()) 8339 } 8340 8341 fn tutorial_config_from_value(value: Value) -> Result<TutorialConfig, String> { 8342 validate_tutorial_value(&value)?; 8343 serde_json::from_value(value).map_err(|err| err.to_string()) 8344 } 8345 8346 fn tutorial_event_kind_from_value(value: &Value) -> Option<u16> { 8347 if let Some(num) = value.as_u64() { 8348 return u16::try_from(num).ok(); 8349 } 8350 let name = value.as_str()?.to_ascii_lowercase(); 8351 let kind = match name.as_str() { 8352 "tick" => ev::TICK, 8353 "move" => ev::MOVE, 8354 "hit" => ev::HIT, 8355 "miss" => ev::MISS, 8356 "damage" => ev::DAMAGE, 8357 "death" => ev::DEATH, 8358 "status_applied" => ev::STATUS_APPLIED, 8359 "status_expired" => ev::STATUS_EXPIRED, 8360 "hazard_spawn" => ev::HAZARD_SPAWN, 8361 "hazard_expire" => ev::HAZARD_EXPIRE, 8362 "heal" => ev::HEAL, 8363 "reaction" => ev::REACTION, 8364 "sound" => ev::SOUND, 8365 "detection" => ev::DETECTION, 8366 "tile_change" => ev::TILE_CHANGE, 8367 "tile_trigger" => ev::TILE_TRIGGER, 8368 "director_bark" => ev::DIRECTOR_BARK, 8369 "director_music" => ev::DIRECTOR_MUSIC, 8370 "director_camera" => ev::DIRECTOR_CAMERA, 8371 "projectile_spawn" => ev::PROJECTILE_SPAWN, 8372 "projectile_step" => ev::PROJECTILE_STEP, 8373 "projectile_hit" => ev::PROJECTILE_HIT, 8374 "projectile_ricochet" => ev::PROJECTILE_RICOCHET, 8375 "projectile_expire" => ev::PROJECTILE_EXPIRE, 8376 "ai_decision" => ev::AI_DECISION, 8377 _ => return None, 8378 }; 8379 Some(kind) 8380 } 8381 8382 fn tutorial_phase_from_value(value: &Value) -> Option<Phase> { 8383 if let Some(num) = value.as_u64() { 8384 return match num { 8385 0 => Some(Phase::Plan), 8386 1 => Some(Phase::Commit), 8387 2 => Some(Phase::Execute), 8388 _ => None, 8389 }; 8390 } 8391 let phase = value.as_str()?.to_ascii_lowercase(); 8392 match phase.as_str() { 8393 "plan" => Some(Phase::Plan), 8394 "commit" => Some(Phase::Commit), 8395 "execute" => Some(Phase::Execute), 8396 _ => None, 8397 } 8398 } 8399 8400 fn tutorial_entity_team(sim: &Engine, entity_id: u32) -> Option<u8> { 8401 sim.world() 8402 .entities() 8403 .iter() 8404 .find(|entity| entity.id() == entity_id) 8405 .map(|entity| entity.team()) 8406 } 8407 8408 fn value_as_i32(value: &Value) -> Option<i32> { 8409 value.as_i64().and_then(|v| i32::try_from(v).ok()) 8410 } 8411 8412 fn value_as_u32(value: &Value) -> Option<u32> { 8413 value.as_u64().and_then(|v| u32::try_from(v).ok()) 8414 } 8415 8416 fn tutorial_event_matches(cond: &Map<String, Value>, ev: &DynosticEvent, sim: &Engine) -> bool { 8417 if let Some(kind_value) = cond.get("kind") { 8418 let Some(kind) = tutorial_event_kind_from_value(kind_value) else { 8419 return false; 8420 }; 8421 if ev.kind != kind { 8422 return false; 8423 } 8424 } 8425 if let Some(a) = cond.get("a").and_then(value_as_u32) { 8426 if ev.a != a { 8427 return false; 8428 } 8429 } 8430 if let Some(b) = cond.get("b").and_then(value_as_u32) { 8431 if ev.b != b { 8432 return false; 8433 } 8434 } 8435 if let Some(x) = cond.get("x").and_then(value_as_i32) { 8436 if ev.x != x { 8437 return false; 8438 } 8439 } 8440 if let Some(y) = cond.get("y").and_then(value_as_i32) { 8441 if ev.y != y { 8442 return false; 8443 } 8444 } 8445 if let Some(entity_id) = cond.get("entity_id").and_then(value_as_u32) { 8446 if ev.a != entity_id { 8447 return false; 8448 } 8449 } 8450 if let Some(target_id) = cond.get("target_id").and_then(value_as_u32) { 8451 if ev.b != target_id { 8452 return false; 8453 } 8454 } 8455 if let Some(value_min) = cond.get("value_min").and_then(value_as_i32) { 8456 if ev.value < value_min { 8457 return false; 8458 } 8459 } 8460 if let Some(value_max) = cond.get("value_max").and_then(value_as_i32) { 8461 if ev.value > value_max { 8462 return false; 8463 } 8464 } 8465 if let Some(team) = cond.get("team").and_then(value_as_u32) { 8466 let expected = u8::try_from(team).unwrap_or(u8::MAX); 8467 let team_a = tutorial_entity_team(sim, ev.a); 8468 let team_b = tutorial_entity_team(sim, ev.b); 8469 if team_a != Some(expected) && team_b != Some(expected) { 8470 return false; 8471 } 8472 } 8473 true 8474 } 8475 8476 fn tutorial_has_plan(intents: &[Intent], kind: &str) -> bool { 8477 let mut kind = kind.to_ascii_lowercase(); 8478 if kind == "ability" { 8479 kind = "use".to_string(); 8480 } 8481 intents.iter().any(|intent| match intent { 8482 Intent::Move { .. } => kind == "move", 8483 Intent::Attack { .. } => kind == "attack", 8484 Intent::Use { .. } => kind == "use", 8485 Intent::Wait { .. } => kind == "wait", 8486 }) 8487 } 8488 8489 fn tutorial_condition_met( 8490 cond: Option<&Value>, 8491 events: &[DynosticEvent], 8492 phase: Phase, 8493 tick: u64, 8494 intents: &[Intent], 8495 sim: &Engine, 8496 ) -> bool { 8497 let Some(cond) = cond else { 8498 return true; 8499 }; 8500 let Some(obj) = cond.as_object() else { 8501 return false; 8502 }; 8503 if let Some(any) = obj.get("any").and_then(|value| value.as_array()) { 8504 return any 8505 .iter() 8506 .any(|entry| tutorial_condition_met(Some(entry), events, phase, tick, intents, sim)); 8507 } 8508 if let Some(all) = obj.get("all").and_then(|value| value.as_array()) { 8509 return all 8510 .iter() 8511 .all(|entry| tutorial_condition_met(Some(entry), events, phase, tick, intents, sim)); 8512 } 8513 let ctype = obj 8514 .get("type") 8515 .and_then(|value| value.as_str()) 8516 .unwrap_or(""); 8517 match ctype { 8518 "event" => events 8519 .iter() 8520 .any(|event| tutorial_event_matches(obj, event, sim)), 8521 "phase" => obj 8522 .get("phase") 8523 .and_then(tutorial_phase_from_value) 8524 .map(|expected| expected == phase) 8525 .unwrap_or(false), 8526 "tick" => obj 8527 .get("tick") 8528 .or_else(|| obj.get("min_tick")) 8529 .and_then(|value| value.as_u64()) 8530 .map(|min_tick| tick >= min_tick) 8531 .unwrap_or(false), 8532 "plan" => obj 8533 .get("intent") 8534 .and_then(|value| value.as_str()) 8535 .map(|intent| tutorial_has_plan(intents, intent)) 8536 .unwrap_or(false), 8537 _ => false, 8538 } 8539 } 8540 8541 fn tutorial_current_step<'a>( 8542 config: &'a TutorialConfig, 8543 state: &TutorialProgress, 8544 ) -> Option<&'a TutorialStep> { 8545 config.steps.get(state.step_index) 8546 } 8547 8548 fn tutorial_advance(config: &TutorialConfig, state: &mut TutorialProgress) { 8549 state.step_index += 1; 8550 state.step_started = false; 8551 state.step_start_tick = 0; 8552 if state.step_index >= config.steps.len() { 8553 state.completed = true; 8554 } 8555 } 8556 8557 fn tutorial_update( 8558 config: &TutorialConfig, 8559 state: &mut TutorialProgress, 8560 events: &[DynosticEvent], 8561 phase: Phase, 8562 tick: u64, 8563 intents: &[Intent], 8564 sim: &Engine, 8565 ) -> Result<(), String> { 8566 if state.completed { 8567 return Ok(()); 8568 } 8569 let step = match tutorial_current_step(config, state) { 8570 Some(step) => step, 8571 None => { 8572 state.completed = true; 8573 return Ok(()); 8574 } 8575 }; 8576 if !state.step_started { 8577 if tutorial_condition_met(step.trigger.as_ref(), events, phase, tick, intents, sim) { 8578 state.step_started = true; 8579 state.step_start_tick = tick; 8580 } else { 8581 return Ok(()); 8582 } 8583 } 8584 let min_ticks = step.min_ticks.unwrap_or(0); 8585 if tick.saturating_sub(state.step_start_tick) < min_ticks { 8586 return Ok(()); 8587 } 8588 if let Some(fail) = step.fail.as_ref() { 8589 if tutorial_condition_met(Some(fail), events, phase, tick, intents, sim) { 8590 return Err(format!( 8591 "tutorial step '{}' failed at tick {}", 8592 step.id, tick 8593 )); 8594 } 8595 } 8596 if step.complete.is_none() { 8597 tutorial_advance(config, state); 8598 return Ok(()); 8599 } 8600 if tutorial_condition_met(step.complete.as_ref(), events, phase, tick, intents, sim) { 8601 tutorial_advance(config, state); 8602 } 8603 Ok(()) 8604 } 8605 8606 fn verify_tutorial_replay( 8607 config: &TutorialConfig, 8608 replay: &Replay, 8609 abilities: &AbilitySet, 8610 reactions: &ReactionSet, 8611 director: &DirectorConfig, 8612 ai_behavior: &AiBehaviorConfig, 8613 ) -> Result<(), String> { 8614 if config.steps.is_empty() { 8615 return Err("tutorial has no steps".to_string()); 8616 } 8617 if replay.turns.is_empty() { 8618 return Err("replay has no turns".to_string()); 8619 } 8620 let mut sim = Engine::new(replay.seed); 8621 sim.set_metric_profile(replay.metric_profile); 8622 sim.set_abilities(abilities.clone()) 8623 .map_err(|errors| errors.join("; "))?; 8624 sim.set_reactions(reactions.clone()) 8625 .map_err(|errors| errors.join("; "))?; 8626 sim.set_director_config(director.clone()) 8627 .map_err(|errors| errors.join("; "))?; 8628 sim.set_ai_behavior_config(ai_behavior.clone()) 8629 .map_err(|errors| errors.join("; "))?; 8630 8631 let mut state = TutorialProgress::default(); 8632 let mut last_intents: Vec<Intent> = Vec::new(); 8633 8634 for (turn_index, turn) in replay.turns.iter().enumerate() { 8635 let intents = &turn.intents; 8636 last_intents = intents.clone(); 8637 tutorial_update( 8638 config, 8639 &mut state, 8640 &[], 8641 Phase::Plan, 8642 sim.tick(), 8643 intents, 8644 &sim, 8645 )?; 8646 sim.clear_plans(); 8647 for intent in intents { 8648 if !sim.apply_intent(intent.clone()) { 8649 return Err(format!( 8650 "tutorial replay failed to apply intent at turn {}", 8651 turn_index + 1 8652 )); 8653 } 8654 } 8655 if sim.commit() == 0 { 8656 return Err(format!( 8657 "tutorial replay commit failed at turn {}", 8658 turn_index + 1 8659 )); 8660 } 8661 while sim.phase() != Phase::Plan { 8662 sim.step(1); 8663 let events = sim.events().to_vec(); 8664 tutorial_update( 8665 config, 8666 &mut state, 8667 &events, 8668 sim.phase(), 8669 sim.tick(), 8670 intents, 8671 &sim, 8672 )?; 8673 } 8674 if state.completed { 8675 break; 8676 } 8677 } 8678 8679 if !state.completed { 8680 tutorial_update( 8681 config, 8682 &mut state, 8683 &[], 8684 Phase::Plan, 8685 sim.tick(), 8686 &last_intents, 8687 &sim, 8688 )?; 8689 } 8690 if !state.completed { 8691 let step = tutorial_current_step(config, &state) 8692 .map(|step| step.id.as_str()) 8693 .unwrap_or("unknown"); 8694 return Err(format!( 8695 "tutorial did not complete (stuck at step '{}')", 8696 step 8697 )); 8698 } 8699 Ok(()) 8700 } 8701 8702 struct TutorialRecordFlags { 8703 pack: Option<String>, 8704 intents: Option<String>, 8705 out: Option<String>, 8706 script: Option<String>, 8707 } 8708 8709 struct TutorialVerifyFlags { 8710 manifest: Option<String>, 8711 pack: Option<String>, 8712 replay: Option<String>, 8713 } 8714 8715 fn print_tutorial_usage() { 8716 eprintln!( 8717 "Usage: dynostic_cli tutorial record --pack PATH --intents PATH --out PATH [--script PATH]\n\ 8718 dynostic_cli tutorial verify --manifest PATH\n\ 8719 dynostic_cli tutorial verify --pack PATH --replay PATH" 8720 ); 8721 } 8722 8723 fn parse_tutorial_record_flags(args: &[String]) -> Result<TutorialRecordFlags, String> { 8724 let mut flags = TutorialRecordFlags { 8725 pack: None, 8726 intents: None, 8727 out: None, 8728 script: None, 8729 }; 8730 let mut i = 0; 8731 while i < args.len() { 8732 match args[i].as_str() { 8733 "--pack" => { 8734 i += 1; 8735 flags.pack = args.get(i).cloned(); 8736 } 8737 "--intents" => { 8738 i += 1; 8739 flags.intents = args.get(i).cloned(); 8740 } 8741 "--out" => { 8742 i += 1; 8743 flags.out = args.get(i).cloned(); 8744 } 8745 "--script" => { 8746 i += 1; 8747 flags.script = args.get(i).cloned(); 8748 } 8749 value => { 8750 return Err(format!("Unknown tutorial record flag: {}", value)); 8751 } 8752 } 8753 i += 1; 8754 } 8755 Ok(flags) 8756 } 8757 8758 fn parse_tutorial_verify_flags(args: &[String]) -> Result<TutorialVerifyFlags, String> { 8759 let mut flags = TutorialVerifyFlags { 8760 manifest: None, 8761 pack: None, 8762 replay: None, 8763 }; 8764 let mut i = 0; 8765 while i < args.len() { 8766 match args[i].as_str() { 8767 "--manifest" => { 8768 i += 1; 8769 flags.manifest = args.get(i).cloned(); 8770 } 8771 "--pack" => { 8772 i += 1; 8773 flags.pack = args.get(i).cloned(); 8774 } 8775 "--replay" => { 8776 i += 1; 8777 flags.replay = args.get(i).cloned(); 8778 } 8779 value => { 8780 return Err(format!("Unknown tutorial verify flag: {}", value)); 8781 } 8782 } 8783 i += 1; 8784 } 8785 Ok(flags) 8786 } 8787 8788 fn handle_tutorial_command(args: Vec<String>) { 8789 if args.is_empty() { 8790 print_tutorial_usage(); 8791 std::process::exit(2); 8792 } 8793 let subcommand = &args[0]; 8794 match subcommand.as_str() { 8795 "record" => { 8796 let flags = match parse_tutorial_record_flags(&args[1..]) { 8797 Ok(flags) => flags, 8798 Err(err) => { 8799 eprintln!("{}", err); 8800 print_tutorial_usage(); 8801 std::process::exit(2); 8802 } 8803 }; 8804 let pack_path = match flags.pack { 8805 Some(path) => path, 8806 None => { 8807 eprintln!("Missing --pack"); 8808 print_tutorial_usage(); 8809 std::process::exit(2); 8810 } 8811 }; 8812 let intents_path = match flags.intents { 8813 Some(path) => path, 8814 None => { 8815 eprintln!("Missing --intents"); 8816 print_tutorial_usage(); 8817 std::process::exit(2); 8818 } 8819 }; 8820 let out_path = match flags.out { 8821 Some(path) => path, 8822 None => { 8823 eprintln!("Missing --out"); 8824 print_tutorial_usage(); 8825 std::process::exit(2); 8826 } 8827 }; 8828 let ctx = build_pack_context(&pack_path).unwrap_or_else(|err| { 8829 eprintln!("Failed to load pack: {}", err); 8830 std::process::exit(2); 8831 }); 8832 let ResolvedPackContext { 8833 abilities, 8834 reactions, 8835 director, 8836 ai, 8837 warnings, 8838 .. 8839 } = ctx; 8840 for warning in &warnings { 8841 eprintln!("warning: {}", warning); 8842 } 8843 let script_path = flags.script.as_deref().map(Path::new); 8844 let script = script_path 8845 .map(load_scenario_script) 8846 .transpose() 8847 .unwrap_or_else(|err| { 8848 eprintln!("Failed to load script: {}", err); 8849 std::process::exit(2); 8850 }); 8851 let intent_script = 8852 load_tutorial_intents(Path::new(&intents_path)).unwrap_or_else(|err| { 8853 eprintln!("Failed to load intents: {}", err); 8854 std::process::exit(2); 8855 }); 8856 if intent_script.turns.is_empty() { 8857 eprintln!("Tutorial intents has no turns"); 8858 std::process::exit(2); 8859 } 8860 let seed = intent_script.seed.unwrap_or(123); 8861 let director = director.unwrap_or_default(); 8862 let ai_behavior = ai.unwrap_or_default(); 8863 let mut sim = Engine::new(seed); 8864 sim.set_abilities(abilities.clone()) 8865 .unwrap_or_else(|errors| { 8866 eprintln!("Ability validation failed: {}", errors.join("; ")); 8867 std::process::exit(2); 8868 }); 8869 sim.set_reactions(reactions.clone()) 8870 .unwrap_or_else(|errors| { 8871 eprintln!("Reaction validation failed: {}", errors.join("; ")); 8872 std::process::exit(2); 8873 }); 8874 sim.set_director_config(director.clone()) 8875 .unwrap_or_else(|errors| { 8876 eprintln!("Director validation failed: {}", errors.join("; ")); 8877 std::process::exit(2); 8878 }); 8879 sim.set_ai_behavior_config(ai_behavior.clone()) 8880 .unwrap_or_else(|errors| { 8881 eprintln!("AI config validation failed: {}", errors.join("; ")); 8882 std::process::exit(2); 8883 }); 8884 if let Some(script) = script.as_ref() { 8885 if let Err(err) = apply_scenario_script(&mut sim, script) { 8886 eprintln!("Failed to apply script: {}", err); 8887 std::process::exit(2); 8888 } 8889 } 8890 let mut replay = Replay::new( 8891 seed, 8892 sim.world().metric_profile(), 8893 abilities, 8894 reactions, 8895 director, 8896 ai_behavior, 8897 ); 8898 for (index, turn) in intent_script.turns.iter().enumerate() { 8899 if turn.intents.is_empty() { 8900 eprintln!("Tutorial intents turn {} has no intents", index + 1); 8901 std::process::exit(2); 8902 } 8903 let recorded = 8904 Replay::record_turn(&mut sim, turn.intents.clone()).unwrap_or_else(|err| { 8905 eprintln!("Failed to record turn {}: {}", index + 1, err.message); 8906 std::process::exit(2); 8907 }); 8908 replay.turns.push(recorded); 8909 } 8910 let encoded = replay.to_json().unwrap_or_else(|err| { 8911 eprintln!("Failed to serialize replay: {}", err); 8912 std::process::exit(2); 8913 }); 8914 if let Err(err) = fs::write(&out_path, encoded) { 8915 eprintln!("Failed to write {}: {}", out_path, err); 8916 std::process::exit(2); 8917 } 8918 } 8919 "verify" => { 8920 let flags = match parse_tutorial_verify_flags(&args[1..]) { 8921 Ok(flags) => flags, 8922 Err(err) => { 8923 eprintln!("{}", err); 8924 print_tutorial_usage(); 8925 std::process::exit(2); 8926 } 8927 }; 8928 if let Some(manifest_path) = flags.manifest { 8929 let manifest = load_tutorial_manifest(&manifest_path).unwrap_or_else(|err| { 8930 eprintln!( 8931 "Failed to load tutorial manifest {}: {}", 8932 manifest_path, err 8933 ); 8934 std::process::exit(2); 8935 }); 8936 if manifest.version != TUTORIAL_MANIFEST_VERSION { 8937 eprintln!( 8938 "tutorial manifest version mismatch: expected {}, got {}", 8939 TUTORIAL_MANIFEST_VERSION, manifest.version 8940 ); 8941 std::process::exit(2); 8942 } 8943 let mut failed = false; 8944 for entry in manifest.tutorials { 8945 let ctx = match build_pack_context(&entry.pack) { 8946 Ok(ctx) => ctx, 8947 Err(err) => { 8948 eprintln!("tutorial {} pack load failed: {}", entry.id, err); 8949 failed = true; 8950 continue; 8951 } 8952 }; 8953 let ResolvedPackContext { 8954 abilities, 8955 reactions, 8956 director, 8957 ai, 8958 tutorial, 8959 warnings, 8960 .. 8961 } = ctx; 8962 for warning in &warnings { 8963 eprintln!("warning: {}", warning); 8964 } 8965 let tutorial_value = match tutorial { 8966 Some(value) => value, 8967 None => { 8968 eprintln!("tutorial {} missing tutorial config", entry.id); 8969 failed = true; 8970 continue; 8971 } 8972 }; 8973 let config = match tutorial_config_from_value(tutorial_value) { 8974 Ok(config) => config, 8975 Err(err) => { 8976 eprintln!("tutorial {} invalid config: {}", entry.id, err); 8977 failed = true; 8978 continue; 8979 } 8980 }; 8981 let replay = match load_replay_from_path(&entry.replay) { 8982 Ok(replay) => replay, 8983 Err(err) => { 8984 eprintln!("tutorial {} replay load failed: {}", entry.id, err); 8985 failed = true; 8986 continue; 8987 } 8988 }; 8989 let director = director.unwrap_or_default(); 8990 let ai_behavior = ai.unwrap_or_default(); 8991 if let Err(err) = verify_tutorial_replay( 8992 &config, 8993 &replay, 8994 &abilities, 8995 &reactions, 8996 &director, 8997 &ai_behavior, 8998 ) { 8999 eprintln!("tutorial {} verify failed: {}", entry.id, err); 9000 failed = true; 9001 } 9002 } 9003 if failed { 9004 std::process::exit(2); 9005 } 9006 return; 9007 } 9008 let pack_path = match flags.pack { 9009 Some(path) => path, 9010 None => { 9011 eprintln!("Missing --pack"); 9012 print_tutorial_usage(); 9013 std::process::exit(2); 9014 } 9015 }; 9016 let replay_path = match flags.replay { 9017 Some(path) => path, 9018 None => { 9019 eprintln!("Missing --replay"); 9020 print_tutorial_usage(); 9021 std::process::exit(2); 9022 } 9023 }; 9024 let ctx = build_pack_context(&pack_path).unwrap_or_else(|err| { 9025 eprintln!("Failed to load pack: {}", err); 9026 std::process::exit(2); 9027 }); 9028 let ResolvedPackContext { 9029 abilities, 9030 reactions, 9031 director, 9032 ai, 9033 tutorial, 9034 warnings, 9035 .. 9036 } = ctx; 9037 for warning in &warnings { 9038 eprintln!("warning: {}", warning); 9039 } 9040 let tutorial_value = match tutorial { 9041 Some(value) => value, 9042 None => { 9043 eprintln!("Pack {} missing tutorial config", pack_path); 9044 std::process::exit(2); 9045 } 9046 }; 9047 let config = tutorial_config_from_value(tutorial_value).unwrap_or_else(|err| { 9048 eprintln!("Invalid tutorial config: {}", err); 9049 std::process::exit(2); 9050 }); 9051 let replay = load_replay_from_path(&replay_path).unwrap_or_else(|err| { 9052 eprintln!("Failed to load replay: {}", err); 9053 std::process::exit(2); 9054 }); 9055 let director = director.unwrap_or_default(); 9056 let ai_behavior = ai.unwrap_or_default(); 9057 if let Err(err) = verify_tutorial_replay( 9058 &config, 9059 &replay, 9060 &abilities, 9061 &reactions, 9062 &director, 9063 &ai_behavior, 9064 ) { 9065 eprintln!("tutorial verify failed: {}", err); 9066 std::process::exit(2); 9067 } 9068 } 9069 _ => { 9070 eprintln!("Unknown tutorial subcommand: {}", subcommand); 9071 print_tutorial_usage(); 9072 std::process::exit(2); 9073 } 9074 } 9075 } 9076 9077 #[derive(Serialize)] 9078 struct ReproDependencyInfo { 9079 id: String, 9080 version: SemVer, 9081 } 9082 9083 #[derive(Serialize)] 9084 struct ReproPackInfo { 9085 id: String, 9086 version: SemVer, 9087 dependencies: Vec<ReproDependencyInfo>, 9088 } 9089 9090 #[derive(Serialize)] 9091 struct ReproEvent { 9092 kind: u16, 9093 a: u32, 9094 b: u32, 9095 x: i32, 9096 y: i32, 9097 value: i32, 9098 } 9099 9100 #[derive(Serialize)] 9101 struct ReproBundle { 9102 version: u32, 9103 engine: SemVer, 9104 pack: Option<ReproPackInfo>, 9105 replay: Replay, 9106 last_turn: u32, 9107 last_tick: u64, 9108 world_hash: String, 9109 last_events: Vec<ReproEvent>, 9110 } 9111 9112 struct ReproSimResult { 9113 last_turn: u32, 9114 last_tick: u64, 9115 world_hash: u64, 9116 last_events: Vec<DynosticEvent>, 9117 } 9118 9119 fn collect_pack_versions(pack_path: &str) -> Result<ReproPackInfo, String> { 9120 let root_pack = load_pack_manifest(pack_path)?; 9121 let deps = resolve_pack_dependencies(&root_pack, pack_path)?; 9122 let mut dep_infos: Vec<ReproDependencyInfo> = deps 9123 .into_iter() 9124 .map(|dep| ReproDependencyInfo { 9125 id: dep.id, 9126 version: dep.version, 9127 }) 9128 .collect(); 9129 dep_infos.sort_by(|a, b| a.id.cmp(&b.id)); 9130 Ok(ReproPackInfo { 9131 id: root_pack.id, 9132 version: root_pack.version, 9133 dependencies: dep_infos, 9134 }) 9135 } 9136 9137 fn simulate_replay_for_repro( 9138 replay: &Replay, 9139 max_turns: Option<u32>, 9140 events_limit: usize, 9141 ) -> Result<ReproSimResult, String> { 9142 let mut sim = Engine::new(replay.seed); 9143 sim.set_metric_profile(replay.metric_profile); 9144 sim.set_abilities(replay.abilities.clone()) 9145 .map_err(|errors| errors.join("; "))?; 9146 9147 let mut last_turn = 0_u32; 9148 let mut last_events: Vec<DynosticEvent> = Vec::new(); 9149 for (turn_index, turn) in replay.turns.iter().enumerate() { 9150 if let Some(limit) = max_turns { 9151 if turn_index as u32 >= limit { 9152 break; 9153 } 9154 } 9155 sim.clear_plans(); 9156 for intent in &turn.intents { 9157 if !sim.apply_intent(intent.clone()) { 9158 return Err(format!("failed to apply intent at turn {}", turn_index)); 9159 } 9160 } 9161 if sim.commit() == 0 { 9162 return Err(format!("commit failed at turn {}", turn_index)); 9163 } 9164 let mut turn_events = Vec::new(); 9165 let mut ticks = 0_u32; 9166 while sim.phase() != Phase::Plan { 9167 if ticks >= REPRO_MAX_TICKS_PER_TURN { 9168 return Err("replay exceeded max ticks".to_string()); 9169 } 9170 sim.step(1); 9171 turn_events.extend_from_slice(sim.events()); 9172 ticks += 1; 9173 } 9174 last_turn = turn_index as u32 + 1; 9175 last_events = turn_events; 9176 } 9177 9178 if events_limit == 0 { 9179 last_events.clear(); 9180 } else if last_events.len() > events_limit { 9181 last_events = last_events[last_events.len() - events_limit..].to_vec(); 9182 } 9183 9184 Ok(ReproSimResult { 9185 last_turn, 9186 last_tick: sim.tick(), 9187 world_hash: hash_world(sim.world()), 9188 last_events, 9189 }) 9190 } 9191 9192 fn print_repro_usage() { 9193 eprintln!( 9194 "Usage: dynostic_cli repro export --replay PATH --out PATH [--pack PATH] [--events N] [--turn N]" 9195 ); 9196 } 9197 9198 #[derive(Default)] 9199 struct ReproExportFlags { 9200 replay: Option<String>, 9201 out: Option<String>, 9202 pack: Option<String>, 9203 events: Option<usize>, 9204 turn: Option<u32>, 9205 } 9206 9207 fn parse_repro_export_flags(args: &[String]) -> Result<ReproExportFlags, String> { 9208 let mut flags = ReproExportFlags::default(); 9209 let mut iter = args.iter().peekable(); 9210 while let Some(arg) = iter.next() { 9211 match arg.as_str() { 9212 "--replay" => { 9213 let value = iter 9214 .next() 9215 .ok_or_else(|| "Missing value for --replay".to_string())?; 9216 flags.replay = Some(value.clone()); 9217 } 9218 "--out" => { 9219 let value = iter 9220 .next() 9221 .ok_or_else(|| "Missing value for --out".to_string())?; 9222 flags.out = Some(value.clone()); 9223 } 9224 "--pack" => { 9225 let value = iter 9226 .next() 9227 .ok_or_else(|| "Missing value for --pack".to_string())?; 9228 flags.pack = Some(value.clone()); 9229 } 9230 "--events" => { 9231 let value = iter 9232 .next() 9233 .ok_or_else(|| "Missing value for --events".to_string())?; 9234 let parsed = parse_u32(value, "events") as usize; 9235 flags.events = Some(parsed); 9236 } 9237 "--turn" => { 9238 let value = iter 9239 .next() 9240 .ok_or_else(|| "Missing value for --turn".to_string())?; 9241 flags.turn = Some(parse_u32(value, "turn")); 9242 } 9243 _ if arg.starts_with("--replay=") => { 9244 flags.replay = Some(arg.trim_start_matches("--replay=").to_string()); 9245 } 9246 _ if arg.starts_with("--out=") => { 9247 flags.out = Some(arg.trim_start_matches("--out=").to_string()); 9248 } 9249 _ if arg.starts_with("--pack=") => { 9250 flags.pack = Some(arg.trim_start_matches("--pack=").to_string()); 9251 } 9252 _ if arg.starts_with("--events=") => { 9253 let value = arg.trim_start_matches("--events="); 9254 flags.events = Some(parse_u32(value, "events") as usize); 9255 } 9256 _ if arg.starts_with("--turn=") => { 9257 let value = arg.trim_start_matches("--turn="); 9258 flags.turn = Some(parse_u32(value, "turn")); 9259 } 9260 _ if arg.starts_with("--") => { 9261 return Err(format!("Unknown flag {}", arg)); 9262 } 9263 _ => { 9264 return Err(format!("Unexpected arg {}", arg)); 9265 } 9266 } 9267 } 9268 Ok(flags) 9269 } 9270 9271 fn handle_repro_command(args: Vec<String>) { 9272 if args.is_empty() { 9273 print_repro_usage(); 9274 std::process::exit(2); 9275 } 9276 let subcommand = &args[0]; 9277 match subcommand.as_str() { 9278 "export" => { 9279 let flags = match parse_repro_export_flags(&args[1..]) { 9280 Ok(flags) => flags, 9281 Err(err) => { 9282 eprintln!("{}", err); 9283 print_repro_usage(); 9284 std::process::exit(2); 9285 } 9286 }; 9287 let replay_path = match flags.replay { 9288 Some(path) => path, 9289 None => { 9290 eprintln!("Missing --replay"); 9291 print_repro_usage(); 9292 std::process::exit(2); 9293 } 9294 }; 9295 let out_path = match flags.out { 9296 Some(path) => path, 9297 None => { 9298 eprintln!("Missing --out"); 9299 print_repro_usage(); 9300 std::process::exit(2); 9301 } 9302 }; 9303 let data = fs::read_to_string(&replay_path).unwrap_or_else(|err| { 9304 eprintln!("Failed to read replay {}: {}", replay_path, err); 9305 std::process::exit(2); 9306 }); 9307 let replay = Replay::from_json(&data).unwrap_or_else(|err| { 9308 eprintln!("Failed to parse replay {}: {}", replay_path, err); 9309 std::process::exit(2); 9310 }); 9311 let pack = flags.pack.as_deref().map(|pack_path| { 9312 collect_pack_versions(pack_path).unwrap_or_else(|err| { 9313 eprintln!("Failed to load pack {}: {}", pack_path, err); 9314 std::process::exit(2); 9315 }) 9316 }); 9317 let events_limit = flags.events.unwrap_or(DEFAULT_REPRO_EVENTS); 9318 let sim = simulate_replay_for_repro(&replay, flags.turn, events_limit).unwrap_or_else( 9319 |err| { 9320 eprintln!("Failed to replay: {}", err); 9321 std::process::exit(2); 9322 }, 9323 ); 9324 let last_events = sim 9325 .last_events 9326 .iter() 9327 .map(|event| ReproEvent { 9328 kind: event.kind, 9329 a: event.a, 9330 b: event.b, 9331 x: event.x, 9332 y: event.y, 9333 value: event.value, 9334 }) 9335 .collect(); 9336 let bundle = ReproBundle { 9337 version: REPRO_BUNDLE_VERSION, 9338 engine: SemVer::current(), 9339 pack, 9340 replay, 9341 last_turn: sim.last_turn, 9342 last_tick: sim.last_tick, 9343 world_hash: format_hash(sim.world_hash), 9344 last_events, 9345 }; 9346 let encoded = serde_json::to_string_pretty(&bundle).unwrap_or_else(|err| { 9347 eprintln!("Failed to serialize repro bundle: {}", err); 9348 std::process::exit(2); 9349 }); 9350 if let Err(err) = fs::write(&out_path, encoded) { 9351 eprintln!("Failed to write {}: {}", out_path, err); 9352 std::process::exit(2); 9353 } 9354 } 9355 _ => { 9356 eprintln!("Unknown repro subcommand: {}", subcommand); 9357 print_repro_usage(); 9358 std::process::exit(2); 9359 } 9360 } 9361 } 9362 9363 #[derive(Clone, Debug, Serialize)] 9364 struct CineRunOutput { 9365 ticks: u32, 9366 event_hash: String, 9367 choices: Vec<CineChoiceRecord>, 9368 world: CineWorldState, 9369 world_effects: Vec<CineWorldEffect>, 9370 } 9371 9372 #[derive(Clone, Debug, Deserialize)] 9373 struct CineChoiceInputFile { 9374 choices: Vec<usize>, 9375 } 9376 9377 #[derive(Clone, Debug, Deserialize)] 9378 struct CineChoiceRecordFile { 9379 choices: Vec<CineChoiceRecord>, 9380 } 9381 9382 #[derive(Clone, Debug, PartialEq, Eq, Hash)] 9383 enum CineDialogueLocation { 9384 Main(usize), 9385 Sequence { 9386 sequence_id: String, 9387 event_index: usize, 9388 }, 9389 } 9390 9391 #[derive(Clone, Debug, PartialEq, Eq)] 9392 struct CineDialogueCsvRow { 9393 location: CineDialogueLocation, 9394 tick: u32, 9395 text_id: Option<String>, 9396 voice: Option<String>, 9397 duration: u32, 9398 } 9399 9400 #[derive(Clone, Debug, PartialEq, Eq)] 9401 struct CineDialogueCsvUpdate { 9402 location: CineDialogueLocation, 9403 text_id: Option<String>, 9404 voice: Option<String>, 9405 duration: u32, 9406 } 9407 9408 fn collect_cine_dialogue_rows(timeline: &CineTimeline) -> Vec<CineDialogueCsvRow> { 9409 let mut rows = Vec::new(); 9410 9411 for (event_index, event) in timeline.events.iter().enumerate() { 9412 if let CineOp::Say { 9413 text_id, 9414 voice, 9415 duration, 9416 .. 9417 } = &event.op 9418 { 9419 rows.push(CineDialogueCsvRow { 9420 location: CineDialogueLocation::Main(event_index), 9421 tick: event.tick, 9422 text_id: text_id.clone(), 9423 voice: voice.clone(), 9424 duration: *duration, 9425 }); 9426 } 9427 } 9428 9429 for (sequence_id, sequence) in &timeline.sequences { 9430 for (event_index, event) in sequence.events.iter().enumerate() { 9431 if let CineOp::Say { 9432 text_id, 9433 voice, 9434 duration, 9435 .. 9436 } = &event.op 9437 { 9438 rows.push(CineDialogueCsvRow { 9439 location: CineDialogueLocation::Sequence { 9440 sequence_id: sequence_id.clone(), 9441 event_index, 9442 }, 9443 tick: event.tick, 9444 text_id: text_id.clone(), 9445 voice: voice.clone(), 9446 duration: *duration, 9447 }); 9448 } 9449 } 9450 } 9451 9452 rows 9453 } 9454 9455 fn csv_escape_field(value: &str) -> String { 9456 let escaped = value.replace('"', "\"\""); 9457 if escaped.contains(',') 9458 || escaped.contains('"') 9459 || escaped.contains('\n') 9460 || escaped.contains('\r') 9461 { 9462 format!("\"{}\"", escaped) 9463 } else { 9464 escaped 9465 } 9466 } 9467 9468 fn render_cine_dialogue_csv(rows: &[CineDialogueCsvRow]) -> String { 9469 let mut output = String::from("scope,sequence_id,event_index,tick,text_id,voice,duration\n"); 9470 for row in rows { 9471 let (scope, sequence_id, event_index) = match &row.location { 9472 CineDialogueLocation::Main(event_index) => ("main", "", *event_index), 9473 CineDialogueLocation::Sequence { 9474 sequence_id, 9475 event_index, 9476 } => ("sequence", sequence_id.as_str(), *event_index), 9477 }; 9478 let fields = [ 9479 csv_escape_field(scope), 9480 csv_escape_field(sequence_id), 9481 csv_escape_field(&event_index.to_string()), 9482 csv_escape_field(&row.tick.to_string()), 9483 csv_escape_field(row.text_id.as_deref().unwrap_or("")), 9484 csv_escape_field(row.voice.as_deref().unwrap_or("")), 9485 csv_escape_field(&row.duration.to_string()), 9486 ]; 9487 output.push_str(&fields.join(",")); 9488 output.push('\n'); 9489 } 9490 output 9491 } 9492 9493 fn parse_csv_records(data: &str) -> Result<Vec<Vec<String>>, String> { 9494 let mut records: Vec<Vec<String>> = Vec::new(); 9495 let mut row: Vec<String> = Vec::new(); 9496 let mut field = String::new(); 9497 let mut chars = data.chars().peekable(); 9498 let mut in_quotes = false; 9499 9500 while let Some(ch) = chars.next() { 9501 if in_quotes { 9502 if ch == '"' { 9503 if chars.peek().copied() == Some('"') { 9504 field.push('"'); 9505 chars.next(); 9506 } else { 9507 in_quotes = false; 9508 } 9509 } else { 9510 field.push(ch); 9511 } 9512 continue; 9513 } 9514 9515 match ch { 9516 '"' => in_quotes = true, 9517 ',' => { 9518 row.push(std::mem::take(&mut field)); 9519 } 9520 '\n' => { 9521 row.push(std::mem::take(&mut field)); 9522 if !(row.len() == 1 && row[0].trim().is_empty()) { 9523 records.push(std::mem::take(&mut row)); 9524 } else { 9525 row.clear(); 9526 } 9527 } 9528 '\r' => { 9529 if chars.peek().copied() == Some('\n') { 9530 continue; 9531 } 9532 row.push(std::mem::take(&mut field)); 9533 if !(row.len() == 1 && row[0].trim().is_empty()) { 9534 records.push(std::mem::take(&mut row)); 9535 } else { 9536 row.clear(); 9537 } 9538 } 9539 _ => field.push(ch), 9540 } 9541 } 9542 9543 if in_quotes { 9544 return Err("CSV parse error: unterminated quoted field".to_string()); 9545 } 9546 9547 if !field.is_empty() || !row.is_empty() { 9548 row.push(field); 9549 if !(row.len() == 1 && row[0].trim().is_empty()) { 9550 records.push(row); 9551 } 9552 } 9553 9554 Ok(records) 9555 } 9556 9557 fn csv_required_column(columns: &HashMap<String, usize>, name: &str) -> Result<usize, String> { 9558 columns 9559 .get(name) 9560 .copied() 9561 .ok_or_else(|| format!("CSV missing required column '{}'", name)) 9562 } 9563 9564 fn csv_optional_cell(row: &[String], index: usize) -> String { 9565 row.get(index).cloned().unwrap_or_default() 9566 } 9567 9568 fn parse_cine_dialogue_csv(data: &str) -> Result<Vec<CineDialogueCsvUpdate>, String> { 9569 let records = parse_csv_records(data)?; 9570 if records.is_empty() { 9571 return Err("CSV file is empty".to_string()); 9572 } 9573 9574 let mut columns: HashMap<String, usize> = HashMap::new(); 9575 for (index, header) in records[0].iter().enumerate() { 9576 let key = header.trim().to_ascii_lowercase(); 9577 if !key.is_empty() { 9578 columns.insert(key, index); 9579 } 9580 } 9581 9582 let scope_index = csv_required_column(&columns, "scope")?; 9583 let sequence_index = csv_required_column(&columns, "sequence_id")?; 9584 let event_index_index = csv_required_column(&columns, "event_index")?; 9585 let text_id_index = csv_required_column(&columns, "text_id")?; 9586 let voice_index = csv_required_column(&columns, "voice")?; 9587 let duration_index = csv_required_column(&columns, "duration")?; 9588 9589 let mut updates = Vec::new(); 9590 let mut seen_locations: HashSet<CineDialogueLocation> = HashSet::new(); 9591 9592 for (record_index, record) in records.iter().enumerate().skip(1) { 9593 if record.iter().all(|value| value.trim().is_empty()) { 9594 continue; 9595 } 9596 9597 let row_number = record_index + 1; 9598 let scope_raw = csv_optional_cell(record, scope_index); 9599 let scope = scope_raw.trim(); 9600 if scope.is_empty() { 9601 return Err(format!("row {} missing scope", row_number)); 9602 } 9603 9604 let event_index_raw = csv_optional_cell(record, event_index_index); 9605 let event_index = event_index_raw.trim().parse::<usize>().map_err(|_| { 9606 format!( 9607 "row {} invalid event_index '{}'", 9608 row_number, 9609 event_index_raw.trim() 9610 ) 9611 })?; 9612 9613 let sequence_raw = csv_optional_cell(record, sequence_index); 9614 let sequence_trimmed = sequence_raw.trim(); 9615 let scope_lower = scope.to_ascii_lowercase(); 9616 let location = if scope_lower == "main" { 9617 CineDialogueLocation::Main(event_index) 9618 } else if scope_lower == "sequence" { 9619 if sequence_trimmed.is_empty() { 9620 return Err(format!( 9621 "row {} scope=sequence requires sequence_id", 9622 row_number 9623 )); 9624 } 9625 CineDialogueLocation::Sequence { 9626 sequence_id: sequence_trimmed.to_string(), 9627 event_index, 9628 } 9629 } else if scope_lower.starts_with("sequence:") { 9630 let suffix = scope[9..].trim(); 9631 if suffix.is_empty() { 9632 return Err(format!( 9633 "row {} scope '{}' missing sequence identifier", 9634 row_number, scope 9635 )); 9636 } 9637 CineDialogueLocation::Sequence { 9638 sequence_id: suffix.to_string(), 9639 event_index, 9640 } 9641 } else { 9642 return Err(format!("row {} invalid scope '{}'", row_number, scope)); 9643 }; 9644 9645 let duration_raw = csv_optional_cell(record, duration_index); 9646 let duration = duration_raw 9647 .trim() 9648 .parse::<u32>() 9649 .map_err(|_| format!("row {} invalid duration '{}'", row_number, duration_raw))?; 9650 if duration == 0 { 9651 return Err(format!("row {} duration must be > 0", row_number)); 9652 } 9653 9654 if !seen_locations.insert(location.clone()) { 9655 return Err(format!( 9656 "row {} duplicates dialogue row {}", 9657 row_number, 9658 describe_dialogue_location(&location) 9659 )); 9660 } 9661 9662 let text_id_raw = csv_optional_cell(record, text_id_index); 9663 let voice_raw = csv_optional_cell(record, voice_index); 9664 updates.push(CineDialogueCsvUpdate { 9665 location, 9666 text_id: { 9667 let value = text_id_raw.trim(); 9668 if value.is_empty() { 9669 None 9670 } else { 9671 Some(value.to_string()) 9672 } 9673 }, 9674 voice: { 9675 let value = voice_raw.trim(); 9676 if value.is_empty() { 9677 None 9678 } else { 9679 Some(value.to_string()) 9680 } 9681 }, 9682 duration, 9683 }); 9684 } 9685 9686 Ok(updates) 9687 } 9688 9689 fn describe_dialogue_location(location: &CineDialogueLocation) -> String { 9690 match location { 9691 CineDialogueLocation::Main(event_index) => format!("main[{}]", event_index), 9692 CineDialogueLocation::Sequence { 9693 sequence_id, 9694 event_index, 9695 } => format!("sequence[{}][{}]", sequence_id, event_index), 9696 } 9697 } 9698 9699 fn apply_dialogue_update(event: &mut CineEvent, update: &CineDialogueCsvUpdate) -> bool { 9700 if let CineOp::Say { 9701 text_id, 9702 voice, 9703 duration, 9704 .. 9705 } = &mut event.op 9706 { 9707 *text_id = update.text_id.clone(); 9708 *voice = update.voice.clone(); 9709 *duration = update.duration; 9710 true 9711 } else { 9712 false 9713 } 9714 } 9715 9716 fn apply_cine_dialogue_csv_updates( 9717 timeline: &mut CineTimeline, 9718 updates: &[CineDialogueCsvUpdate], 9719 ) -> Result<usize, String> { 9720 let mut applied = 0usize; 9721 let mut unresolved = Vec::new(); 9722 9723 for update in updates { 9724 let updated = match &update.location { 9725 CineDialogueLocation::Main(event_index) => timeline 9726 .events 9727 .get_mut(*event_index) 9728 .map(|event| apply_dialogue_update(event, update)) 9729 .unwrap_or(false), 9730 CineDialogueLocation::Sequence { 9731 sequence_id, 9732 event_index, 9733 } => timeline 9734 .sequences 9735 .get_mut(sequence_id) 9736 .and_then(|sequence| sequence.events.get_mut(*event_index)) 9737 .map(|event| apply_dialogue_update(event, update)) 9738 .unwrap_or(false), 9739 }; 9740 9741 if updated { 9742 applied += 1; 9743 } else { 9744 unresolved.push(describe_dialogue_location(&update.location)); 9745 } 9746 } 9747 9748 if !unresolved.is_empty() { 9749 return Err(format!( 9750 "CSV references missing dialogue lines: {}", 9751 unresolved.join(", ") 9752 )); 9753 } 9754 9755 Ok(applied) 9756 } 9757 9758 const CINE_LOCALE_QA_REPORT_VERSION: u32 = 1; 9759 9760 #[derive(Clone, Debug, Serialize)] 9761 struct CineCaptionSafeZoneQa { 9762 x_pct: f64, 9763 y_pct: f64, 9764 w_pct: f64, 9765 h_pct: f64, 9766 } 9767 9768 #[derive(Clone, Debug, Serialize)] 9769 struct CineCaptionSettingsQa { 9770 max_lines: u32, 9771 overflow_min_scale: f64, 9772 safe_zone: CineCaptionSafeZoneQa, 9773 } 9774 9775 #[derive(Clone, Debug, Serialize)] 9776 struct CineLocaleQaEntry { 9777 scope: String, 9778 sequence_id: Option<String>, 9779 event_index: usize, 9780 tick: u32, 9781 op: String, 9782 locale: String, 9783 text_id: Option<String>, 9784 sample_text: String, 9785 duration_ticks: u32, 9786 duration_seconds: f64, 9787 chars: u32, 9788 chars_per_second: f64, 9789 estimated_lines: u32, 9790 max_lines: u32, 9791 high_reading_speed: bool, 9792 overflow: bool, 9793 missing_localization: bool, 9794 settings: CineCaptionSettingsQa, 9795 warnings: Vec<String>, 9796 } 9797 9798 #[derive(Clone, Debug, Serialize)] 9799 struct CineLocaleQaSummary { 9800 events_scanned: usize, 9801 entries: usize, 9802 high_reading_speed: usize, 9803 overflow_warnings: usize, 9804 missing_localizations: usize, 9805 } 9806 9807 #[derive(Clone, Debug, Serialize)] 9808 struct CineLocaleQaReport { 9809 version: u32, 9810 timeline: String, 9811 ticks_per_second: u32, 9812 locales: Vec<String>, 9813 max_cps: f64, 9814 screen_width: u32, 9815 screen_height: u32, 9816 entries: Vec<CineLocaleQaEntry>, 9817 warnings: Vec<String>, 9818 summary: CineLocaleQaSummary, 9819 } 9820 9821 #[derive(Clone, Debug)] 9822 struct CineLocaleQaConfig { 9823 max_cps: f64, 9824 screen_width: u32, 9825 screen_height: u32, 9826 char_width: f64, 9827 default_locale: String, 9828 } 9829 9830 impl Default for CineLocaleQaConfig { 9831 fn default() -> Self { 9832 Self { 9833 max_cps: 24.0, 9834 screen_width: 1920, 9835 screen_height: 1080, 9836 char_width: 8.0, 9837 default_locale: "en".to_string(), 9838 } 9839 } 9840 } 9841 9842 #[derive(Clone, Debug)] 9843 struct CineCaptionEventQa { 9844 scope: String, 9845 sequence_id: Option<String>, 9846 event_index: usize, 9847 tick: u32, 9848 op: String, 9849 text_id: Option<String>, 9850 text: Option<String>, 9851 duration_ticks: u32, 9852 } 9853 9854 fn qa_trimmed_string(value: Option<&Value>) -> Option<String> { 9855 let raw = value.and_then(Value::as_str)?; 9856 let trimmed = raw.trim(); 9857 if trimmed.is_empty() { 9858 None 9859 } else { 9860 Some(trimmed.to_string()) 9861 } 9862 } 9863 9864 fn qa_non_empty_string(value: Option<&Value>) -> Option<String> { 9865 let raw = value.and_then(Value::as_str)?; 9866 if raw.trim().is_empty() { 9867 None 9868 } else { 9869 Some(raw.to_string()) 9870 } 9871 } 9872 9873 fn qa_read_u32(value: Option<&Value>) -> Option<u32> { 9874 value 9875 .and_then(Value::as_u64) 9876 .and_then(|parsed| u32::try_from(parsed).ok()) 9877 } 9878 9879 fn qa_clamp_pct(value: Option<&Value>, fallback: f64) -> f64 { 9880 value 9881 .and_then(Value::as_f64) 9882 .unwrap_or(fallback) 9883 .clamp(0.0, 100.0) 9884 } 9885 9886 fn qa_clamp_max_lines(value: Option<&Value>) -> u32 { 9887 value 9888 .and_then(Value::as_u64) 9889 .and_then(|parsed| u32::try_from(parsed).ok()) 9890 .unwrap_or(2) 9891 .clamp(1, 4) 9892 } 9893 9894 fn qa_clamp_scale(value: Option<&Value>) -> f64 { 9895 value 9896 .and_then(Value::as_f64) 9897 .unwrap_or(0.75) 9898 .clamp(0.4, 1.0) 9899 } 9900 9901 fn qa_caption_settings_for_locale(root: &Value, locale: &str) -> CineCaptionSettingsQa { 9902 let captions = root.get("captions").and_then(Value::as_object); 9903 let base_safe_zone = captions 9904 .and_then(|obj| obj.get("safe_zone")) 9905 .and_then(Value::as_object); 9906 let locale_override = captions 9907 .and_then(|obj| obj.get("locales")) 9908 .and_then(Value::as_object) 9909 .and_then(|locales| locales.get(locale)) 9910 .and_then(Value::as_object); 9911 let locale_safe_zone = locale_override 9912 .and_then(|obj| obj.get("safe_zone")) 9913 .and_then(Value::as_object); 9914 9915 let max_lines = qa_clamp_max_lines( 9916 locale_override 9917 .and_then(|obj| obj.get("max_lines")) 9918 .or_else(|| captions.and_then(|obj| obj.get("max_lines"))), 9919 ); 9920 let overflow_min_scale = qa_clamp_scale( 9921 locale_override 9922 .and_then(|obj| obj.get("overflow_min_scale")) 9923 .or_else(|| captions.and_then(|obj| obj.get("overflow_min_scale"))), 9924 ); 9925 let safe_zone = CineCaptionSafeZoneQa { 9926 x_pct: qa_clamp_pct( 9927 locale_safe_zone 9928 .and_then(|obj| obj.get("x_pct")) 9929 .or_else(|| base_safe_zone.and_then(|obj| obj.get("x_pct"))), 9930 5.0, 9931 ), 9932 y_pct: qa_clamp_pct( 9933 locale_safe_zone 9934 .and_then(|obj| obj.get("y_pct")) 9935 .or_else(|| base_safe_zone.and_then(|obj| obj.get("y_pct"))), 9936 5.0, 9937 ), 9938 w_pct: qa_clamp_pct( 9939 locale_safe_zone 9940 .and_then(|obj| obj.get("w_pct")) 9941 .or_else(|| base_safe_zone.and_then(|obj| obj.get("w_pct"))), 9942 90.0, 9943 ), 9944 h_pct: qa_clamp_pct( 9945 locale_safe_zone 9946 .and_then(|obj| obj.get("h_pct")) 9947 .or_else(|| base_safe_zone.and_then(|obj| obj.get("h_pct"))), 9948 90.0, 9949 ), 9950 }; 9951 9952 CineCaptionSettingsQa { 9953 max_lines, 9954 overflow_min_scale, 9955 safe_zone, 9956 } 9957 } 9958 9959 fn qa_extract_locale_maps(root: &Value) -> Option<BTreeMap<String, BTreeMap<String, String>>> { 9960 let locales = root 9961 .get("locales") 9962 .or_else(|| root.get("localization")) 9963 .and_then(Value::as_object)?; 9964 9965 let has_nested_maps = locales.values().any(Value::is_object); 9966 if !has_nested_maps { 9967 let mut fallback_map = BTreeMap::new(); 9968 for (text_id, value) in locales { 9969 if let Some(text) = qa_non_empty_string(Some(value)) { 9970 fallback_map.insert(text_id.clone(), text); 9971 } 9972 } 9973 let mut maps = BTreeMap::new(); 9974 maps.insert("*".to_string(), fallback_map); 9975 return Some(maps); 9976 } 9977 9978 let mut maps = BTreeMap::new(); 9979 for (locale, map_value) in locales { 9980 if let Some(map_obj) = map_value.as_object() { 9981 let mut locale_map = BTreeMap::new(); 9982 for (text_id, text_value) in map_obj { 9983 if let Some(text) = qa_non_empty_string(Some(text_value)) { 9984 locale_map.insert(text_id.clone(), text); 9985 } 9986 } 9987 maps.insert(locale.clone(), locale_map); 9988 } 9989 } 9990 Some(maps) 9991 } 9992 9993 fn qa_collect_caption_events(root: &Value) -> Vec<CineCaptionEventQa> { 9994 let mut events = Vec::new(); 9995 9996 let mut push_events = |scope: &str, sequence_id: Option<&str>, values: &[Value]| { 9997 for (event_index, event_value) in values.iter().enumerate() { 9998 let Some(event) = event_value.as_object() else { 9999 continue; 10000 }; 10001 let op = event 10002 .get("op") 10003 .and_then(Value::as_str) 10004 .unwrap_or_default() 10005 .to_ascii_lowercase(); 10006 if op != "say" && op != "caption" { 10007 continue; 10008 } 10009 let subtitle = event.get("subtitle").and_then(Value::as_object); 10010 let text_id = qa_trimmed_string( 10011 subtitle 10012 .and_then(|obj| obj.get("text_id")) 10013 .or_else(|| event.get("text_id")), 10014 ); 10015 let text = qa_non_empty_string( 10016 subtitle 10017 .and_then(|obj| obj.get("text")) 10018 .or_else(|| event.get("text")), 10019 ); 10020 let duration_ticks = qa_read_u32( 10021 subtitle 10022 .and_then(|obj| obj.get("duration")) 10023 .or_else(|| event.get("duration")), 10024 ) 10025 .unwrap_or(0); 10026 10027 events.push(CineCaptionEventQa { 10028 scope: scope.to_string(), 10029 sequence_id: sequence_id.map(ToString::to_string), 10030 event_index, 10031 tick: qa_read_u32(event.get("tick")).unwrap_or(0), 10032 op, 10033 text_id, 10034 text, 10035 duration_ticks, 10036 }); 10037 } 10038 }; 10039 10040 if let Some(main_events) = root.get("events").and_then(Value::as_array) { 10041 push_events("main", None, main_events.as_slice()); 10042 } 10043 10044 if let Some(sequences) = root.get("sequences").and_then(Value::as_object) { 10045 let mut sequence_ids: Vec<&String> = sequences.keys().collect(); 10046 sequence_ids.sort(); 10047 for sequence_id in sequence_ids { 10048 let Some(sequence_obj) = sequences.get(sequence_id).and_then(Value::as_object) else { 10049 continue; 10050 }; 10051 let Some(sequence_events) = sequence_obj.get("events").and_then(Value::as_array) else { 10052 continue; 10053 }; 10054 push_events("sequence", Some(sequence_id), sequence_events.as_slice()); 10055 } 10056 } 10057 10058 events 10059 } 10060 10061 fn qa_text_panel_width(settings: &CineCaptionSettingsQa, screen_width: u32) -> f64 { 10062 let width = screen_width.max(1) as f64; 10063 let safe_x = (width * settings.safe_zone.x_pct / 100.0).round(); 10064 let mut safe_w = (width * settings.safe_zone.w_pct / 100.0).round(); 10065 safe_w = safe_w.max(8.0).min((width - safe_x).max(8.0)); 10066 (safe_w - 16.0).max(1.0) 10067 } 10068 10069 fn qa_estimate_wrapped_lines(text: &str, max_chars: usize) -> u32 { 10070 let max_chars = max_chars.max(1); 10071 let mut line_count = 0u32; 10072 for line in text.split('\n') { 10073 let chars = line.chars().count(); 10074 if chars == 0 { 10075 line_count += 1; 10076 } else { 10077 line_count += chars.div_ceil(max_chars) as u32; 10078 } 10079 } 10080 line_count.max(1) 10081 } 10082 10083 fn qa_push_unique_warning(warnings: &mut Vec<String>, seen: &mut HashSet<String>, message: String) { 10084 if seen.insert(message.clone()) { 10085 warnings.push(message); 10086 } 10087 } 10088 10089 #[derive(Clone, Debug)] 10090 struct CineLocaleQaEntryArgs { 10091 text_id: Option<String>, 10092 missing_localization: bool, 10093 settings: CineCaptionSettingsQa, 10094 } 10095 10096 fn qa_build_entry( 10097 event: &CineCaptionEventQa, 10098 locale: &str, 10099 sample_text: String, 10100 args: CineLocaleQaEntryArgs, 10101 ticks_per_second: u32, 10102 cfg: &CineLocaleQaConfig, 10103 ) -> CineLocaleQaEntry { 10104 let CineLocaleQaEntryArgs { 10105 text_id, 10106 missing_localization, 10107 settings, 10108 } = args; 10109 let duration_ticks = event.duration_ticks.max(1); 10110 let duration_seconds = duration_ticks as f64 / (ticks_per_second.max(1) as f64); 10111 let chars = sample_text.chars().count() as u32; 10112 let chars_per_second = if chars > 0 { 10113 chars as f64 / duration_seconds.max(0.001) 10114 } else { 10115 0.0 10116 }; 10117 10118 let panel_width = qa_text_panel_width(&settings, cfg.screen_width); 10119 let max_chars_per_line = 10120 ((panel_width / settings.overflow_min_scale) / cfg.char_width.max(1.0)).floor() as usize; 10121 let estimated_lines = if chars > 0 { 10122 qa_estimate_wrapped_lines(&sample_text, max_chars_per_line.max(1)) 10123 } else { 10124 0 10125 }; 10126 let high_reading_speed = chars > 0 && chars_per_second > cfg.max_cps; 10127 let overflow = chars > 0 && estimated_lines > settings.max_lines; 10128 10129 let mut entry_warnings = Vec::new(); 10130 if missing_localization { 10131 if let Some(text_id) = text_id.as_deref() { 10132 entry_warnings.push(format!( 10133 "cine.caption text_id '{}' missing locale '{}'", 10134 text_id, locale 10135 )); 10136 } else { 10137 entry_warnings.push(format!("cine.caption missing locale '{}'", locale)); 10138 } 10139 } 10140 if high_reading_speed { 10141 entry_warnings.push(format!( 10142 "cine.caption[{}] locale '{}' high reading speed {:.1} chars/s ({})", 10143 event.event_index, locale, chars_per_second, event.op 10144 )); 10145 } 10146 if overflow { 10147 entry_warnings.push(format!( 10148 "cine.caption[{}] locale '{}' overflow risk ({} lines > {})", 10149 event.event_index, locale, estimated_lines, settings.max_lines 10150 )); 10151 } 10152 10153 CineLocaleQaEntry { 10154 scope: event.scope.clone(), 10155 sequence_id: event.sequence_id.clone(), 10156 event_index: event.event_index, 10157 tick: event.tick, 10158 op: event.op.clone(), 10159 locale: locale.to_string(), 10160 text_id, 10161 sample_text, 10162 duration_ticks, 10163 duration_seconds, 10164 chars, 10165 chars_per_second, 10166 estimated_lines, 10167 max_lines: settings.max_lines, 10168 high_reading_speed, 10169 overflow, 10170 missing_localization, 10171 settings, 10172 warnings: entry_warnings, 10173 } 10174 } 10175 10176 fn build_cine_locale_qa_report( 10177 timeline: &Value, 10178 timeline_path: &str, 10179 cfg: &CineLocaleQaConfig, 10180 ) -> CineLocaleQaReport { 10181 let ticks_per_second = qa_read_u32(timeline.get("ticks_per_second")) 10182 .unwrap_or(10) 10183 .max(1); 10184 let events = qa_collect_caption_events(timeline); 10185 let locale_maps = qa_extract_locale_maps(timeline); 10186 let mut entries = Vec::new(); 10187 let mut warnings = Vec::new(); 10188 let mut seen_warnings = HashSet::new(); 10189 10190 for event in &events { 10191 match (&event.text_id, locale_maps.as_ref()) { 10192 (Some(text_id), Some(maps)) if !maps.is_empty() => { 10193 for (locale, map) in maps { 10194 let localized = map.get(text_id).cloned(); 10195 let missing_localization = localized.is_none(); 10196 if missing_localization { 10197 qa_push_unique_warning( 10198 &mut warnings, 10199 &mut seen_warnings, 10200 format!( 10201 "cine.caption text_id '{}' missing locale '{}'", 10202 text_id, locale 10203 ), 10204 ); 10205 } 10206 let sample_text = 10207 localized.unwrap_or_else(|| event.text.clone().unwrap_or_default()); 10208 let settings = qa_caption_settings_for_locale(timeline, locale); 10209 let entry = qa_build_entry( 10210 event, 10211 locale, 10212 sample_text, 10213 CineLocaleQaEntryArgs { 10214 text_id: Some(text_id.clone()), 10215 missing_localization, 10216 settings, 10217 }, 10218 ticks_per_second, 10219 cfg, 10220 ); 10221 for warning in &entry.warnings { 10222 qa_push_unique_warning(&mut warnings, &mut seen_warnings, warning.clone()); 10223 } 10224 entries.push(entry); 10225 } 10226 } 10227 (Some(text_id), _) => { 10228 qa_push_unique_warning( 10229 &mut warnings, 10230 &mut seen_warnings, 10231 format!("cine.caption locale map missing for text_id '{}'", text_id), 10232 ); 10233 let sample_text = event.text.clone().unwrap_or_default(); 10234 let settings = qa_caption_settings_for_locale(timeline, &cfg.default_locale); 10235 let entry = qa_build_entry( 10236 event, 10237 "*", 10238 sample_text, 10239 CineLocaleQaEntryArgs { 10240 text_id: Some(text_id.clone()), 10241 missing_localization: true, 10242 settings, 10243 }, 10244 ticks_per_second, 10245 cfg, 10246 ); 10247 for warning in &entry.warnings { 10248 qa_push_unique_warning(&mut warnings, &mut seen_warnings, warning.clone()); 10249 } 10250 entries.push(entry); 10251 } 10252 (None, Some(maps)) if !maps.is_empty() => { 10253 for locale in maps.keys() { 10254 let sample_text = event.text.clone().unwrap_or_default(); 10255 let settings = qa_caption_settings_for_locale(timeline, locale); 10256 let entry = qa_build_entry( 10257 event, 10258 locale, 10259 sample_text, 10260 CineLocaleQaEntryArgs { 10261 text_id: None, 10262 missing_localization: false, 10263 settings, 10264 }, 10265 ticks_per_second, 10266 cfg, 10267 ); 10268 for warning in &entry.warnings { 10269 qa_push_unique_warning(&mut warnings, &mut seen_warnings, warning.clone()); 10270 } 10271 entries.push(entry); 10272 } 10273 } 10274 (None, _) => { 10275 let sample_text = event.text.clone().unwrap_or_default(); 10276 let settings = qa_caption_settings_for_locale(timeline, &cfg.default_locale); 10277 let entry = qa_build_entry( 10278 event, 10279 "*", 10280 sample_text, 10281 CineLocaleQaEntryArgs { 10282 text_id: None, 10283 missing_localization: false, 10284 settings, 10285 }, 10286 ticks_per_second, 10287 cfg, 10288 ); 10289 for warning in &entry.warnings { 10290 qa_push_unique_warning(&mut warnings, &mut seen_warnings, warning.clone()); 10291 } 10292 entries.push(entry); 10293 } 10294 } 10295 } 10296 10297 warnings.sort(); 10298 let mut locales: Vec<String> = entries.iter().map(|entry| entry.locale.clone()).collect(); 10299 locales.sort(); 10300 locales.dedup(); 10301 10302 let summary = CineLocaleQaSummary { 10303 events_scanned: events.len(), 10304 entries: entries.len(), 10305 high_reading_speed: entries 10306 .iter() 10307 .filter(|entry| entry.high_reading_speed) 10308 .count(), 10309 overflow_warnings: entries.iter().filter(|entry| entry.overflow).count(), 10310 missing_localizations: entries 10311 .iter() 10312 .filter(|entry| entry.missing_localization) 10313 .count(), 10314 }; 10315 10316 CineLocaleQaReport { 10317 version: CINE_LOCALE_QA_REPORT_VERSION, 10318 timeline: timeline_path.to_string(), 10319 ticks_per_second, 10320 locales, 10321 max_cps: cfg.max_cps, 10322 screen_width: cfg.screen_width, 10323 screen_height: cfg.screen_height, 10324 entries, 10325 warnings, 10326 summary, 10327 } 10328 } 10329 10330 struct CineFlags { 10331 timeline: Option<String>, 10332 pack: Option<String>, 10333 csv: Option<String>, 10334 out: Option<String>, 10335 choices: Option<String>, 10336 ticks: Option<u32>, 10337 snapshot_in: Option<String>, 10338 snapshot_out: Option<String>, 10339 } 10340 10341 fn print_cine_usage() { 10342 eprintln!( 10343 "Usage: dynostic_cli cine <validate|lint|run|dialogue-export|dialogue-import|locale-qa|save|resume> [options]\n\ 10344 validate --timeline PATH\n\ 10345 lint --timeline PATH [--pack PATH]\n\ 10346 run --timeline PATH [--out PATH] [--choices PATH]\n\ 10347 dialogue-export --timeline PATH [--out PATH]\n\ 10348 dialogue-import --timeline PATH --csv PATH [--out PATH]\n\ 10349 locale-qa --timeline PATH [--out PATH]\n\ 10350 save --timeline PATH --ticks N --out PATH [--choices PATH]\n\ 10351 resume --snapshot PATH [--out PATH]" 10352 ); 10353 } 10354 10355 fn parse_cine_flags(args: &[String]) -> Result<CineFlags, String> { 10356 let mut timeline: Option<String> = None; 10357 let mut pack: Option<String> = None; 10358 let mut csv: Option<String> = None; 10359 let mut out: Option<String> = None; 10360 let mut choices: Option<String> = None; 10361 let mut ticks: Option<u32> = None; 10362 let mut snapshot_in: Option<String> = None; 10363 let mut snapshot_out: Option<String> = None; 10364 let mut positional: Vec<String> = Vec::new(); 10365 let mut iter = args.iter().peekable(); 10366 while let Some(arg) = iter.next() { 10367 match arg.as_str() { 10368 "--timeline" | "--in" => { 10369 let value = iter 10370 .next() 10371 .ok_or_else(|| "Missing value for --timeline".to_string())?; 10372 timeline = Some(value.clone()); 10373 } 10374 "--pack" => { 10375 let value = iter 10376 .next() 10377 .ok_or_else(|| "Missing value for --pack".to_string())?; 10378 pack = Some(value.clone()); 10379 } 10380 "--csv" => { 10381 let value = iter 10382 .next() 10383 .ok_or_else(|| "Missing value for --csv".to_string())?; 10384 csv = Some(value.clone()); 10385 } 10386 "--choices" => { 10387 let value = iter 10388 .next() 10389 .ok_or_else(|| "Missing value for --choices".to_string())?; 10390 choices = Some(value.clone()); 10391 } 10392 "--ticks" => { 10393 let value = iter 10394 .next() 10395 .ok_or_else(|| "Missing value for --ticks".to_string())?; 10396 ticks = Some(parse_u32(value, "ticks")); 10397 } 10398 "--snapshot" | "--snapshot-in" => { 10399 let value = iter 10400 .next() 10401 .ok_or_else(|| "Missing value for --snapshot".to_string())?; 10402 snapshot_in = Some(value.clone()); 10403 } 10404 "--snapshot-out" => { 10405 let value = iter 10406 .next() 10407 .ok_or_else(|| "Missing value for --snapshot-out".to_string())?; 10408 snapshot_out = Some(value.clone()); 10409 } 10410 "--out" => { 10411 let value = iter 10412 .next() 10413 .ok_or_else(|| "Missing value for --out".to_string())?; 10414 out = Some(value.clone()); 10415 } 10416 _ if arg.starts_with("--timeline=") => { 10417 timeline = Some(arg.trim_start_matches("--timeline=").to_string()); 10418 } 10419 _ if arg.starts_with("--pack=") => { 10420 pack = Some(arg.trim_start_matches("--pack=").to_string()); 10421 } 10422 _ if arg.starts_with("--csv=") => { 10423 csv = Some(arg.trim_start_matches("--csv=").to_string()); 10424 } 10425 _ if arg.starts_with("--in=") => { 10426 timeline = Some(arg.trim_start_matches("--in=").to_string()); 10427 } 10428 _ if arg.starts_with("--choices=") => { 10429 choices = Some(arg.trim_start_matches("--choices=").to_string()); 10430 } 10431 _ if arg.starts_with("--ticks=") => { 10432 ticks = Some(parse_u32(arg.trim_start_matches("--ticks="), "ticks")); 10433 } 10434 _ if arg.starts_with("--snapshot=") => { 10435 snapshot_in = Some(arg.trim_start_matches("--snapshot=").to_string()); 10436 } 10437 _ if arg.starts_with("--snapshot-in=") => { 10438 snapshot_in = Some(arg.trim_start_matches("--snapshot-in=").to_string()); 10439 } 10440 _ if arg.starts_with("--snapshot-out=") => { 10441 snapshot_out = Some(arg.trim_start_matches("--snapshot-out=").to_string()); 10442 } 10443 _ if arg.starts_with("--out=") => { 10444 out = Some(arg.trim_start_matches("--out=").to_string()); 10445 } 10446 _ if arg.starts_with("--") => { 10447 return Err(format!("Unknown flag {}", arg)); 10448 } 10449 _ => { 10450 positional.push(arg.to_string()); 10451 } 10452 } 10453 } 10454 if timeline.is_none() && !positional.is_empty() { 10455 timeline = Some(positional.remove(0)); 10456 } 10457 Ok(CineFlags { 10458 timeline, 10459 pack, 10460 csv, 10461 out, 10462 choices, 10463 ticks, 10464 snapshot_in, 10465 snapshot_out, 10466 }) 10467 } 10468 10469 fn load_cine_choices(path: &str) -> Vec<usize> { 10470 let data = fs::read_to_string(path).unwrap_or_else(|err| { 10471 eprintln!("Failed to read choices {}: {}", path, err); 10472 std::process::exit(2); 10473 }); 10474 if let Ok(choices) = serde_json::from_str::<Vec<usize>>(&data) { 10475 return choices; 10476 } 10477 if let Ok(wrapper) = serde_json::from_str::<CineChoiceInputFile>(&data) { 10478 return wrapper.choices; 10479 } 10480 if let Ok(wrapper) = serde_json::from_str::<CineChoiceRecordFile>(&data) { 10481 return wrapper 10482 .choices 10483 .into_iter() 10484 .map(|record| record.choice_index.unwrap_or(0)) 10485 .collect(); 10486 } 10487 eprintln!("Failed to parse choices file {}", path); 10488 std::process::exit(2); 10489 } 10490 10491 fn handle_cine_command(args: Vec<String>) { 10492 if args.is_empty() { 10493 print_cine_usage(); 10494 std::process::exit(2); 10495 } 10496 let subcommand = &args[0]; 10497 let flags = match parse_cine_flags(&args[1..]) { 10498 Ok(flags) => flags, 10499 Err(err) => { 10500 eprintln!("{}", err); 10501 print_cine_usage(); 10502 std::process::exit(2); 10503 } 10504 }; 10505 10506 match subcommand.as_str() { 10507 "validate" => { 10508 let timeline_path = match flags.timeline.as_deref() { 10509 Some(path) => path, 10510 None => { 10511 eprintln!("Missing timeline path"); 10512 print_cine_usage(); 10513 std::process::exit(2); 10514 } 10515 }; 10516 let data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10517 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10518 std::process::exit(2); 10519 }); 10520 let timeline = CineTimeline::from_json(&data).unwrap_or_else(|err| { 10521 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10522 std::process::exit(2); 10523 }); 10524 let mut timeline = timeline; 10525 match timeline.validate() { 10526 Ok(()) => println!("OK: cine timeline validated: {}", timeline_path), 10527 Err(errors) => { 10528 for error in errors { 10529 eprintln!("{}", error); 10530 } 10531 std::process::exit(2); 10532 } 10533 } 10534 } 10535 "lint" => { 10536 let timeline_path = match flags.timeline.as_deref() { 10537 Some(path) => path, 10538 None => { 10539 eprintln!("Missing timeline path"); 10540 print_cine_usage(); 10541 std::process::exit(2); 10542 } 10543 }; 10544 let data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10545 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10546 std::process::exit(2); 10547 }); 10548 let timeline = CineTimeline::from_json(&data).unwrap_or_else(|err| { 10549 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10550 std::process::exit(2); 10551 }); 10552 let mut timeline = timeline; 10553 if let Err(errors) = timeline.validate() { 10554 for error in errors { 10555 eprintln!("{}", error); 10556 } 10557 std::process::exit(2); 10558 } 10559 10560 let mut warnings = Vec::new(); 10561 let mut lint_assets = None; 10562 if let Some(pack_path) = flags.pack.as_deref() { 10563 let ctx = build_pack_context(pack_path).unwrap_or_else(|err| { 10564 eprintln!("Failed to load pack {}: {}", pack_path, err); 10565 std::process::exit(2); 10566 }); 10567 warnings.extend( 10568 ctx.warnings 10569 .into_iter() 10570 .map(|warning| format!("pack: {}", warning)), 10571 ); 10572 lint_assets = Some(ctx.assets); 10573 } 10574 warnings.extend(timeline.lint(lint_assets.as_ref())); 10575 if warnings.is_empty() { 10576 println!("OK: cine lint clean: {}", timeline_path); 10577 } else { 10578 for warning in warnings { 10579 eprintln!("warning: {}", warning); 10580 } 10581 std::process::exit(2); 10582 } 10583 } 10584 "run" => { 10585 let timeline_path = match flags.timeline.as_deref() { 10586 Some(path) => path, 10587 None => { 10588 eprintln!("Missing timeline path"); 10589 print_cine_usage(); 10590 std::process::exit(2); 10591 } 10592 }; 10593 let data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10594 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10595 std::process::exit(2); 10596 }); 10597 let timeline = CineTimeline::from_json(&data).unwrap_or_else(|err| { 10598 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10599 std::process::exit(2); 10600 }); 10601 let mut player = CinePlayer::new(timeline).unwrap_or_else(|err| { 10602 eprintln!("cine run failed: {}", err.message); 10603 std::process::exit(2); 10604 }); 10605 if let Some(choices_path) = flags.choices.as_deref() { 10606 let choices = load_cine_choices(choices_path); 10607 player.set_choice_inputs(choices); 10608 } 10609 let report = player.run_to_end(); 10610 let output = CineRunOutput { 10611 ticks: report.ticks, 10612 event_hash: format_hash(report.event_hash), 10613 choices: report.choices, 10614 world: report.world, 10615 world_effects: report.world_effects, 10616 }; 10617 let encoded = serde_json::to_string_pretty(&output).unwrap_or_else(|err| { 10618 eprintln!("Failed to serialize cine report: {}", err); 10619 std::process::exit(2); 10620 }); 10621 if let Some(out_path) = flags.out { 10622 if let Err(err) = fs::write(&out_path, encoded) { 10623 eprintln!("Failed to write {}: {}", out_path, err); 10624 std::process::exit(2); 10625 } 10626 println!("OK: cine report written: {}", out_path); 10627 } else { 10628 println!("{}", encoded); 10629 } 10630 } 10631 "dialogue-export" => { 10632 let timeline_path = match flags.timeline.as_deref() { 10633 Some(path) => path, 10634 None => { 10635 eprintln!("Missing timeline path"); 10636 print_cine_usage(); 10637 std::process::exit(2); 10638 } 10639 }; 10640 let data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10641 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10642 std::process::exit(2); 10643 }); 10644 let timeline = CineTimeline::from_json(&data).unwrap_or_else(|err| { 10645 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10646 std::process::exit(2); 10647 }); 10648 let rows = collect_cine_dialogue_rows(&timeline); 10649 let csv_payload = render_cine_dialogue_csv(&rows); 10650 if let Some(out_path) = flags.out.as_deref() { 10651 if let Err(err) = fs::write(out_path, csv_payload) { 10652 eprintln!("Failed to write {}: {}", out_path, err); 10653 std::process::exit(2); 10654 } 10655 println!( 10656 "OK: cine dialogue CSV written: {} ({} lines)", 10657 out_path, 10658 rows.len() 10659 ); 10660 } else { 10661 print!("{}", csv_payload); 10662 } 10663 } 10664 "dialogue-import" => { 10665 let timeline_path = match flags.timeline.as_deref() { 10666 Some(path) => path, 10667 None => { 10668 eprintln!("Missing timeline path"); 10669 print_cine_usage(); 10670 std::process::exit(2); 10671 } 10672 }; 10673 let csv_path = match flags.csv.as_deref() { 10674 Some(path) => path, 10675 None => { 10676 eprintln!("Missing --csv"); 10677 print_cine_usage(); 10678 std::process::exit(2); 10679 } 10680 }; 10681 let out_path = flags.out.as_deref().unwrap_or(timeline_path); 10682 10683 let timeline_data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10684 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10685 std::process::exit(2); 10686 }); 10687 let mut timeline = CineTimeline::from_json(&timeline_data).unwrap_or_else(|err| { 10688 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10689 std::process::exit(2); 10690 }); 10691 let csv_data = fs::read_to_string(csv_path).unwrap_or_else(|err| { 10692 eprintln!("Failed to read CSV {}: {}", csv_path, err); 10693 std::process::exit(2); 10694 }); 10695 let updates = parse_cine_dialogue_csv(&csv_data).unwrap_or_else(|err| { 10696 eprintln!("Failed to parse CSV {}: {}", csv_path, err); 10697 std::process::exit(2); 10698 }); 10699 let applied = 10700 apply_cine_dialogue_csv_updates(&mut timeline, &updates).unwrap_or_else(|err| { 10701 eprintln!("cine dialogue import failed: {}", err); 10702 std::process::exit(2); 10703 }); 10704 let encoded = timeline.to_json().unwrap_or_else(|err| { 10705 eprintln!("Failed to serialize timeline {}: {}", timeline_path, err); 10706 std::process::exit(2); 10707 }); 10708 if let Err(err) = fs::write(out_path, encoded) { 10709 eprintln!("Failed to write {}: {}", out_path, err); 10710 std::process::exit(2); 10711 } 10712 println!( 10713 "OK: cine dialogue CSV imported: {} ({} lines)", 10714 out_path, applied 10715 ); 10716 } 10717 "locale-qa" => { 10718 let timeline_path = match flags.timeline.as_deref() { 10719 Some(path) => path, 10720 None => { 10721 eprintln!("Missing timeline path"); 10722 print_cine_usage(); 10723 std::process::exit(2); 10724 } 10725 }; 10726 let data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10727 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10728 std::process::exit(2); 10729 }); 10730 let timeline_value: Value = serde_json::from_str(&data).unwrap_or_else(|err| { 10731 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10732 std::process::exit(2); 10733 }); 10734 let report = build_cine_locale_qa_report( 10735 &timeline_value, 10736 timeline_path, 10737 &CineLocaleQaConfig::default(), 10738 ); 10739 let encoded = serde_json::to_string_pretty(&report).unwrap_or_else(|err| { 10740 eprintln!("Failed to serialize locale QA report: {}", err); 10741 std::process::exit(2); 10742 }); 10743 if let Some(out_path) = flags.out.as_deref() { 10744 if let Err(err) = fs::write(out_path, encoded) { 10745 eprintln!("Failed to write {}: {}", out_path, err); 10746 std::process::exit(2); 10747 } 10748 println!( 10749 "OK: cine locale QA report written: {} ({} entries)", 10750 out_path, 10751 report.entries.len() 10752 ); 10753 } else { 10754 println!("{}", encoded); 10755 } 10756 } 10757 "save" => { 10758 let timeline_path = match flags.timeline.as_deref() { 10759 Some(path) => path, 10760 None => { 10761 eprintln!("Missing timeline path"); 10762 print_cine_usage(); 10763 std::process::exit(2); 10764 } 10765 }; 10766 let ticks = match flags.ticks { 10767 Some(value) => value, 10768 None => { 10769 eprintln!("Missing --ticks"); 10770 print_cine_usage(); 10771 std::process::exit(2); 10772 } 10773 }; 10774 let out_path = match flags.out.as_deref().or(flags.snapshot_out.as_deref()) { 10775 Some(path) => path, 10776 None => { 10777 eprintln!("Missing --out"); 10778 print_cine_usage(); 10779 std::process::exit(2); 10780 } 10781 }; 10782 let data = fs::read_to_string(timeline_path).unwrap_or_else(|err| { 10783 eprintln!("Failed to read timeline {}: {}", timeline_path, err); 10784 std::process::exit(2); 10785 }); 10786 let timeline = CineTimeline::from_json(&data).unwrap_or_else(|err| { 10787 eprintln!("Failed to parse timeline {}: {}", timeline_path, err); 10788 std::process::exit(2); 10789 }); 10790 let mut player = CinePlayer::new(timeline).unwrap_or_else(|err| { 10791 eprintln!("cine save failed: {}", err.message); 10792 std::process::exit(2); 10793 }); 10794 if let Some(choices_path) = flags.choices.as_deref() { 10795 let choices = load_cine_choices(choices_path); 10796 player.set_choice_inputs(choices); 10797 } 10798 player.advance_ticks(ticks); 10799 let snapshot = player.save(); 10800 let encoded = snapshot.to_json().unwrap_or_else(|err| { 10801 eprintln!("Failed to serialize cine snapshot: {}", err); 10802 std::process::exit(2); 10803 }); 10804 if let Err(err) = fs::write(out_path, encoded) { 10805 eprintln!("Failed to write {}: {}", out_path, err); 10806 std::process::exit(2); 10807 } 10808 println!("OK: cine snapshot written: {}", out_path); 10809 } 10810 "resume" => { 10811 let snapshot_path = match flags.snapshot_in.as_deref() { 10812 Some(path) => path, 10813 None => { 10814 eprintln!("Missing --snapshot"); 10815 print_cine_usage(); 10816 std::process::exit(2); 10817 } 10818 }; 10819 let data = fs::read_to_string(snapshot_path).unwrap_or_else(|err| { 10820 eprintln!("Failed to read snapshot {}: {}", snapshot_path, err); 10821 std::process::exit(2); 10822 }); 10823 let snapshot = CineSaveState::from_json(&data).unwrap_or_else(|err| { 10824 eprintln!("Failed to parse snapshot {}: {}", snapshot_path, err); 10825 std::process::exit(2); 10826 }); 10827 let mut player = CinePlayer::from_save(snapshot).unwrap_or_else(|err| { 10828 eprintln!("cine resume failed: {}", err.message); 10829 std::process::exit(2); 10830 }); 10831 let report = player.run_to_end(); 10832 let output = CineRunOutput { 10833 ticks: report.ticks, 10834 event_hash: format_hash(report.event_hash), 10835 choices: report.choices, 10836 world: report.world, 10837 world_effects: report.world_effects, 10838 }; 10839 let encoded = serde_json::to_string_pretty(&output).unwrap_or_else(|err| { 10840 eprintln!("Failed to serialize cine report: {}", err); 10841 std::process::exit(2); 10842 }); 10843 if let Some(out_path) = flags.out { 10844 if let Err(err) = fs::write(&out_path, encoded) { 10845 eprintln!("Failed to write {}: {}", out_path, err); 10846 std::process::exit(2); 10847 } 10848 println!("OK: cine report written: {}", out_path); 10849 } else { 10850 println!("{}", encoded); 10851 } 10852 } 10853 _ => { 10854 eprintln!("Unknown cine subcommand: {}", subcommand); 10855 print_cine_usage(); 10856 std::process::exit(2); 10857 } 10858 } 10859 } 10860 10861 fn print_stats(stats: &dynostic_core::SimStats) { 10862 println!( 10863 "stats: step_calls={} ticks={} path_calls={} path_nodes={} los_checks={} ability_calls={} ability_tiles={} ability_entities={} ability_effects={} hazard_ticks={} status_ticks={} ai_plans={} ai_entities_planned={}", 10864 stats.step_calls, 10865 stats.ticks, 10866 stats.path_calls, 10867 stats.path_nodes, 10868 stats.los_checks, 10869 stats.ability_target_calls, 10870 stats.ability_tiles, 10871 stats.ability_entities, 10872 stats.ability_effects, 10873 stats.hazard_ticks, 10874 stats.status_ticks, 10875 stats.ai_plans, 10876 stats.ai_entities_planned 10877 ); 10878 } 10879 10880 fn demo_intents() -> Vec<dynostic_core::Intent> { 10881 vec![ 10882 dynostic_core::Intent::Attack { 10883 entity_id: 1, 10884 target_id: 3, 10885 }, 10886 dynostic_core::Intent::Attack { 10887 entity_id: 2, 10888 target_id: 4, 10889 }, 10890 dynostic_core::Intent::Attack { 10891 entity_id: 3, 10892 target_id: 1, 10893 }, 10894 dynostic_core::Intent::Attack { 10895 entity_id: 4, 10896 target_id: 2, 10897 }, 10898 ] 10899 } 10900 10901 fn main() { 10902 let mut raw_args: Vec<String> = std::env::args().skip(1).collect(); 10903 if raw_args.first().map(|value| value.as_str()) == Some("new") { 10904 raw_args.remove(0); 10905 handle_new_command(raw_args); 10906 return; 10907 } 10908 if raw_args.first().map(|value| value.as_str()) == Some("pack") { 10909 raw_args.remove(0); 10910 handle_pack_command(raw_args); 10911 return; 10912 } 10913 if raw_args.first().map(|value| value.as_str()) == Some("mod") { 10914 raw_args.remove(0); 10915 handle_mod_command(raw_args); 10916 return; 10917 } 10918 if raw_args.first().map(|value| value.as_str()) == Some("balance") 10919 || raw_args.first().map(|value| value.as_str()) == Some("sim-farm") 10920 { 10921 raw_args.remove(0); 10922 handle_balance_command(raw_args); 10923 return; 10924 } 10925 if raw_args.first().map(|value| value.as_str()) == Some("compat") { 10926 raw_args.remove(0); 10927 handle_compat_command(raw_args); 10928 return; 10929 } 10930 if raw_args.first().map(|value| value.as_str()) == Some("golden") { 10931 raw_args.remove(0); 10932 handle_golden_command(raw_args); 10933 return; 10934 } 10935 if raw_args.first().map(|value| value.as_str()) == Some("episode") { 10936 raw_args.remove(0); 10937 handle_episode_command(raw_args); 10938 return; 10939 } 10940 if raw_args.first().map(|value| value.as_str()) == Some("tutorial") { 10941 raw_args.remove(0); 10942 handle_tutorial_command(raw_args); 10943 return; 10944 } 10945 if raw_args.first().map(|value| value.as_str()) == Some("cine") { 10946 raw_args.remove(0); 10947 handle_cine_command(raw_args); 10948 return; 10949 } 10950 if raw_args.first().map(|value| value.as_str()) == Some("campaign") { 10951 raw_args.remove(0); 10952 handle_campaign_command(raw_args); 10953 return; 10954 } 10955 if raw_args.first().map(|value| value.as_str()) == Some("asset") { 10956 raw_args.remove(0); 10957 handle_asset_command(raw_args); 10958 return; 10959 } 10960 if raw_args.first().map(|value| value.as_str()) == Some("repro") { 10961 raw_args.remove(0); 10962 handle_repro_command(raw_args); 10963 return; 10964 } 10965 if raw_args.first().map(|value| value.as_str()) == Some("release") { 10966 raw_args.remove(0); 10967 handle_release_command(raw_args); 10968 return; 10969 } 10970 10971 let args = parse_args(); 10972 10973 if let Some(path) = args.validate_path.as_deref() { 10974 match validate_abilities(path) { 10975 Ok(()) => { 10976 println!("OK: abilities file validated: {}", path); 10977 return; 10978 } 10979 Err(err) => { 10980 eprintln!("Ability validation failed: {}", err); 10981 std::process::exit(2); 10982 } 10983 } 10984 } 10985 10986 if let Some(path) = args.validate_pack_path.as_deref() { 10987 match validate_pack(path) { 10988 Ok(()) => { 10989 println!("OK: pack validated: {}", path); 10990 return; 10991 } 10992 Err(err) => { 10993 eprintln!("Pack validation failed: {}", err); 10994 std::process::exit(2); 10995 } 10996 } 10997 } 10998 10999 if let Some(path) = args.validate_campaign_path.as_deref() { 11000 match validate_campaign(path) { 11001 Ok(()) => { 11002 println!("OK: campaign validated: {}", path); 11003 return; 11004 } 11005 Err(err) => { 11006 eprintln!("Campaign validation failed: {}", err); 11007 std::process::exit(2); 11008 } 11009 } 11010 } 11011 11012 if let Some(path) = args.campaign_path.as_deref() { 11013 let campaign = load_campaign(path).unwrap_or_else(|err| { 11014 eprintln!("Failed to load campaign: {}", err); 11015 std::process::exit(2); 11016 }); 11017 if let Err(errors) = campaign.validate() { 11018 eprintln!("Campaign validation failed: {}", errors.join("; ")); 11019 std::process::exit(2); 11020 } 11021 let state = if let Some(state_path) = args.campaign_state_in.as_deref() { 11022 let data = fs::read_to_string(state_path).unwrap_or_else(|err| { 11023 eprintln!("Failed to read campaign state: {}", err); 11024 std::process::exit(2); 11025 }); 11026 CampaignState::from_json(&data).unwrap_or_else(|err| { 11027 eprintln!("Failed to parse campaign state: {}", err); 11028 std::process::exit(2); 11029 }) 11030 } else { 11031 CampaignState { 11032 version: dynostic_core::CAMPAIGN_STATE_VERSION, 11033 roster: campaign.starting_roster.clone(), 11034 inventory: campaign.starting_inventory.clone(), 11035 flags: campaign.starting_flags.clone(), 11036 completed_nodes: Vec::new(), 11037 current_node: None, 11038 active_dialogue: None, 11039 mission_scars: BTreeMap::new(), 11040 } 11041 }; 11042 let mut campaign = Campaign::with_state(campaign, state); 11043 let run_steps = args.campaign_run.max(1); 11044 for _ in 0..run_steps { 11045 let node_id = match campaign.available_nodes().first() { 11046 Some(node) => node.id.clone(), 11047 None => break, 11048 }; 11049 if let Err(err) = campaign.select_node(&node_id) { 11050 eprintln!("Failed to select node: {}", err); 11051 std::process::exit(2); 11052 } 11053 if let Err(err) = campaign.complete_current_node() { 11054 eprintln!("Failed to complete node: {}", err); 11055 std::process::exit(2); 11056 } 11057 while campaign.state.active_dialogue.is_some() { 11058 if let Err(err) = campaign.choose_first_dialogue_option() { 11059 eprintln!("Dialogue error: {}", err); 11060 break; 11061 } 11062 } 11063 } 11064 if let Some(out_path) = args.campaign_state_out.as_deref() { 11065 let json = campaign.state.to_json().unwrap_or_else(|err| { 11066 eprintln!("Failed to serialize campaign state: {}", err); 11067 std::process::exit(2); 11068 }); 11069 fs::write(out_path, json).unwrap_or_else(|err| { 11070 eprintln!("Failed to write campaign state: {}", err); 11071 std::process::exit(2); 11072 }); 11073 } 11074 println!( 11075 "campaign nodes completed: {}", 11076 campaign.state.completed_nodes.len() 11077 ); 11078 return; 11079 } 11080 11081 if let Some(path) = args.replay_path.as_deref() { 11082 let data = fs::read_to_string(path).unwrap_or_else(|err| { 11083 eprintln!("Failed to read replay: {}", err); 11084 std::process::exit(2); 11085 }); 11086 let replay = Replay::from_json(&data).unwrap_or_else(|err| { 11087 eprintln!("Failed to parse replay: {}", err); 11088 std::process::exit(2); 11089 }); 11090 let report = replay.verify().unwrap_or_else(|err| { 11091 eprintln!("Replay failed: {}", err.message); 11092 std::process::exit(2); 11093 }); 11094 if report.ok { 11095 println!("Replay OK: {} turns", replay.turns.len()); 11096 } else { 11097 eprintln!("Replay mismatches: {}", report.mismatches.len()); 11098 for mismatch in report.mismatches { 11099 eprintln!( 11100 "turn {}: events {:x} -> {:x}, world {:x} -> {:x}, ticks {} -> {}", 11101 mismatch.turn_index, 11102 mismatch.expected_event_hash, 11103 mismatch.actual_event_hash, 11104 mismatch.expected_world_hash, 11105 mismatch.actual_world_hash, 11106 mismatch.expected_ticks, 11107 mismatch.actual_ticks 11108 ); 11109 } 11110 std::process::exit(2); 11111 } 11112 return; 11113 } 11114 11115 let mut sim = Engine::new(args.seed); 11116 if let Some(path) = args.pack_path.as_deref() { 11117 match build_pack_context(path) { 11118 Ok(ctx) => { 11119 for warning in &ctx.warnings { 11120 eprintln!("warning: {}", warning); 11121 } 11122 sim.set_abilities(ctx.abilities).unwrap_or_else(|errors| { 11123 eprintln!("Ability validation failed: {}", errors.join("; ")); 11124 std::process::exit(2); 11125 }); 11126 sim.set_reactions(ctx.reactions).unwrap_or_else(|errors| { 11127 eprintln!("Reaction validation failed: {}", errors.join("; ")); 11128 std::process::exit(2); 11129 }); 11130 if let Some(director) = ctx.director { 11131 sim.set_director_config(director).unwrap_or_else(|errors| { 11132 eprintln!("Director validation failed: {}", errors.join("; ")); 11133 std::process::exit(2); 11134 }); 11135 } 11136 if let Some(ai) = ctx.ai { 11137 sim.set_ai_behavior_config(ai).unwrap_or_else(|errors| { 11138 eprintln!("AI config validation failed: {}", errors.join("; ")); 11139 std::process::exit(2); 11140 }); 11141 } 11142 } 11143 Err(err) => { 11144 eprintln!("Failed to load pack: {}", err); 11145 std::process::exit(2); 11146 } 11147 } 11148 } else if let Some(path) = args.abilities_path.as_deref() { 11149 match load_abilities(path) { 11150 Ok(abilities) => { 11151 if let Err(errors) = sim.set_abilities(abilities) { 11152 eprintln!("Ability validation failed: {}", errors.join("; ")); 11153 std::process::exit(2); 11154 } 11155 } 11156 Err(err) => { 11157 eprintln!("Failed to load abilities: {}", err); 11158 std::process::exit(2); 11159 } 11160 } 11161 } 11162 11163 if let Some(path) = args.snapshot_in.as_deref() { 11164 let (world, _) = load_snapshot_world(path).unwrap_or_else(|err| { 11165 eprintln!("Failed to load snapshot: {}", err); 11166 std::process::exit(2); 11167 }); 11168 sim.set_world(world); 11169 } 11170 if let Some(metric_profile) = args.metric_profile { 11171 sim.set_metric_profile(metric_profile); 11172 } 11173 11174 if args.ai_enabled && args.record_path.is_none() { 11175 let mut config = sim.ai_config(); 11176 if let Some(value) = args.ai_aggression { 11177 config.aggression = value; 11178 } 11179 if let Some(value) = args.ai_risk { 11180 config.risk_tolerance = value; 11181 } 11182 if let Some(value) = args.ai_focus { 11183 config.focus_fire = value; 11184 } 11185 if let Some(value) = args.ai_vision { 11186 config.vision_range = value; 11187 } 11188 sim.set_ai_config(config); 11189 sim.auto_plan_ai(args.ai_team); 11190 } 11191 11192 if let Some(path) = args.record_path.as_deref() { 11193 let mut replay = Replay::new( 11194 args.seed, 11195 sim.world().metric_profile(), 11196 sim.world().abilities().clone(), 11197 sim.world().reactions().clone(), 11198 sim.world().director_config().clone(), 11199 sim.ai_behavior(), 11200 ); 11201 for _ in 0..args.turns { 11202 let intents = if args.ai_enabled { 11203 sim.clear_plans(); 11204 sim.auto_plan_ai(args.ai_team); 11205 let plans = sim.world().planned_intents().to_vec(); 11206 sim.clear_plans(); 11207 plans 11208 } else if args.no_plan { 11209 Vec::new() 11210 } else { 11211 demo_intents() 11212 }; 11213 if intents.is_empty() { 11214 eprintln!("No intents to record (use --ai or default plans)."); 11215 std::process::exit(2); 11216 } 11217 let turn = Replay::record_turn(&mut sim, intents).unwrap_or_else(|err| { 11218 eprintln!("Failed to record turn: {}", err.message); 11219 std::process::exit(2); 11220 }); 11221 replay.turns.push(turn); 11222 } 11223 let json = replay.to_json().unwrap_or_else(|err| { 11224 eprintln!("Failed to serialize replay: {}", err); 11225 std::process::exit(2); 11226 }); 11227 fs::write(path, json).unwrap_or_else(|err| { 11228 eprintln!("Failed to write replay: {}", err); 11229 std::process::exit(2); 11230 }); 11231 println!("Recorded replay: {}", path); 11232 if args.profile { 11233 print_stats(sim.stats()); 11234 } 11235 return; 11236 } 11237 11238 if !args.no_plan && !args.ai_enabled { 11239 for intent in demo_intents() { 11240 let _ = sim.apply_intent(intent); 11241 } 11242 let committed = sim.commit(); 11243 if committed == 0 { 11244 eprintln!("No valid intents committed; staying in Plan."); 11245 } 11246 } 11247 sim.step(args.ticks); 11248 11249 let (x, y) = sim.pos(); 11250 let phase_label = match sim.phase() { 11251 Phase::Plan => "plan", 11252 Phase::Commit => "commit", 11253 Phase::Execute => "execute", 11254 }; 11255 println!( 11256 "phase={} ticks={} entities={} pos=({},{}) events={}", 11257 phase_label, 11258 sim.tick(), 11259 sim.world().entities().len(), 11260 x, 11261 y, 11262 sim.events().len() 11263 ); 11264 if args.profile { 11265 print_stats(sim.stats()); 11266 } 11267 11268 if let Some(path) = args.snapshot_out.as_deref() { 11269 let snapshot = SnapshotEnvelope { 11270 version: SNAPSHOT_VERSION, 11271 engine: SemVer::current(), 11272 world: sim.world().clone(), 11273 }; 11274 let json = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|err| { 11275 eprintln!("Failed to serialize snapshot: {}", err); 11276 std::process::exit(2); 11277 }); 11278 fs::write(path, json).unwrap_or_else(|err| { 11279 eprintln!("Failed to write snapshot: {}", err); 11280 std::process::exit(2); 11281 }); 11282 } 11283 11284 if args.dump_json { 11285 println!("{}", sim.world().to_json().expect("serialize world")); 11286 } 11287 } 11288 11289 #[cfg(test)] 11290 mod tests { 11291 use super::*; 11292 11293 fn parse_timeline(data: &str) -> CineTimeline { 11294 CineTimeline::from_json(data).expect("valid timeline") 11295 } 11296 11297 fn parse_value(data: &str) -> Value { 11298 serde_json::from_str(data).expect("valid json") 11299 } 11300 11301 fn parse_episode_manifest(data: &str) -> EpisodeManifest { 11302 serde_json::from_str(data).expect("valid episode manifest") 11303 } 11304 11305 fn assert_contains(haystack: &[String], needle: &str) { 11306 assert!( 11307 haystack.iter().any(|entry| entry.contains(needle)), 11308 "expected error containing '{}', got {:?}", 11309 needle, 11310 haystack 11311 ); 11312 } 11313 11314 #[test] 11315 fn collects_dialogue_rows_from_main_and_sequences() { 11316 let timeline = parse_timeline( 11317 r#"{ 11318 "version": 1, 11319 "events": [ 11320 { "tick": 0, "op": "say", "text_id": "intro.hello", "voice": "voice_a", "duration": 4 }, 11321 { "tick": 1, "op": "spawn", "id": "hero", "x": 0, "y": 0 } 11322 ], 11323 "sequences": { 11324 "branch_a": { 11325 "events": [ 11326 { "tick": 2, "op": "say", "text_id": "branch.a", "voice": "voice_b", "duration": 6 } 11327 ] 11328 } 11329 } 11330 }"#, 11331 ); 11332 let rows = collect_cine_dialogue_rows(&timeline); 11333 assert_eq!(rows.len(), 2); 11334 assert_eq!(rows[0].location, CineDialogueLocation::Main(0)); 11335 assert_eq!(rows[0].tick, 0); 11336 assert_eq!(rows[0].text_id.as_deref(), Some("intro.hello")); 11337 assert_eq!( 11338 rows[1].location, 11339 CineDialogueLocation::Sequence { 11340 sequence_id: "branch_a".to_string(), 11341 event_index: 0, 11342 } 11343 ); 11344 assert_eq!(rows[1].tick, 2); 11345 } 11346 11347 #[test] 11348 fn renders_and_parses_dialogue_csv_round_trip() { 11349 let rows = vec![ 11350 CineDialogueCsvRow { 11351 location: CineDialogueLocation::Main(3), 11352 tick: 11, 11353 text_id: Some("line,\"quoted\"".to_string()), 11354 voice: Some("voice,a".to_string()), 11355 duration: 8, 11356 }, 11357 CineDialogueCsvRow { 11358 location: CineDialogueLocation::Sequence { 11359 sequence_id: "branch.one".to_string(), 11360 event_index: 2, 11361 }, 11362 tick: 17, 11363 text_id: None, 11364 voice: None, 11365 duration: 5, 11366 }, 11367 ]; 11368 let csv = render_cine_dialogue_csv(&rows); 11369 let parsed = parse_cine_dialogue_csv(&csv).expect("parse csv"); 11370 assert_eq!(parsed.len(), 2); 11371 assert_eq!( 11372 parsed[0].location, 11373 CineDialogueLocation::Main(3), 11374 "main row location" 11375 ); 11376 assert_eq!(parsed[0].text_id.as_deref(), Some("line,\"quoted\"")); 11377 assert_eq!(parsed[0].voice.as_deref(), Some("voice,a")); 11378 assert_eq!(parsed[0].duration, 8); 11379 assert_eq!( 11380 parsed[1].location, 11381 CineDialogueLocation::Sequence { 11382 sequence_id: "branch.one".to_string(), 11383 event_index: 2, 11384 } 11385 ); 11386 assert_eq!(parsed[1].text_id, None); 11387 assert_eq!(parsed[1].voice, None); 11388 assert_eq!(parsed[1].duration, 5); 11389 } 11390 11391 #[test] 11392 fn applies_dialogue_updates_to_timeline() { 11393 let mut timeline = parse_timeline( 11394 r#"{ 11395 "version": 1, 11396 "events": [ 11397 { "tick": 0, "op": "say", "text_id": "intro.hello", "voice": "voice_a", "duration": 4 } 11398 ], 11399 "sequences": { 11400 "branch_a": { 11401 "events": [ 11402 { "tick": 2, "op": "say", "text_id": "branch.a", "voice": "voice_b", "duration": 6 } 11403 ] 11404 } 11405 } 11406 }"#, 11407 ); 11408 11409 let updates = vec![ 11410 CineDialogueCsvUpdate { 11411 location: CineDialogueLocation::Main(0), 11412 text_id: Some("intro.updated".to_string()), 11413 voice: None, 11414 duration: 7, 11415 }, 11416 CineDialogueCsvUpdate { 11417 location: CineDialogueLocation::Sequence { 11418 sequence_id: "branch_a".to_string(), 11419 event_index: 0, 11420 }, 11421 text_id: Some("branch.updated".to_string()), 11422 voice: Some("voice_c".to_string()), 11423 duration: 9, 11424 }, 11425 ]; 11426 let applied = apply_cine_dialogue_csv_updates(&mut timeline, &updates).expect("apply"); 11427 assert_eq!(applied, 2); 11428 11429 if let CineOp::Say { 11430 text_id, 11431 voice, 11432 duration, 11433 .. 11434 } = &timeline.events[0].op 11435 { 11436 assert_eq!(text_id.as_deref(), Some("intro.updated")); 11437 assert_eq!(voice, &None); 11438 assert_eq!(*duration, 7); 11439 } else { 11440 panic!("expected say op in timeline.events[0]"); 11441 } 11442 if let CineOp::Say { 11443 text_id, 11444 voice, 11445 duration, 11446 .. 11447 } = &timeline.sequences["branch_a"].events[0].op 11448 { 11449 assert_eq!(text_id.as_deref(), Some("branch.updated")); 11450 assert_eq!(voice.as_deref(), Some("voice_c")); 11451 assert_eq!(*duration, 9); 11452 } else { 11453 panic!("expected say op in timeline.sequences.branch_a.events[0]"); 11454 } 11455 } 11456 11457 #[test] 11458 fn rejects_duplicate_dialogue_rows_in_csv() { 11459 let csv = "scope,sequence_id,event_index,tick,text_id,voice,duration\n\ 11460 main,,0,0,hello,voice_a,3\n\ 11461 main,,0,0,hello2,voice_b,5\n"; 11462 let err = parse_cine_dialogue_csv(csv).expect_err("duplicate should fail"); 11463 assert!(err.contains("duplicates dialogue row")); 11464 } 11465 11466 #[test] 11467 fn errors_when_csv_references_missing_dialogue() { 11468 let mut timeline = parse_timeline( 11469 r#"{ 11470 "version": 1, 11471 "events": [ 11472 { "tick": 0, "op": "spawn", "id": "hero", "x": 0, "y": 0 } 11473 ] 11474 }"#, 11475 ); 11476 let updates = vec![CineDialogueCsvUpdate { 11477 location: CineDialogueLocation::Main(0), 11478 text_id: Some("intro.updated".to_string()), 11479 voice: Some("voice".to_string()), 11480 duration: 4, 11481 }]; 11482 let err = apply_cine_dialogue_csv_updates(&mut timeline, &updates).expect_err("missing"); 11483 assert!(err.contains("missing dialogue lines")); 11484 } 11485 11486 #[test] 11487 fn locale_qa_report_applies_locale_caption_overrides() { 11488 let timeline = parse_value( 11489 r#"{ 11490 "version": 1, 11491 "ticks_per_second": 10, 11492 "captions": { 11493 "max_lines": 2, 11494 "safe_zone": { "w_pct": 70 }, 11495 "locales": { 11496 "ja": { 11497 "max_lines": 1, 11498 "safe_zone": { "w_pct": 35 } 11499 } 11500 } 11501 }, 11502 "locales": { 11503 "en": { "line.1": "Keep moving." }, 11504 "ja": { "line.1": "前に進み続けろ。" } 11505 }, 11506 "events": [ 11507 { "tick": 0, "op": "caption", "text_id": "line.1", "duration": 4 } 11508 ] 11509 }"#, 11510 ); 11511 let report = 11512 build_cine_locale_qa_report(&timeline, "timeline.json", &CineLocaleQaConfig::default()); 11513 assert_eq!(report.version, CINE_LOCALE_QA_REPORT_VERSION); 11514 assert_eq!(report.entries.len(), 2); 11515 let ja_entry = report 11516 .entries 11517 .iter() 11518 .find(|entry| entry.locale == "ja") 11519 .expect("ja locale entry"); 11520 assert_eq!(ja_entry.settings.max_lines, 1); 11521 assert!((ja_entry.settings.safe_zone.w_pct - 35.0).abs() < 0.001); 11522 } 11523 11524 #[test] 11525 fn locale_qa_report_flags_missing_locales_speed_and_overflow() { 11526 let timeline = parse_value( 11527 r#"{ 11528 "version": 1, 11529 "ticks_per_second": 10, 11530 "captions": { 11531 "max_lines": 1, 11532 "safe_zone": { "w_pct": 20 } 11533 }, 11534 "locales": { 11535 "en": { "line.ok": "Steady." }, 11536 "es": {} 11537 }, 11538 "events": [ 11539 { 11540 "tick": 2, 11541 "op": "caption", 11542 "text_id": "line.missing", 11543 "text": "A very long subtitle line that should be flagged for speed and overflow checks", 11544 "duration": 1 11545 } 11546 ] 11547 }"#, 11548 ); 11549 let config = CineLocaleQaConfig { 11550 max_cps: 20.0, 11551 screen_width: 640, 11552 screen_height: 360, 11553 char_width: 9.0, 11554 default_locale: "en".to_string(), 11555 }; 11556 let report = build_cine_locale_qa_report(&timeline, "timeline.json", &config); 11557 assert_eq!(report.summary.missing_localizations, 2); 11558 assert!(report.summary.high_reading_speed >= 1); 11559 assert!(report.summary.overflow_warnings >= 1); 11560 assert!(report 11561 .warnings 11562 .iter() 11563 .any(|warning| warning.contains("line.missing"))); 11564 assert!(report 11565 .warnings 11566 .iter() 11567 .any(|warning| warning.contains("locale 'es'"))); 11568 assert!(report 11569 .warnings 11570 .iter() 11571 .any(|warning| warning.contains("overflow risk"))); 11572 } 11573 11574 #[test] 11575 fn locale_qa_report_handles_timelines_without_locale_tables() { 11576 let timeline = parse_value( 11577 r#"{ 11578 "version": 1, 11579 "events": [ 11580 { "tick": 0, "op": "say", "text": "No locale map", "duration": 3 } 11581 ] 11582 }"#, 11583 ); 11584 let report = 11585 build_cine_locale_qa_report(&timeline, "timeline.json", &CineLocaleQaConfig::default()); 11586 assert_eq!(report.entries.len(), 1); 11587 assert_eq!(report.entries[0].locale, "*"); 11588 assert!(!report.entries[0].missing_localization); 11589 } 11590 11591 #[test] 11592 fn episode_manifest_validation_accepts_minimal_manifest() { 11593 let manifest = parse_episode_manifest( 11594 r#"{ 11595 "version": 1, 11596 "id": "arc_one", 11597 "name": "Arc One", 11598 "episodes": [ 11599 { "id": "ep1", "name": "Episode 1" } 11600 ] 11601 }"#, 11602 ); 11603 let errors = validate_episode_manifest(&manifest); 11604 assert!(errors.is_empty(), "unexpected errors: {:?}", errors); 11605 } 11606 11607 #[test] 11608 fn episode_manifest_validation_reports_duplicates_and_missing_refs() { 11609 let manifest = parse_episode_manifest( 11610 r#"{ 11611 "version": 1, 11612 "id": "arc", 11613 "name": "Arc", 11614 "episodes": [ 11615 { "id": "ep1", "name": "Episode 1", "requires_episodes": ["missing"] }, 11616 { "id": "ep1", "name": "", "timeline": "" } 11617 ] 11618 }"#, 11619 ); 11620 let errors = validate_episode_manifest(&manifest); 11621 assert_contains(&errors, "episodes[1].id duplicated"); 11622 assert_contains(&errors, "episodes[1].name is empty"); 11623 assert_contains(&errors, "episodes[1].timeline is empty"); 11624 assert_contains( 11625 &errors, 11626 "requires_episodes references missing episode missing", 11627 ); 11628 } 11629 11630 #[test] 11631 fn episode_manifest_validation_enforces_ordered_flow_and_unlock_rule_dedupes() { 11632 let manifest = parse_episode_manifest( 11633 r#"{ 11634 "version": 1, 11635 "id": "arc", 11636 "name": "Arc", 11637 "episodes": [ 11638 { "id": "ep1", "name": "Episode 1", "requires_episodes": ["ep2"] }, 11639 { 11640 "id": "ep2", 11641 "name": "Episode 2", 11642 "requires_episodes": ["ep1", "ep1"], 11643 "requires_flags": ["cleared_intro", "cleared_intro"] 11644 } 11645 ] 11646 }"#, 11647 ); 11648 let errors = validate_episode_manifest(&manifest); 11649 assert_contains(&errors, "must reference an earlier episode in ordered flow"); 11650 assert_contains(&errors, ".requires_episodes[1] duplicated (ep1)"); 11651 assert_contains(&errors, ".requires_flags[1] duplicated (cleared_intro)"); 11652 assert_contains(&errors, "episode manifest flow contains a dependency cycle"); 11653 } 11654 11655 #[test] 11656 fn gameplay_projection_policy_accepts_isometric_projection_fields() { 11657 let map_a = parse_value(r#"{ "projection": "isometric" }"#); 11658 let map_b = parse_value(r#"{ "projection": { "gameplay": "isometric" } }"#); 11659 let map_c = parse_value(r#"{ "projection": { "mode": "isometric" } }"#); 11660 let map_d = parse_value(r#"{ "gameplay_projection": "isometric" }"#); 11661 assert_eq!(gameplay_projection_policy_violation(&map_a), Ok(None)); 11662 assert_eq!(gameplay_projection_policy_violation(&map_b), Ok(None)); 11663 assert_eq!(gameplay_projection_policy_violation(&map_c), Ok(None)); 11664 assert_eq!(gameplay_projection_policy_violation(&map_d), Ok(None)); 11665 } 11666 11667 #[test] 11668 fn gameplay_projection_policy_rejects_non_isometric_projection() { 11669 let map = parse_value(r#"{ "projection": "orthographic" }"#); 11670 let violation = 11671 gameplay_projection_policy_violation(&map).expect("projection policy parse"); 11672 assert_eq!( 11673 violation, 11674 Some("gameplay projection must be isometric (found orthographic)".to_string()) 11675 ); 11676 } 11677 11678 #[test] 11679 fn gameplay_projection_policy_reports_invalid_projection_metadata() { 11680 let map = parse_value(r#"{ "projection": true }"#); 11681 let err = gameplay_projection_policy_violation(&map).expect_err("projection metadata"); 11682 assert_eq!(err, "map.projection must be a string or object"); 11683 } 11684 }