Files
heros/docs/DATA_MODEL.md

449 lines
30 KiB
Markdown

# 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
```
## Campaign
The campaign file defines 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"],
"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" }
]
}
```
## 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. Enemy AI can use `damage` and `heal` 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"
}
}
```
The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, and `great_mend`. Hero and strategist classes get low-cost Spark alongside Fire/Mend, 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 and `heal_mp` restores MP to a valid target; `Bean` and `Wine` 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.
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. 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. Later campaign data should add finite stock counts, unlock flags, and sell-back rules.
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"],
"conditional_items": [
{
"campaign_flags": { "regrouped_after_sishui": true },
"items": ["leather_armor"]
}
]
},
"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` and non-empty `text`.
`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`.
```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", "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`, and `growth_bonus` so the battle resolver can handle counterattacks, MP tactics, 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, and player roster progression. 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, or branch decisions. 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 victory, `CampaignState` advances `current_scenario_id` to the next entry in `data/campaign/campaign.json`. 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()`. A successful purchase updates campaign gold and inventory in the save file immediately, then calls `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. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. `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 save file and resets `current_scenario_id` to `start_scenario`.
Run `tools/validate_data.ps1` after data edits to check campaign paths, map dimensions, terrain keys, class/officer/item/skill references, item effect names and amounts, 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.