Files
heros/docs/DATA_MODEL.md

37 KiB

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

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.

{
  "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, and starting equipment.

{
  "cao_cao": {
    "name": "Cao Cao",
    "faction": "wei",
    "class_id": "hero",
    "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.

{
  "cavalry": {
    "name": "Cavalry",
    "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.

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.

{
  "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, or agi, 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. 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, and positive duration_turns; it blocks the affected unit from selecting or casting tactics while active.

{
  "seal_tactic": {
    "name": "Seal Tactic",
    "kind": "support",
    "target": "enemy",
    "mp_cost": 6,
    "range": [1, 3],
    "power": 0,
    "stat": "int",
    "effects": [
      { "type": "action_lock", "status": "seal", "action": "skill", "duration_turns": 2 }
    ]
  }
}

The current single-target tactic set includes spark, fire_tactic, blaze, mend, great_mend, guard_order, rally_order, disrupt_order, poison_mist, and seal_tactic. Hero classes get low-cost Spark alongside Fire/Mend and ally orders, strategist classes also get Poison Mist and Seal 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.

{
  "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.

{
  "bronze_sword": {
    "name": "Bronze Sword",
    "kind": "weapon",
    "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. 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.

Consumables use kind: "consumable" with effect records. heal_hp restores HP, heal_mp restores MP, and cure_status removes active battle-only effects with a matching status from a valid target; 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.

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

Scenario shop.items controls which positive-price items appear in the current pre-battle shop. 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. 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 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.

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, visible objective text, battle conditions, 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.

{
  "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."],
    "conditional_lines": [
      {
        "campaign_flags": { "pursued_dong_zhuo": true },
        "lines": ["The army arrives tired from a rapid pursuit."]
      }
    ]
  },
  "shop": {
    "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]]
  },
  "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.

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:

{ "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 a zero-based pos coordinate. 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.

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. 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, and movement-triggered unit_reaches_tile triggers. battle_begin runs after the briefing is closed. unit_reaches_tile events run when a moved unit enters the zero-based pos cell; use team, unit_ids, or officer_ids to limit who can trigger the event. Actions currently include log, dialogue, set_objective, spawn_deployment, and spawn_deployments. Event dialogue actions use the same dialogue line format, portrait default rules, and optional side placement as post_battle_dialogue.

{
  "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": "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:

{
  "id": "midroad_ambush",
  "once": true,
  "when": { "type": "unit_reaches_tile", "team": "player", "pos": [7, 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]
      }
    }
  ]
}

briefing.conditional_lines, 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 or event to apply. Use separate blocks 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, and battle-only status_effects so the battle resolver can handle counterattacks, MP tactics, support buffs, enemy debuffs, damage-over-time statuses, action-lock statuses, EXP, level-ups, and enemy tactic AI 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, 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, and post-battle choices are guarded by completed scenario id 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 and equipment swaps affect BattleState.battle_inventory. CampaignState.apply_battle_result() commits that inventory snapshot 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.

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 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 and shop.conditional_items are merged while the scenario loads, before the briefing panel opens. During battle, item previews can show HP/MP recovery and status cures, 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 status/action-lock effect shapes, item effect names and cure statuses, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, condition unit references, destination condition coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids.