# Data Model Plan

The prototype now uses a JSON catalog that hydrates compact scenario deployments into the same runtime unit dictionaries `BattleState` already understands. Legacy inline `units` remain supported for quick experiments.

## Files

```text
data/defs/officers.json
data/defs/classes.json
data/defs/terrain.json
data/defs/items.json
data/defs/skills.json
data/campaign/campaign.json
data/scenarios/001_yellow_turbans.json
data/scenarios/002_sishui_gate.json
data/scenarios/003_xingyang_ambush.json
data/scenarios/004_qingzhou_campaign.json
data/scenarios/005_puyang_raid.json
data/scenarios/006_dingtao_counterattack.json
data/scenarios/007_xian_emperor_escort.json
data/scenarios/008_wan_castle_escape.json
data/scenarios/009_xiapi_siege.json
data/scenarios/010_white_horse_relief.json
data/scenarios/011_yan_ford_pursuit.json
data/scenarios/012_guandu_showdown.json
data/scenarios/013_wuchao_raid.json
data/scenarios/014_cangting_pursuit.json
data/scenarios/015_ye_campaign.json
data/scenarios/016_ye_siege.json
data/scenarios/017_ye_surrender.json
data/scenarios/018_northern_pursuit.json
data/scenarios/019_nanpi_pressure.json
data/scenarios/020_nanpi_surrender.json
data/scenarios/021_bohai_remnants.json
data/scenarios/022_liaoxi_pursuit.json
data/scenarios/023_white_wolf_mountain.json
data/scenarios/024_liaodong_pursuit.json
data/scenarios/025_xinye_advance.json
data/scenarios/026_changban_pursuit.json
data/scenarios/027_jiangling_chase.json
data/scenarios/028_xiakou_pursuit.json
data/scenarios/029_red_cliffs_fleet.json
data/scenarios/030_red_cliffs_fire.json
data/scenarios/031_huarong_retreat.json
data/scenarios/032_jiangling_rearguard.json
data/scenarios/033_tong_pass_vanguard.json
data/scenarios/034_ma_chao_counterstroke.json
data/scenarios/035_weishui_camps.json
data/scenarios/036_weishui_crossing.json
data/scenarios/037_guanzhong_foothold.json
data/scenarios/038_ma_chao_retreat.json
data/scenarios/039_hanzhong_approach.json
data/scenarios/040_hanzhong_gate.json
data/scenarios/041_hanzhong_settlement.json
data/scenarios/042_ruxu_advance.json
data/scenarios/043_ruxu_river_line.json
data/scenarios/044_ruxu_crossing.json
data/scenarios/045_fancheng_relief.json
data/scenarios/046_jingzhou_supply_line.json
data/scenarios/047_maicheng_pursuit.json
data/scenarios/048_jing_province_settlement.json
data/scenarios/049_luoyang_summons.json
data/scenarios/050_wei_succession.json
data/scenarios/051_eastern_front_alarm.json
data/scenarios/052_wu_river_line.json
data/scenarios/053_jianye_approach.json
data/scenarios/054_wu_counterstroke.json
data/scenarios/055_jianye_gate.json
data/scenarios/056_jianye_sortie.json
data/scenarios/057_inner_jianye.json
data/scenarios/058_jianye_command.json
data/scenarios/059_wu_surrender.json
data/scenarios/060_southern_holdouts.json
data/scenarios/061_nanhai_route.json
data/scenarios/062_southern_fleet.json
data/scenarios/063_hainan_coast.json
data/scenarios/064_final_harbor.json
data/scenarios/065_last_captains.json
data/scenarios/066_sea_exiles.json
data/scenarios/067_outer_islands.json
```

## Campaign

The campaign file defines chapter ranges, scenario order, resource paths, and officers who are available at the start of a new campaign.

```json
{
  "id": "cao_cao_campaign",
  "title": "Cao Cao Campaign",
  "start_scenario": "001_yellow_turbans",
  "initial_joined_officers": ["cao_cao", "xiahou_dun"],
  "chapters": [
    {
      "id": "rise_through_turmoil",
      "title": "Rise Through Turmoil",
      "start_scenario": "001_yellow_turbans",
      "end_scenario": "009_xiapi_siege"
    }
  ],
  "scenarios": [
    { "id": "001_yellow_turbans", "title": "Yingchuan Skirmish", "path": "res://data/scenarios/001_yellow_turbans.json" },
    { "id": "002_sishui_gate", "title": "Sishui Gate Vanguard", "path": "res://data/scenarios/002_sishui_gate.json" },
    { "id": "003_xingyang_ambush", "title": "Xingyang Ambush", "path": "res://data/scenarios/003_xingyang_ambush.json" },
    { "id": "004_qingzhou_campaign", "title": "Qingzhou Campaign", "path": "res://data/scenarios/004_qingzhou_campaign.json" },
    { "id": "005_puyang_raid", "title": "Puyang Raid", "path": "res://data/scenarios/005_puyang_raid.json" },
    { "id": "006_dingtao_counterattack", "title": "Dingtao Counterattack", "path": "res://data/scenarios/006_dingtao_counterattack.json" },
    { "id": "007_xian_emperor_escort", "title": "Xian Emperor Escort", "path": "res://data/scenarios/007_xian_emperor_escort.json" },
    { "id": "008_wan_castle_escape", "title": "Wan Castle Escape", "path": "res://data/scenarios/008_wan_castle_escape.json" },
    { "id": "009_xiapi_siege", "title": "Xiapi Siege", "path": "res://data/scenarios/009_xiapi_siege.json" },
    { "id": "010_white_horse_relief", "title": "White Horse Relief", "path": "res://data/scenarios/010_white_horse_relief.json" },
    { "id": "011_yan_ford_pursuit", "title": "Yan Ford Pursuit", "path": "res://data/scenarios/011_yan_ford_pursuit.json" },
    { "id": "012_guandu_showdown", "title": "Guandu Showdown", "path": "res://data/scenarios/012_guandu_showdown.json" },
    { "id": "013_wuchao_raid", "title": "Wuchao Raid", "path": "res://data/scenarios/013_wuchao_raid.json" },
    { "id": "014_cangting_pursuit", "title": "Cangting Pursuit", "path": "res://data/scenarios/014_cangting_pursuit.json" },
    { "id": "015_ye_campaign", "title": "Ye Campaign", "path": "res://data/scenarios/015_ye_campaign.json" },
    { "id": "016_ye_siege", "title": "Ye Siege", "path": "res://data/scenarios/016_ye_siege.json" },
    { "id": "017_ye_surrender", "title": "Ye Surrender", "path": "res://data/scenarios/017_ye_surrender.json" },
    { "id": "018_northern_pursuit", "title": "Northern Pursuit", "path": "res://data/scenarios/018_northern_pursuit.json" },
    { "id": "019_nanpi_pressure", "title": "Nanpi Pressure", "path": "res://data/scenarios/019_nanpi_pressure.json" },
    { "id": "020_nanpi_surrender", "title": "Nanpi Surrender", "path": "res://data/scenarios/020_nanpi_surrender.json" },
    { "id": "021_bohai_remnants", "title": "Bohai Remnants", "path": "res://data/scenarios/021_bohai_remnants.json" },
    { "id": "022_liaoxi_pursuit", "title": "Liaoxi Pursuit", "path": "res://data/scenarios/022_liaoxi_pursuit.json" },
    { "id": "023_white_wolf_mountain", "title": "White Wolf Mountain", "path": "res://data/scenarios/023_white_wolf_mountain.json" },
    { "id": "024_liaodong_pursuit", "title": "Liaodong Pursuit", "path": "res://data/scenarios/024_liaodong_pursuit.json" },
    { "id": "025_xinye_advance", "title": "Xinye Advance", "path": "res://data/scenarios/025_xinye_advance.json" },
    { "id": "026_changban_pursuit", "title": "Changban Pursuit", "path": "res://data/scenarios/026_changban_pursuit.json" },
    { "id": "027_jiangling_chase", "title": "Jiangling Chase", "path": "res://data/scenarios/027_jiangling_chase.json" },
    { "id": "028_xiakou_pursuit", "title": "Xiakou Pursuit", "path": "res://data/scenarios/028_xiakou_pursuit.json" },
    { "id": "029_red_cliffs_fleet", "title": "Red Cliffs Fleet", "path": "res://data/scenarios/029_red_cliffs_fleet.json" },
    { "id": "030_red_cliffs_fire", "title": "Red Cliffs Fire", "path": "res://data/scenarios/030_red_cliffs_fire.json" },
    { "id": "031_huarong_retreat", "title": "Huarong Retreat", "path": "res://data/scenarios/031_huarong_retreat.json" },
    { "id": "032_jiangling_rearguard", "title": "Jiangling Rearguard", "path": "res://data/scenarios/032_jiangling_rearguard.json" },
    { "id": "033_tong_pass_vanguard", "title": "Tong Pass Vanguard", "path": "res://data/scenarios/033_tong_pass_vanguard.json" },
    { "id": "034_ma_chao_counterstroke", "title": "Ma Chao Counterstroke", "path": "res://data/scenarios/034_ma_chao_counterstroke.json" },
    { "id": "035_weishui_camps", "title": "Weishui Camps", "path": "res://data/scenarios/035_weishui_camps.json" },
    { "id": "036_weishui_crossing", "title": "Weishui Crossing", "path": "res://data/scenarios/036_weishui_crossing.json" },
    { "id": "037_guanzhong_foothold", "title": "Guanzhong Foothold", "path": "res://data/scenarios/037_guanzhong_foothold.json" },
    { "id": "038_ma_chao_retreat", "title": "Ma Chao Retreat", "path": "res://data/scenarios/038_ma_chao_retreat.json" },
    { "id": "039_hanzhong_approach", "title": "Hanzhong Approach", "path": "res://data/scenarios/039_hanzhong_approach.json" },
    { "id": "040_hanzhong_gate", "title": "Hanzhong Gate", "path": "res://data/scenarios/040_hanzhong_gate.json" },
    { "id": "041_hanzhong_settlement", "title": "Hanzhong Settlement", "path": "res://data/scenarios/041_hanzhong_settlement.json" },
    { "id": "042_ruxu_advance", "title": "Ruxu Advance", "path": "res://data/scenarios/042_ruxu_advance.json" },
    { "id": "043_ruxu_river_line", "title": "Ruxu River Line", "path": "res://data/scenarios/043_ruxu_river_line.json" },
    { "id": "044_ruxu_crossing", "title": "Ruxu Crossing", "path": "res://data/scenarios/044_ruxu_crossing.json" },
    { "id": "045_fancheng_relief", "title": "Fancheng Relief", "path": "res://data/scenarios/045_fancheng_relief.json" },
    { "id": "046_jingzhou_supply_line", "title": "Jingzhou Supply Line", "path": "res://data/scenarios/046_jingzhou_supply_line.json" },
    { "id": "047_maicheng_pursuit", "title": "Maicheng Pursuit", "path": "res://data/scenarios/047_maicheng_pursuit.json" },
    { "id": "048_jing_province_settlement", "title": "Jing Province Settlement", "path": "res://data/scenarios/048_jing_province_settlement.json" },
    { "id": "049_luoyang_summons", "title": "Luoyang Summons", "path": "res://data/scenarios/049_luoyang_summons.json" },
    { "id": "050_wei_succession", "title": "Wei Succession", "path": "res://data/scenarios/050_wei_succession.json" },
    { "id": "051_eastern_front_alarm", "title": "Eastern Front Alarm", "path": "res://data/scenarios/051_eastern_front_alarm.json" },
    { "id": "052_wu_river_line", "title": "Wu River Line", "path": "res://data/scenarios/052_wu_river_line.json" },
    { "id": "053_jianye_approach", "title": "Jianye Approach", "path": "res://data/scenarios/053_jianye_approach.json" },
    { "id": "054_wu_counterstroke", "title": "Wu Counterstroke", "path": "res://data/scenarios/054_wu_counterstroke.json" },
    { "id": "055_jianye_gate", "title": "Jianye Gate", "path": "res://data/scenarios/055_jianye_gate.json" },
    { "id": "056_jianye_sortie", "title": "Jianye Sortie", "path": "res://data/scenarios/056_jianye_sortie.json" },
    { "id": "057_inner_jianye", "title": "Inner Jianye", "path": "res://data/scenarios/057_inner_jianye.json" },
    { "id": "058_jianye_command", "title": "Jianye Command", "path": "res://data/scenarios/058_jianye_command.json" },
    { "id": "059_wu_surrender", "title": "Wu Surrender", "path": "res://data/scenarios/059_wu_surrender.json" },
    { "id": "060_southern_holdouts", "title": "Southern Holdouts", "path": "res://data/scenarios/060_southern_holdouts.json" },
    { "id": "061_nanhai_route", "title": "Nanhai Route", "path": "res://data/scenarios/061_nanhai_route.json" },
    { "id": "062_southern_fleet", "title": "Southern Fleet", "path": "res://data/scenarios/062_southern_fleet.json" },
    { "id": "063_hainan_coast", "title": "Hainan Coast", "path": "res://data/scenarios/063_hainan_coast.json" },
    { "id": "064_final_harbor", "title": "Final Harbor", "path": "res://data/scenarios/064_final_harbor.json" },
    { "id": "065_last_captains", "title": "Last Captains", "path": "res://data/scenarios/065_last_captains.json" },
    { "id": "066_sea_exiles", "title": "Sea Exiles", "path": "res://data/scenarios/066_sea_exiles.json" },
    { "id": "067_outer_islands", "title": "Outer Islands", "path": "res://data/scenarios/067_outer_islands.json" }
  ]
}
```

`chapters` are display metadata for campaign presentation. Each chapter has a stable lowercase `id`, non-empty `title`, and inclusive `start_scenario`/`end_scenario` ids from the linear scenario order. When chapters are present, the validator requires the ranges to cover every campaign scenario exactly once, without overlaps or gaps. `CampaignState` uses them for briefing headers, chapter-local battle numbering, and pre-battle chapter progress/replay overview entries; save files still store scenario ids, not chapter ids.

## Officers

Officers hold identity, personal base stats, initial class, default portrait path, and starting equipment. `DataCatalog` copies the portrait path into hydrated runtime units so dialogue and battle HUD presentation can share the same asset.

```json
{
  "cao_cao": {
    "name": "Cao Cao",
    "faction": "wei",
    "class_id": "hero",
    "portrait": "res://art/portraits/cao_cao.png",
    "level": 1,
    "exp": 0,
    "base": { "hp": 38, "mp": 10, "atk": 13, "def": 8, "int": 12, "agi": 8 },
    "growth_bonus": { "atk": 1, "int": 1 },
    "equipment": { "weapon": "bronze_sword", "armor": "cloth_robe", "accessory": null }
  }
}
```

## Classes

Classes provide movement, min/max attack range, skill access, growth grades, class bonuses, equipment permissions, and promotion routes.

```json
{
  "cavalry": {
    "name": "Cavalry",
    "sprite": "res://art/units/cavalry.png",
    "tier": 1,
    "move_type": "mounted",
    "move": 5,
    "attack_range": [1, 1],
    "skills": [],
    "growth": { "hp": "A", "mp": "D", "atk": "A", "def": "B", "int": "D", "agi": "B" },
    "base_bonus": { "hp": 4, "atk": 2, "def": 0 },
    "equipment_slots": {
      "weapon": ["spear", "sword"],
      "armor": ["light_armor", "heavy_armor"],
      "accessory": ["accessory"]
    },
    "promotion": { "level": 15, "to": "elite_cavalry" }
  }
}
```

`attack_range` is `[min, max]` in Manhattan distance. A range of `[2, 2]` means the unit cannot attack adjacent targets.
`sprite` is an optional class-level board token image used by hydrated units unless an officer or deployment overrides it. Base class sprites are transparent PNG cutouts under `res://art/units/` so board tokens can show only the character over the terrain and token backplate. `enemy_sprite` is an optional class-level image for generic enemy units with `team: "enemy"` and no `officer_id`; runtime sprite precedence is deployment `sprite`, officer `sprite`, generic enemy class `enemy_sprite`, then class `sprite`. The current enemy class art is stored as transparent PNG cutouts under `res://art/units/enemies/` so named officers and player units can keep their cleaner class or officer presentation while ordinary troops read as their own force.

`promotion.level` and `promotion.to` define an automatic class promotion route. When a unit reaches the threshold during battle, the runtime updates class name, movement, movement type, growth, class skills, attack range, and class base-bonus deltas. The promoted `class_id` and derived roster fields are captured in the campaign roster snapshot, and the next battle rehydrates class-derived fields from the saved `class_id`. Promotion targets must be higher-tier classes and must keep all source equipment slot permissions unless runtime auto-unequip logic is added later. The current core routes cover hero, cavalry, infantry, archer, warrior, and strategist first-tier classes.

`warrior` is the first player-facing axe class. It gives Dian Wei a heavy melee role without requiring unique weapon-instance logic yet.

`strategist` is the first MP-focused enemy-capable class. It uses the same `skills` list as player units, so scenario designers can add tactical pressure without adding new runtime unit fields.

## Skills

Skills define MP tactics. A class, officer, or scenario deployment can add skill ids to the runtime unit. `range` is `[min, max]` in Manhattan distance and can use `0` as the minimum for self-targeting support skills. Skill `kind` can be `damage`, `heal`, or `support`. Enemy AI can use damage, healing, support, debuff, damage-over-time, and action-lock skills when it has enough MP.

```json
{
  "fire_tactic": {
    "name": "Fire",
    "kind": "damage",
    "target": "enemy",
    "mp_cost": 4,
    "range": [1, 3],
    "power": 8,
    "stat": "int"
  }
}
```

Support skills use an `effects` array. `stat_bonus` uses `stat` in `atk`, `def`, `int`, `agi`, or `move`, a non-zero `amount`, and positive `duration_turns`. Positive amounts are buffs and negative amounts are debuffs; both are battle-only and expire when the target's team begins enough future phases. Move bonuses alter the affected unit's pathfinding range while active, and AGI bonuses or penalties alter physical hit and avoid forecasts. `damage_over_time` uses a stable `status`, positive `amount`, and positive `duration_turns`; it ticks at the start of the target team's phase before expiration and can defeat units without awarding delayed defeat EXP. `action_lock` uses a stable `status`, an `action` of `skill`, `move`, or `attack`, and positive `duration_turns`; it blocks the affected unit from selecting or casting tactics, moving, or making physical attacks and counterattacks while active.

Skills may define an area of effect with `area_shape` and `area_radius`. Omitting these fields is the same as `area_shape: "single"` and `area_radius: 0`. The first supported non-single shape is `diamond`, where `range` selects the center cell and `area_radius` applies the skill to valid targets within Manhattan distance from that center. Area skills spend MP once, grant one base skill/heal/support EXP award per cast, apply defeat bonuses per defeated target, and ignore units that do not match the skill target rule. Healing areas can be centered on an empty cell as long as at least one valid injured ally is inside the area.

```json
{
  "disarm_tactic": {
    "name": "Disarm Tactic",
    "kind": "support",
    "target": "enemy",
    "mp_cost": 5,
    "range": [1, 3],
    "power": 0,
    "stat": "int",
    "effects": [
      { "type": "action_lock", "status": "disarm", "action": "attack", "duration_turns": 2 }
    ]
  }
}
```

```json
{
  "blaze": {
    "name": "Blaze",
    "kind": "damage",
    "target": "enemy",
    "mp_cost": 7,
    "range": [1, 3],
    "area_shape": "diamond",
    "area_radius": 1,
    "power": 13,
    "stat": "int"
  }
}
```

The current tactic set includes `spark`, `fire_tactic`, area `blaze`, `mend`, area `great_mend`, `guard_order`, `rally_order`, `march_order`, `focus_order`, `disrupt_order`, `hamper_order`, `feint_order`, `disarm_tactic`, area `poison_mist`, `seal_tactic`, and `snare_tactic`. Hero classes get low-cost Spark alongside Fire/Mend and ally and enemy orders, strategist classes also get Disarm Tactic, Poison Mist, Seal Tactic, and Snare Tactic, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class.

## Terrain

Terrain keeps compact map characters but moves costs and bonuses out of code. `defense` reduces physical and damage-tactic damage taken on that tile, while `avoid` reduces physical attack hit chance. Per-move-type costs can be added before pathfinding becomes more complex.

```json
{
  "G": {
    "id": "plain",
    "name": "Plain",
    "color": "#6d9e4f",
    "defense": 0,
    "avoid": 0,
    "move_cost": { "foot": 1, "mounted": 1, "archer": 1, "water": 99 }
  }
}
```

Physical hit chance is `90 + attacker AGI - target AGI - terrain avoid`, clamped to 25-100. Skill damage currently uses terrain defense but does not roll against terrain avoid.

## Items

Items start as static bonuses and consumable effects. Equipment leveling should later live on save-game item instances, not base item definitions. Consumables and unequipped weapon, armor, and accessory rewards are stored as campaign inventory counts and copied into the battle state when a scenario loads. Equipment can optionally set `rarity: "named"` to mark story-tier rewards while still using the same stackable inventory model as other equipment.

```json
{
  "bronze_sword": {
    "name": "Bronze Sword",
    "kind": "weapon",
    "icon": "res://art/items/sword.png",
    "weapon_type": "sword",
    "range": [1, 1],
    "bonuses": { "atk": 3 },
    "effects": [],
    "price": 300
  }
}
```

Weapon `range` also uses `[min, max]`. When class and equipment both provide range, the runtime unit keeps the lowest minimum and highest maximum. Weapons can optionally define `effective_vs_move_types` plus `effective_bonus_damage` to add a small physical damage bonus during combat against targets with a matching runtime `move_type`; this is shown in attack forecasts, combat logs, shop stock, and equipment menus, but it does not mutate saved unit stats. The first examples are spear vs mounted, bow vs water, and axe vs foot. Accessories use `kind: "accessory"` with an `accessory_type` checked against the class `equipment_slots.accessory` list; `war_drum` and `imperial_seal` are the first branch-choice accessory rewards. Current equipment inventory is stackable stock only; unique equipment instances, durability, and enhancement are planned later. `war_axe` is a stronger axe reward for the Warrior/Bandit weapon family. The first named rewards are `yitian_sword`, `dragon_spear`, `wind_chaser_bow`, and `black_tortoise_robe`; their `rarity` tag is presentation-only and does not enforce uniqueness.
`icon` is an optional item UI image shown in shop, item, and equipment buttons when present. Current item icons are transparent PNG cutouts under `res://art/items/`, and validation checks their alpha corners and sampled transparent background so inventory buttons stay clean over UI surfaces.

Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, `heal_mp` restores MP, `cure_status` removes active battle-only effects with a matching `status` from a valid target, and `cleanse_debuffs` removes only negative `stat_bonus` effects while leaving positive buffs intact; `Bean`, `Wine`, `Antidote`, and `Panacea` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu.

```json
{
  "panacea": {
    "name": "Panacea",
    "kind": "consumable",
    "target": "ally",
    "range": [0, 1],
    "uses": 1,
    "effects": [
      { "type": "cure_status", "status": "poison" },
      { "type": "cure_status", "status": "seal" },
      { "type": "cure_status", "status": "snare" },
      { "type": "cure_status", "status": "disarm" },
      { "type": "cleanse_debuffs" }
    ],
    "price": 140
  }
}
```

Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.merchant` can provide a `name` and `lines` that are shown above buy stock as a lightweight camp event. `shop.conditional_items` can append extra stock when saved campaign flags match. Entries can be plain item ids or objects with `id` and optional non-negative `stock`; `shop.stock` and conditional block `stock` objects can also set finite limits by item id. Items omitted from stock data are unlimited. Finite stock is tracked as per-scenario purchase counts in the campaign save, so buying an item decrements that scenario's remaining stock and manual checkpoint saves preserve it. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. The shop can also sell unequipped inventory stock back for 50% of base `price`, saving immediately and refreshing the loaded battle inventory copy. Later campaign data should add broader unlock flags.

The pre-battle Armory operates on the currently loaded player deployments and the copied battle inventory, then saves the changed roster entries and inventory counts back into `CampaignState`. Gear can also be unequipped back into inventory stock before combat, making it available for later Armory swaps or shop selling. Unlike in-battle equipment swaps, Armory changes persist immediately and survive defeat or restart. Completed-scenario replays keep Shop and Armory disabled so replay setup cannot alter the campaign save.

Scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` control the first pre-battle Roster selection pass. Required officers and required scenario units always deploy, optional player deployments can move between sortie and reserve, and reserve units do not participate in map occupancy, targeting, or living-unit victory checks. If all loaded player units are required, the rules still apply but the pre-battle Roster button stays disabled because there is no meaningful sortie choice. If `roster` is omitted, all player deployments sortie as before.

Scenario-only protected units can use `team: "player"`, `controllable: false`, `persist_progression: false`, and an optional `ai_target_priority`. They remain valid targets for enemies, healing, and defeat conditions, but the player cannot select, move, attack, change equipment, or place them in Formation, and they are omitted from campaign roster progression snapshots. `ai_target_priority` is a non-negative scenario hint that makes enemy movement and damage-skill AI treat that unit as a more attractive target. Events can later change the same value with `set_ai_target_priority`.

Scenario `formation.cells` controls the highlighted starting cells available during pre-battle Formation. These choices only change the current battle's unit positions; they are not saved to campaign state. If a scenario omits `formation`, player deployment positions become the default formation cells.

## Scenarios

Scenarios should use deployments rather than full inline unit stat blocks. `DataCatalog` will resolve officer, class, and item references into runtime unit dictionaries. Scenarios may also define briefing text, selectable camp conversations, visible objective text, battle conditions, map background art, and victory rewards.

Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. Deployments can also override `portrait`, `sprite`, and `equipment`; the validator checks image files and equipment against class equipment slots, and `DataCatalog` applies their presentation paths, stat bonuses, and weapon ranges when hydrating the unit.

```json
{
  "id": "001_yellow_turbans",
  "name": "Yingchuan Skirmish",
  "objectives": {
    "victory": "Defeat all Yellow Turban units.",
    "defeat": "All allied units are defeated."
  },
  "conditions": {
    "victory": { "type": "all_units_defeated", "team": "enemy" },
    "defeat": { "type": "all_units_defeated", "team": "player" }
  },
  "briefing": {
    "title": "Yingchuan Skirmish",
    "location": "Yingchuan, 184 CE",
    "lines": ["Yellow Turban rebels have gathered near Yingchuan."],
    "camp_dialogue": [
      { "speaker": "Cao Cao", "side": "left", "text": "We settle this before the revolt spreads." }
    ],
    "camp_conversations": [
      {
        "id": "cao_cao_strategy",
        "group": "officer",
        "officer_id": "cao_cao",
        "label": "Cao Cao - Strategy",
        "speaker": "Cao Cao",
        "summary": "Read the revolt before the first move.",
        "lines": [
          { "speaker": "Cao Cao", "side": "left", "text": "We end this before the revolt becomes a blaze." }
        ]
      },
      {
        "id": "northern_woods_cache",
        "group": "topic",
        "label": "Northern Woods Cache",
        "summary": "Follow up on the merchant's rumor.",
        "lines": [
          { "speaker": "Cao Cao", "side": "left", "text": "Send a scout through the northern woods if the line holds." }
        ],
        "effects": [
          { "type": "grant_item", "item_id": "bean", "count": 1, "text": "The scouts add a Bean to the field supplies." }
        ]
      }
    ],
    "conditional_lines": [
      {
        "campaign_flags": { "pursued_dong_zhuo": true },
        "lines": ["The army arrives tired from a rapid pursuit."]
      }
    ]
  },
  "shop": {
    "merchant": {
      "name": "Camp Merchant",
      "lines": ["I saved beans and antidotes for your column."]
    },
    "items": ["bean", { "id": "cloth_robe", "stock": 1 }],
    "stock": { "bean": 3 },
    "conditional_items": [
      {
        "campaign_flags": { "regrouped_after_sishui": true },
        "items": ["training_spear", "leather_armor"],
        "stock": { "leather_armor": 1 }
      }
    ]
  },
  "roster": {
    "max_units": 3,
    "required_officers": ["cao_cao"],
    "required_units": ["protected_envoy"]
  },
  "formation": {
    "cells": [[1, 3], [1, 4], [2, 3], [2, 4]]
  },
  "map": {
    "width": 10,
    "height": 8,
    "background": "res://art/backgrounds/battlefield_frontier.png",
    "terrain": [
      "GGGGGGGGCC",
      "GGGFFFFGCC",
      "GGGFFFGGGG",
      "GGGRRGGGGG",
      "GGGRRGGHHG",
      "GGGGGGGHHG",
      "GGGWWGGGGG",
      "GGGWWGGGGG"
    ]
  },
  "deployments": [
    { "unit_id": "cao_cao", "officer_id": "cao_cao", "team": "player", "pos": [1, 3] },
    { "unit_id": "protected_envoy", "name": "Protected Envoy", "class_id": "infantry", "team": "player", "controllable": false, "persist_progression": false, "ai_target_priority": 6, "pos": [1, 4] },
    { "unit_id": "xiahou_yuan_ch2", "officer_id": "xiahou_yuan", "team": "player", "requires_joined": true, "pos": [2, 3] },
    {
      "unit_id": "yellow_turban_1",
      "name": "Zhang Mancheng",
      "class_id": "bandit",
      "team": "enemy",
      "level": 1,
      "pos": [7, 2],
      "base": { "hp": 34, "atk": 11, "def": 5 }
    }
  ],
  "rewards": {
    "gold": 300,
    "items": ["bean"],
    "join_officers": ["xiahou_yuan"],
    "leave_officers": []
  },
  "post_battle_dialogue": [
    { "speaker": "Cao Cao", "text": "This victory is only the first fire." }
  ],
  "post_battle_choices": [
    {
      "id": "pursue_dong_zhuo",
      "label": "Pursue Dong Zhuo",
      "description": "Cao Cao presses the retreating army.",
      "set_flags": { "pursued_dong_zhuo": true }
    }
  ]
}
```

`objectives` are display text. `conditions` drive the actual victory and defeat checks. Defeat is evaluated before victory, matching Cao Cao Zhuan-style commander-loss rules. If `conditions` is omitted, battles fall back to defeating all enemies for victory and losing all player units for defeat.

`briefing.camp_conversations` drives the pre-battle Talk menu. Each entry needs a stable lowercase `id`, a non-empty `label`, a `group` of `officer` or `topic`, and non-empty dialogue `lines`. Officer conversations also need an `officer_id` that exists in `officers.json`; `BattleState` normalizes their dialogue lines so speaker portraits can be filled from officer defaults. Optional `campaign_flags`, such as `"campaign_flags": { "regrouped_after_sishui": true }`, can hide a conversation unless saved flags match exactly. Optional `effects` can currently include `grant_item` entries with an `item_id`, optional positive `count`, and optional non-empty `text`. Camp conversation supplies are claimed once per scenario/conversation, saved immediately to campaign inventory through `CampaignState`, and copied into the already-loaded battle inventory; completed-scenario replays cannot claim them. If `camp_conversations` is absent, the older `briefing.camp_dialogue` array is still exposed as one fallback War Council conversation.

`BattleState` also exposes condition progress and defeat-risk text for presentation. The active HUD and briefing panels split objective, progress, and risk details while showing defeated enemy counts, destination reach status, named commander or protected-unit safety, and turns remaining from the same `conditions` data. Conditions gated by `after_event` stay marked as pending; when the referenced event is a `unit_reaches_tile` or `turn_start` trigger, the tracker can surface that prerequisite as the next visible objective, and `unit_reaches_tile` prerequisites can also draw objective markers while the gated condition is still locked.

Player deployments may set `requires_joined: true`; those units are only loaded if their `officer_id` is in campaign `joined_officers`. Scenario rewards can add officers through `rewards.join_officers` and remove them through `rewards.leave_officers`. Post-battle choices can also use `join_officers` and `leave_officers` for branch-specific membership changes. `data/campaign/campaign.json` seeds a new campaign with `initial_joined_officers`.

When a saved campaign is loaded, `CampaignState` reconciles completed-scenario officer membership in campaign order. It reapplies only `join_officers` and `leave_officers` from completed scenario rewards plus the saved choice id in `applied_post_battle_choices`. Old saves without that choice ledger are backfilled only when exactly one completed scenario choice has `set_flags` that match saved campaign flags. If zero or multiple choices match, choice membership is skipped for that scenario. Gold, items, roster progression, flags, and completed ids are not replayed by this reconciliation pass.

Supported condition forms:

```json
{ "type": "all_units_defeated", "team": "enemy" }
{ "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }
{ "type": "any_unit_defeated", "unit_ids": ["cao_cao_ch2"] }
{ "type": "turn_reached", "turn": 9, "team": "player" }
{ "type": "turn_limit", "turn": 8 }
{ "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [13, 4] }
{ "type": "all", "conditions": [{ "type": "all_units_defeated", "team": "enemy" }] }
{ "type": "any", "conditions": [{ "type": "any_officer_defeated", "officer_ids": ["cao_cao"] }] }
```

Conditions may include `after_event` to stay inactive until a one-shot event has fired. This is useful for reinforcement battles where the player should not win before the reinforcement event occurs.

`turn_reached` is the precise condition form. `turn_limit` is a shorthand for "win by the end of this turn" and is evaluated as a player-phase defeat after the limit is exceeded.

`unit_reaches_tile` checks living deployed units against either one zero-based `pos` coordinate or a `cells` array such as `[[10, 4], [11, 4]]`. Use `officer_ids` for persistent named officers or `unit_ids` for scenario-specific units; the validator requires one of those filters so destination objectives do not accidentally trigger from any unit. Victory destination cells are drawn as objective markers on the battle map, including every entry in `cells`. If a pending victory condition waits on an `after_event` whose trigger is `unit_reaches_tile`, that event's `pos` or `cells` are also drawn as the current objective marker until the event fires.

`post_battle_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker`, non-empty `text`, optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`, and optional `side` values of `left` or `right` for portrait placement. Portrait paths must stay inside the project, point to existing readable image files, and keep at least a 512x512 source resolution. If `side` is omitted, it defaults to `left`. If `portrait` is omitted, `BattleState` uses a matching officer definition's default portrait path when the line's `speaker` matches the officer `name`. If no usable portrait is loaded, the battle scene falls back to a speaker-initial panel.

`post_battle_choices` are shown on the victory result panel after first-time scenario rewards are applied. Each choice needs a unique stable lowercase `id`, a non-empty `label`, and a `set_flags` object whose keys are stable lowercase flag ids. Choices may also grant positive `gold`, known `items`, `join_officers`, and `leave_officers`. When rewards are saved but the player has not selected a choice yet, `CampaignState.pending_post_battle_choice_scenario_id` records that scenario id so reloading the game returns to the victory choice panel instead of skipping the branch. The selected choice is saved to `CampaignState.flags`, `CampaignState.applied_post_battle_choices`, and campaign membership only when the player presses its result-panel button; completed-scenario replays do not show choices again. Save-load migration for older saves uses flags as a conservative choice signal, so avoid designing multiple choices in one scenario that can all match the same saved flag state.

Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, movement-triggered `unit_reaches_tile`, and defeat-triggered `unit_defeated` triggers. Each event needs a stable lowercase `id` that is unique within the scenario; condition and event `after_event` gates can only reference those explicit ids. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell or any zero-based entry in `cells`; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. `unit_defeated` events run after a deployed unit is marked defeated and before final victory/defeat checks; use `team`, `unit_ids`, or `officer_ids` to limit which fallen unit can trigger the event. Event `when` blocks may include `after_event` to wait until an earlier one-shot scenario beat has fired, which is useful for dialogue or follow-up pressure that only makes sense after a destination, ambush, gate, fire event, or named-unit defeat. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `grant_gold`, `spawn_deployment`, `spawn_deployments`, `withdraw_unit`, `withdraw_units`, and `set_ai_target_priority`. `set_objective` updates visible objective text, emits concrete log lines for changed victory/defeat text, and raises an objective-update signal so the battle scene can refresh the HUD and show a short notice. `grant_gold` accepts a positive `amount` and optional non-empty `text`, adds battle-only gold, and persists to the campaign save only on first-time victory. `set_ai_target_priority` accepts `unit_id` or `unit_ids`, a `priority` from 0 to 20, optional `text`, and optional `silent`; it changes enemy AI target scoring immediately and, unless silent, logs the change plus shows target-focus floating text. `withdraw_unit` accepts `unit_id` or `id`, while `withdraw_units` accepts a `unit_ids` array; living deployed targets leave the battlefield without being marked defeated, so they are removed from map occupancy and living-unit counts while protected-unit defeat checks still distinguish withdrawal from death. Event `dialogue` actions use the same dialogue line format, portrait default rules, and optional `side` placement as `post_battle_dialogue`. Event item grants add to the battle inventory immediately; event gold grants add to the battle reward pool. Both persist to the campaign save only when the battle is won.

```json
{
  "id": "turn_6_granary_collapse",
  "once": true,
  "when": { "type": "turn_start", "team": "player", "turn": 6, "after_event": "wuchao_depot_burned" },
  "actions": [
    { "type": "dialogue", "lines": [{ "speaker": "Guo Jia", "text": "Smoke is rising above the camp." }] }
  ]
}
```

```json
{
  "id": "wen_chou_vanguard_defeated",
  "once": true,
  "when": { "type": "unit_defeated", "unit_ids": ["wen_chou_vanguard"] },
  "actions": [
    { "type": "log", "text": "Wen Chou's countercharge collapses at Yan Ford." }
  ]
}
```

```json
{
  "id": "turn_2_warning",
  "once": true,
  "when": {
    "type": "turn_start",
    "team": "enemy",
    "turn": 2,
    "campaign_flags": { "pursued_dong_zhuo": true }
  },
  "actions": [
    { "type": "log", "text": "Enemy reinforcements are approaching." },
    {
      "type": "dialogue",
      "lines": [
        { "speaker": "Cao Cao", "side": "left", "portrait": "res://art/portraits/cao_cao.png", "text": "Hold formation." }
      ]
    },
    { "type": "set_objective", "victory": "Defeat the vanguard and reinforcements." },
    { "type": "grant_item", "item_id": "bean" },
    { "type": "withdraw_units", "unit_ids": ["enemy_scout_1", "enemy_scout_2"] },
    { "type": "set_ai_target_priority", "unit_id": "protected_envoy", "priority": 10, "text": "The envoy is exposed." },
    {
      "type": "spawn_deployment",
      "deployment": {
        "unit_id": "enemy_reinforcement_1",
        "name": "Enemy Rider",
        "class_id": "cavalry",
        "team": "enemy",
        "level": 2,
        "pos": [11, 3],
        "base": { "hp": 36, "atk": 12, "def": 6 }
      }
    }
  ]
}
```

Movement-triggered ambushes use the same event action shape. Use `cells` instead of `pos` when a gate, bridge, road, or marker should allow more than one exact tile.

```json
{
  "id": "midroad_ambush",
  "once": true,
  "when": { "type": "unit_reaches_tile", "team": "player", "cells": [[7, 4], [8, 4]] },
  "actions": [
    { "type": "dialogue", "lines": [{ "speaker": "Scout", "text": "Ambush!" }] },
    {
      "type": "spawn_deployment",
      "deployment": {
        "unit_id": "ambush_guard",
        "name": "Ambush Guard",
        "class_id": "infantry",
        "team": "enemy",
        "level": 7,
        "pos": [6, 2]
      }
    }
  ]
}
```

Movement-triggered pickups can use `grant_item` for one item or `grant_items` for a mixed list. `grant_items.items` accepts item ids or objects with `item_id`/`id` and a positive `count`.

```json
{
  "id": "northern_woods_cache",
  "once": true,
  "when": { "type": "unit_reaches_tile", "team": "player", "pos": [3, 2] },
  "actions": [
    { "type": "log", "text": "The northern woods cache is recovered." },
    { "type": "grant_items", "items": ["bean", { "item_id": "antidote", "count": 2 }] }
  ]
}
```

Movement-triggered gold caches can use `grant_gold` with a positive `amount`. The gold is shown immediately through log and floating feedback, but it stays battle-only until victory commits the battle result.

```json
{
  "id": "captured_pay_chest",
  "once": true,
  "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [14, 4] },
  "actions": [
    { "type": "grant_gold", "amount": 200, "text": "The captured pay chest yields 200 gold." }
  ]
}
```

`briefing.conditional_lines`, `briefing.camp_conversations[].campaign_flags`, `shop.conditional_items`, and `when.campaign_flags` all use exact flag matching. When present, every listed flag must match the saved campaign flag value for that block, conversation, or event to apply. Use separate blocks, conversations, or events for OR-style branches. The validator requires referenced flags to have been introduced by an earlier `post_battle_choices.set_flags` entry in campaign order.

Event-spawned units use the same compact deployment shape as scenario starting units. Spawn attempts are skipped if the unit id already exists, the target cell is occupied, the cell is outside the map, or the unit cannot stand on that terrain. Reinforcements that arrive during their own team's phase wait until their next phase before acting.

## Current Implementation

`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, portrait, equipment, and battle-only `status_effects` so the battle resolver and presentation layer can handle counterattacks, MP tactics, support buffs, enemy debuffs, movement modifiers, accuracy/evasion modifiers, damage-over-time statuses, action-lock statuses, EXP, level-ups, weapon-aware physical attack AI, enemy tactic AI, and officer portrait thumbnails without changing scenario files.

`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, per-scenario shop purchase counts, per-scenario camp conversation claim ledgers, and player roster progression. The same snapshot can be copied to the one-slot manual checkpoint at `user://campaign_manual_save.json` from the pre-battle Save menu. Loading that checkpoint restores it into the active automatic save and then reloads the pending choice panel, campaign completion state, or current scenario briefing. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, post-battle choices, finite shop purchases, and camp conversation supply claims are guarded by scenario/conversation ledgers so restarting a finished battle does not duplicate gold, items, membership changes, branch decisions, or finite shop purchases. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves.

After a first-time victory, `CampaignState` advances `current_scenario_id` to the next entry in `data/campaign/campaign.json`. Completed-scenario replays return a result summary without rewriting the save or moving `current_scenario_id`. The battle scene passes `CampaignState.get_roster_overrides()`, `CampaignState.get_inventory_snapshot()`, `CampaignState.get_flags_snapshot()`, and `CampaignState.get_joined_officers_snapshot()` into `BattleState.load_battle()`, so officers can keep level/EXP/stat progression, equipped gear, consumable/equipment stock counts, recruitment gates, and branch flags into the next scenario.

During a battle, item consumption, event item pickups, equipment swaps, and unequipped gear returned to stock affect `BattleState.battle_inventory`, while event gold pickups accumulate in a separate battle-only reward pool. `CampaignState.apply_battle_result()` commits that inventory snapshot and battle gold only for a first-time victory, then adds the victory rewards. Equipped gear is stored in the player roster snapshot together with the current derived stats; a later stat-model refactor should split base stats, growth, and equipment bonuses.

`BattleState` records player-only `progression_events` when a unit levels up or promotes. `CampaignState.apply_battle_result()` includes those events in the victory summary only when the first-time victory save succeeds, so completed-scenario replays and failed saves do not present unsaved growth as campaign progress. The result panel presents saved rewards, roster changes, growth, campaign status, and pending choices as separate sections; failed saves explicitly show rewards as not applied.

Physical attacks grant normal attack/counter EXP only on hit after the attack/counter exchange resolves. A missed physical attack or counter grants a small miss EXP value, while defeat bonuses still require the attack to hit and defeat the target.

Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold, inventory, and finite-stock purchase counts in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Talk can claim `briefing.camp_conversations[].effects` through `CampaignState.try_claim_camp_conversation_effects()`, which saves the supply reward and conversation ledger immediately before refreshing the loaded battle inventory copy. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Roster and Formation choices are battle setup state, so the manual checkpoint stores campaign progression and saved loadout state but not unsaved sortie or starting-cell edits. Matching `briefing.conditional_lines`, `briefing.camp_conversations[].campaign_flags`, and `shop.conditional_items` are resolved while the scenario loads, before the briefing panel opens. During battle, item previews can show HP/MP recovery, status cures, and debuff cleanses, and no-effect item use is rejected without consuming stock. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails.

When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu.

Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names, cure statuses, debuff cleanse effects, class sprites, item icons, map backgrounds, officer and deployment portraits/sprites, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, camp dialogue, camp conversations, camp conversation campaign flags and camp conversation effects, deployment ids, deployment bounds, impassable spawn cells, shop stock, shop merchant lines, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids.
