# 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, and starting equipment. ```json { "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. ```json { "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, and damage-over-time 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`, 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. ```json { "poison_mist": { "name": "Poison Mist", "kind": "support", "target": "enemy", "mp_cost": 6, "range": [1, 3], "power": 0, "stat": "int", "effects": [ { "type": "damage_over_time", "status": "poison", "amount": 4, "duration_turns": 2 } ] } } ``` The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, `great_mend`, `guard_order`, `rally_order`, `disrupt_order`, and `poison_mist`. Hero classes get low-cost Spark alongside Fire/Mend and ally orders, strategist classes also get Poison Mist, 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. ```json { "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 matching damage-over-time status effects from a valid target; `Bean`, `Wine`, and `Antidote` 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 { "antidote": { "name": "Antidote", "kind": "consumable", "target": "ally", "range": [0, 1], "uses": 1, "effects": [{ "type": "cure_status", "status": "poison" }], "price": 70 } } ``` 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. ```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."], "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: ```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 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`. ```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": "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: ```json { "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, 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 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.