/ rust / dynostic_cli / src / main.rs
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(&registry_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(&registry_path, &registry) {
 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  }