commit b08c2ac37e231c4be8b8d276c688d6fb8d5e2f65 Author: Wickedness Date: Wed Jun 17 23:06:18 2026 +0900 Initial Godot tactical RPG prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d17405 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.godot/ +.import/ +export/ +*.tmp +*.translation + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfca279 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Heroes of Three Kingdoms + +Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Three Kingdoms games. The current editor target is moving to Godot 4.6. + +## Current Slice + +- Seventeen campaign scenarios. +- Grid movement. +- Unit selection. +- Move, attack, wait, end turn. +- Enemy AI with movement, attacks, multiple MP tactic choices, and scenario target priorities. +- Hover tile and unit info. +- Damage forecast for selected-unit attacks. +- Terrain defense and avoid affect physical attack damage and hit chance. +- Min/max attack ranges and counterattack forecasts. +- MP tactics with low/high damage options, healing options, a skill list menu, damage/healing previews, and target overlays. +- Campaign inventory consumables with an item menu, HP/MP recovery preview, and target overlay. +- Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD. +- Scenario-specific pre-battle shop purchases spend campaign gold, save immediately, and sync into the upcoming battle inventory. +- Pre-battle Armory equipment changes save immediately and sync roster equipment plus inventory stock. +- Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. +- Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. +- Escort scenarios can include required, non-controllable protected units. +- Campaign saves joined officers, future deployments can require prior recruitment, and choices can make officers leave. +- Completed saves reconcile officer join/leave rewards on load without replaying gold or items. +- Post-battle choices save a scenario-to-choice id ledger for safer future branch migrations. +- Reloading during a pending post-battle choice returns to the choice panel instead of skipping the branch. +- Qingzhou victory can recruit Dian Wei, backed by a new Warrior class. +- Puyang uses Dian Wei as a required joined officer and introduces a Lu Bu branch choice. +- Dingtao consumes the Puyang branch flags and rewards a stronger axe. +- Wan Castle consumes the imperial escort branch flags and adds a protected Cao Ang escape map. +- Xiapi consumes the Wan Castle branch flags and adds a flood-gate siege against Lu Bu. +- Xiapi victory recruits Guo Jia, and White Horse starts the northern campaign with a decoy-line battle. +- Yan Ford consumes the White Horse branch flags and adds Wen Chou's countercharge before Guandu. +- Guandu consumes the Yan Ford branch flags and adds Yuan Shao's main assault. +- Wuchao consumes the Guandu branch flags and adds a depot-burning raid. +- Cangting consumes the Wuchao branch flags, breaks Yuan Shao's rear guard, and recruits Zhang He. +- Ye consumes the Cangting branch flags and lets Zhang He deploy against the Yuan heirs. +- Ye Siege consumes the Ye campaign branch flags and stages an inner-gate breach. +- Ye Surrender consumes the siege branch flags and settles the city before the Yuan heirs flee north. +- Post-battle choices can save campaign flags that branch later briefing text, shop stock, and scenario events. +- Scenario post-battle dialogue can play before the victory result panel. +- Basic battle EXP, level-ups, and core first-tier class promotion routes. +- Victory results summarize saved player level-ups and promotions. +- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, objective updates, and reinforcements. +- Scenario briefing before battle. +- Victory rewards and consumed inventory saved into a campaign state. +- Scenario-defined victory and defeat conditions. +- Destination-style victory conditions with objective map markers. +- Protected-unit defeat conditions for escort battles. +- Turn-limit defeat conditions with turn-limit HUD display. +- Next-battle flow after victory. +- Campaign completion screen. +- New campaign save reset. +- Victory and defeat result overlay. +- Data-driven scenario setup through JSON definitions and deployments. + +## Run + +1. Install Godot 4. +2. Open this folder as a Godot project. +3. Run the project. The main scene is `res://scenes/battle_scene.tscn`. + +Campaign order lives at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. + +Data files can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\validate_data.ps1`. + +Godot 4.6 migration readiness can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readiness.ps1`. The migration checklist lives in `docs/GODOT_4_6_MIGRATION.md`. + +## Direction + +The project should grow from a small playable battle into a campaign tactics RPG: + +- Battle system first. +- Scenario and event scripting second. +- Progression, equipment, and class depth third. +- Portraits, cutscenes, and campaign presentation after the core loop is fun. + +The code keeps portraits and other visual assets replaceable so generated original assets can be added later without changing battle rules. diff --git a/data/campaign/campaign.json b/data/campaign/campaign.json new file mode 100644 index 0000000..80588e2 --- /dev/null +++ b/data/campaign/campaign.json @@ -0,0 +1,93 @@ +{ + "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" + } + ] +} diff --git a/data/defs/classes.json b/data/defs/classes.json new file mode 100644 index 0000000..4f3aa6f --- /dev/null +++ b/data/defs/classes.json @@ -0,0 +1,203 @@ +{ + "hero": { + "name": "Hero", + "tier": 1, + "move_type": "foot", + "move": 4, + "attack_range": [1, 1], + "skills": ["spark", "fire_tactic", "mend"], + "growth": { "hp": "B", "mp": "B", "atk": "B", "def": "B", "int": "A", "agi": "B" }, + "base_bonus": { "hp": 0, "atk": 0, "def": 0 }, + "equipment_slots": { + "weapon": ["sword"], + "armor": ["robe", "light_armor"], + "accessory": ["accessory"] + }, + "promotion": { "level": 15, "to": "commander" } + }, + "commander": { + "name": "Commander", + "tier": 2, + "move_type": "foot", + "move": 5, + "attack_range": [1, 1], + "skills": ["spark", "fire_tactic", "mend", "great_mend"], + "growth": { "hp": "A", "mp": "B", "atk": "A", "def": "B", "int": "A", "agi": "B" }, + "base_bonus": { "hp": 4, "atk": 1, "def": 1, "int": 1 }, + "equipment_slots": { + "weapon": ["sword"], + "armor": ["robe", "light_armor"], + "accessory": ["accessory"] + } + }, + "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": 2, "atk": 0, "def": 0 }, + "equipment_slots": { + "weapon": ["spear", "sword"], + "armor": ["light_armor", "heavy_armor"], + "accessory": ["accessory"] + }, + "promotion": { "level": 15, "to": "elite_cavalry" } + }, + "elite_cavalry": { + "name": "Elite Cavalry", + "tier": 2, + "move_type": "mounted", + "move": 6, + "attack_range": [1, 1], + "skills": [], + "growth": { "hp": "A", "mp": "D", "atk": "A", "def": "A", "int": "D", "agi": "A" }, + "base_bonus": { "hp": 5, "atk": 2, "def": 1 }, + "equipment_slots": { + "weapon": ["spear", "sword"], + "armor": ["light_armor", "heavy_armor"], + "accessory": ["accessory"] + } + }, + "infantry": { + "name": "Infantry", + "tier": 1, + "move_type": "foot", + "move": 4, + "attack_range": [1, 1], + "skills": [], + "growth": { "hp": "B", "mp": "D", "atk": "B", "def": "B", "int": "D", "agi": "C" }, + "base_bonus": { "hp": 0, "atk": 0, "def": 0 }, + "equipment_slots": { + "weapon": ["sword", "spear"], + "armor": ["light_armor", "heavy_armor"], + "accessory": ["accessory"] + }, + "promotion": { "level": 15, "to": "guard_captain" } + }, + "guard_captain": { + "name": "Guard Captain", + "tier": 2, + "move_type": "foot", + "move": 5, + "attack_range": [1, 1], + "skills": [], + "growth": { "hp": "A", "mp": "D", "atk": "B", "def": "A", "int": "D", "agi": "C" }, + "base_bonus": { "hp": 4, "atk": 1, "def": 2 }, + "equipment_slots": { + "weapon": ["sword", "spear"], + "armor": ["light_armor", "heavy_armor"], + "accessory": ["accessory"] + } + }, + "warrior": { + "name": "Warrior", + "tier": 1, + "move_type": "foot", + "move": 4, + "attack_range": [1, 1], + "skills": [], + "growth": { "hp": "A", "mp": "E", "atk": "A", "def": "B", "int": "E", "agi": "C" }, + "base_bonus": { "hp": 4, "atk": 2, "def": 1, "int": -2 }, + "equipment_slots": { + "weapon": ["axe", "sword"], + "armor": ["light_armor", "heavy_armor"], + "accessory": ["accessory"] + }, + "promotion": { "level": 15, "to": "champion" } + }, + "champion": { + "name": "Champion", + "tier": 2, + "move_type": "foot", + "move": 5, + "attack_range": [1, 1], + "skills": [], + "growth": { "hp": "A", "mp": "E", "atk": "A", "def": "A", "int": "E", "agi": "B" }, + "base_bonus": { "hp": 6, "atk": 2, "def": 2, "int": -2 }, + "equipment_slots": { + "weapon": ["axe", "sword"], + "armor": ["light_armor", "heavy_armor"], + "accessory": ["accessory"] + } + }, + "archer": { + "name": "Archer", + "tier": 1, + "move_type": "archer", + "move": 4, + "attack_range": [2, 2], + "skills": [], + "growth": { "hp": "C", "mp": "D", "atk": "B", "def": "C", "int": "D", "agi": "A" }, + "base_bonus": { "hp": 0, "atk": 0, "def": 0 }, + "equipment_slots": { + "weapon": ["bow"], + "armor": ["robe", "light_armor"], + "accessory": ["accessory"] + }, + "promotion": { "level": 15, "to": "marksman" } + }, + "marksman": { + "name": "Marksman", + "tier": 2, + "move_type": "archer", + "move": 5, + "attack_range": [2, 2], + "skills": [], + "growth": { "hp": "B", "mp": "D", "atk": "A", "def": "C", "int": "D", "agi": "A" }, + "base_bonus": { "hp": 3, "atk": 2, "agi": 1 }, + "equipment_slots": { + "weapon": ["bow"], + "armor": ["robe", "light_armor"], + "accessory": ["accessory"] + } + }, + "strategist": { + "name": "Strategist", + "tier": 1, + "move_type": "foot", + "move": 4, + "attack_range": [1, 1], + "skills": ["spark", "fire_tactic", "mend"], + "growth": { "hp": "C", "mp": "A", "atk": "E", "def": "D", "int": "A", "agi": "C" }, + "base_bonus": { "hp": -2, "mp": 8, "atk": -2, "def": -1, "int": 2 }, + "equipment_slots": { + "weapon": ["sword"], + "armor": ["robe", "light_armor"], + "accessory": ["accessory"] + }, + "promotion": { "level": 15, "to": "military_advisor" } + }, + "military_advisor": { + "name": "Military Advisor", + "tier": 2, + "move_type": "foot", + "move": 4, + "attack_range": [1, 1], + "skills": ["spark", "fire_tactic", "mend", "great_mend"], + "growth": { "hp": "B", "mp": "A", "atk": "E", "def": "C", "int": "A", "agi": "C" }, + "base_bonus": { "hp": 2, "mp": 6, "atk": -2, "def": 1, "int": 2 }, + "equipment_slots": { + "weapon": ["sword"], + "armor": ["robe", "light_armor"], + "accessory": ["accessory"] + } + }, + "bandit": { + "name": "Bandit", + "tier": 1, + "move_type": "foot", + "move": 4, + "attack_range": [1, 1], + "skills": [], + "growth": { "hp": "B", "mp": "E", "atk": "B", "def": "C", "int": "E", "agi": "B" }, + "base_bonus": { "hp": 0, "atk": 0, "def": 0 }, + "equipment_slots": { + "weapon": ["sword", "axe"], + "armor": ["light_armor"], + "accessory": ["accessory"] + } + } +} diff --git a/data/defs/items.json b/data/defs/items.json new file mode 100644 index 0000000..1b84eb7 --- /dev/null +++ b/data/defs/items.json @@ -0,0 +1,110 @@ +{ + "bronze_sword": { + "name": "Bronze Sword", + "kind": "weapon", + "weapon_type": "sword", + "range": [1, 1], + "bonuses": { "atk": 3 }, + "effects": [], + "price": 300 + }, + "iron_sword": { + "name": "Iron Sword", + "kind": "weapon", + "weapon_type": "sword", + "range": [1, 1], + "bonuses": { "atk": 5 }, + "effects": [], + "price": 520 + }, + "training_spear": { + "name": "Training Spear", + "kind": "weapon", + "weapon_type": "spear", + "range": [1, 1], + "bonuses": { "atk": 4 }, + "effects": [], + "price": 360 + }, + "short_bow": { + "name": "Short Bow", + "kind": "weapon", + "weapon_type": "bow", + "range": [2, 2], + "bonuses": { "atk": 3 }, + "effects": [], + "price": 320 + }, + "hand_axe": { + "name": "Hand Axe", + "kind": "weapon", + "weapon_type": "axe", + "range": [1, 1], + "bonuses": { "atk": 4 }, + "effects": [], + "price": 340 + }, + "war_axe": { + "name": "War Axe", + "kind": "weapon", + "weapon_type": "axe", + "range": [1, 1], + "bonuses": { "atk": 6 }, + "effects": [], + "price": 680 + }, + "cloth_robe": { + "name": "Cloth Robe", + "kind": "armor", + "armor_type": "robe", + "bonuses": { "def": 1 }, + "effects": [], + "price": 120 + }, + "leather_armor": { + "name": "Leather Armor", + "kind": "armor", + "armor_type": "light_armor", + "bonuses": { "def": 1 }, + "effects": [], + "price": 180 + }, + "iron_armor": { + "name": "Iron Armor", + "kind": "armor", + "armor_type": "heavy_armor", + "bonuses": { "def": 3 }, + "effects": [], + "price": 520 + }, + "war_drum": { + "name": "War Drum", + "kind": "accessory", + "accessory_type": "accessory", + "bonuses": { "atk": 1, "agi": 1 }, + "effects": [], + "price": 640 + }, + "imperial_seal": { + "name": "Imperial Seal", + "kind": "accessory", + "accessory_type": "accessory", + "bonuses": { "mp": 4, "int": 1 }, + "effects": [], + "price": 900 + }, + "bean": { + "name": "Bean", + "kind": "consumable", + "uses": 1, + "effects": [{ "type": "heal_hp", "amount": 20 }], + "price": 50 + }, + "wine": { + "name": "Wine", + "kind": "consumable", + "uses": 1, + "effects": [{ "type": "heal_mp", "amount": 8 }], + "price": 120 + } +} diff --git a/data/defs/officers.json b/data/defs/officers.json new file mode 100644 index 0000000..6a945c4 --- /dev/null +++ b/data/defs/officers.json @@ -0,0 +1,72 @@ +{ + "cao_cao": { + "name": "Cao Cao", + "faction": "wei", + "class_id": "hero", + "level": 1, + "exp": 0, + "base": { "hp": 38, "mp": 10, "atk": 10, "def": 7, "int": 12, "agi": 8 }, + "growth_bonus": { "atk": 1, "int": 1 }, + "equipment": { "weapon": "bronze_sword", "armor": "cloth_robe", "accessory": null } + }, + "xiahou_dun": { + "name": "Xiahou Dun", + "faction": "wei", + "class_id": "cavalry", + "level": 1, + "exp": 0, + "base": { "hp": 40, "mp": 4, "atk": 11, "def": 6, "int": 5, "agi": 9 }, + "growth_bonus": { "hp": 1, "atk": 1 }, + "equipment": { "weapon": "training_spear", "armor": "leather_armor", "accessory": null } + }, + "xiahou_yuan": { + "name": "Xiahou Yuan", + "faction": "wei", + "class_id": "archer", + "level": 1, + "exp": 0, + "base": { "hp": 32, "mp": 4, "atk": 9, "def": 5, "int": 6, "agi": 12 }, + "growth_bonus": { "agi": 1 }, + "equipment": { "weapon": "short_bow", "armor": "leather_armor", "accessory": null } + }, + "cao_ren": { + "name": "Cao Ren", + "faction": "wei", + "class_id": "infantry", + "level": 1, + "exp": 0, + "base": { "hp": 36, "mp": 3, "atk": 10, "def": 8, "int": 5, "agi": 7 }, + "growth_bonus": { "def": 1 }, + "equipment": { "weapon": "bronze_sword", "armor": "leather_armor", "accessory": null } + }, + "dian_wei": { + "name": "Dian Wei", + "faction": "wei", + "class_id": "warrior", + "level": 3, + "exp": 0, + "base": { "hp": 42, "mp": 2, "atk": 13, "def": 8, "int": 3, "agi": 7 }, + "growth_bonus": { "hp": 1, "atk": 1 }, + "equipment": { "weapon": "hand_axe", "armor": "leather_armor", "accessory": null } + }, + "guo_jia": { + "name": "Guo Jia", + "faction": "wei", + "class_id": "strategist", + "level": 8, + "exp": 0, + "base": { "hp": 30, "mp": 18, "atk": 5, "def": 5, "int": 14, "agi": 9 }, + "growth_bonus": { "mp": 1, "int": 1 }, + "equipment": { "weapon": "bronze_sword", "armor": "cloth_robe", "accessory": null } + }, + "zhang_he": { + "name": "Zhang He", + "faction": "wei", + "class_id": "elite_cavalry", + "level": 12, + "exp": 0, + "base": { "hp": 48, "mp": 6, "atk": 15, "def": 10, "int": 7, "agi": 14 }, + "growth_bonus": { "atk": 1, "agi": 1 }, + "equipment": { "weapon": "training_spear", "armor": "iron_armor", "accessory": null } + } +} diff --git a/data/defs/skills.json b/data/defs/skills.json new file mode 100644 index 0000000..b07f095 --- /dev/null +++ b/data/defs/skills.json @@ -0,0 +1,47 @@ +{ + "spark": { + "name": "Spark", + "kind": "damage", + "target": "enemy", + "mp_cost": 2, + "range": [1, 2], + "power": 5, + "stat": "int" + }, + "fire_tactic": { + "name": "Fire", + "kind": "damage", + "target": "enemy", + "mp_cost": 4, + "range": [1, 3], + "power": 8, + "stat": "int" + }, + "blaze": { + "name": "Blaze", + "kind": "damage", + "target": "enemy", + "mp_cost": 7, + "range": [1, 3], + "power": 13, + "stat": "int" + }, + "mend": { + "name": "Mend", + "kind": "heal", + "target": "ally", + "mp_cost": 4, + "range": [0, 2], + "power": 12, + "stat": "int" + }, + "great_mend": { + "name": "Great Mend", + "kind": "heal", + "target": "ally", + "mp_cost": 7, + "range": [0, 2], + "power": 20, + "stat": "int" + } +} diff --git a/data/defs/terrain.json b/data/defs/terrain.json new file mode 100644 index 0000000..1cdea59 --- /dev/null +++ b/data/defs/terrain.json @@ -0,0 +1,43 @@ +{ + "G": { + "id": "plain", + "name": "Plain", + "color": "#6d9e4f", + "defense": 0, + "avoid": 0, + "move_cost": { "foot": 1, "mounted": 1, "archer": 1, "water": 99 } + }, + "F": { + "id": "forest", + "name": "Forest", + "color": "#33733a", + "defense": 2, + "avoid": 10, + "move_cost": { "foot": 2, "mounted": 3, "archer": 2, "water": 99 } + }, + "H": { + "id": "hill", + "name": "Hill", + "color": "#8c8057", + "defense": 1, + "avoid": 5, + "move_cost": { "foot": 2, "mounted": 3, "archer": 2, "water": 99 } + }, + "R": { + "id": "road", + "name": "Road", + "color": "#a38c6b", + "defense": 0, + "avoid": 0, + "move_cost": { "foot": 1, "mounted": 1, "archer": 1, "water": 99 } + }, + "W": { + "id": "water", + "name": "Water", + "color": "#2e5c9e", + "defense": 0, + "avoid": 0, + "move_cost": { "foot": 99, "mounted": 99, "archer": 99, "water": 1 } + } +} + diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json new file mode 100644 index 0000000..c4ae3f6 --- /dev/null +++ b/data/scenarios/001_yellow_turbans.json @@ -0,0 +1,124 @@ +{ + "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.", + "Cao Cao and Xiahou Dun must break their formation before the revolt spreads." + ] + }, + "shop": { + "items": ["bean"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [2, 3], [2, 4]] + }, + "map": { + "width": 10, + "height": 8, + "terrain": [ + "GGGGGGGGGG", + "GGGFFFFGGG", + "GGGFFFGGGG", + "GGGRRGGGGG", + "GGGRRGGHHG", + "GGGGGGGHHG", + "GGGWWGGGGG", + "GGGWWGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 3] + }, + { + "unit_id": "xiahou_dun", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 4] + }, + { + "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 } + }, + { + "unit_id": "yellow_turban_2", + "name": "Peasant Rebel", + "class_id": "infantry", + "team": "enemy", + "level": 1, + "pos": [8, 5], + "base": { "hp": 30, "atk": 10, "def": 5 } + }, + { + "unit_id": "yellow_turban_3", + "name": "Rebel Archer", + "class_id": "archer", + "team": "enemy", + "level": 1, + "pos": [7, 6], + "base": { "hp": 26, "atk": 9, "def": 4 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Cao Cao engages the Yellow Turbans." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "The rebels are scattered, but their numbers will swell if we hesitate." }, + { "speaker": "Xiahou Dun", "text": "Then we strike now. I will open a path through their front line." } + ] + } + ] + }, + { + "id": "turn_2_warning", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { "type": "log", "text": "The Yellow Turbans are looking for an opening." } + ] + } + ], + "rewards": { + "gold": 300, + "items": ["bean"], + "join_officers": ["xiahou_yuan", "cao_ren"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Dun", "text": "The rebels are scattered. Yingchuan should breathe easier tonight." }, + { "speaker": "Cao Cao", "text": "This is only the first fire. If the court cannot move quickly, we must." }, + { "speaker": "Xiahou Yuan", "text": "Then call on us for the next campaign. We will ride with you." }, + { "speaker": "Cao Ren", "text": "The clan stands ready, my lord." } + ] +} diff --git a/data/scenarios/002_sishui_gate.json b/data/scenarios/002_sishui_gate.json new file mode 100644 index 0000000..606ee49 --- /dev/null +++ b/data/scenarios/002_sishui_gate.json @@ -0,0 +1,201 @@ +{ + "id": "002_sishui_gate", + "name": "Sishui Gate Vanguard", + "objectives": { + "victory": "Defeat Hua Xiong's vanguard.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 9." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_2_warning" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 9, "team": "player" } + ] + }, + "briefing": { + "title": "Sishui Gate Vanguard", + "location": "Sishui Gate, 190 CE", + "lines": [ + "The coalition gathers to challenge Dong Zhuo.", + "Cao Cao leads a small vanguard to test Hua Xiong's defenses." + ] + }, + "shop": { + "items": [ + "bean", + "cloth_robe", + "leather_armor", + "short_bow", + "training_spear" + ] + }, + "roster": { + "max_units": 3, + "required_officers": ["cao_cao"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 3], [2, 4], [2, 5]] + }, + "map": { + "width": 12, + "height": 9, + "terrain": [ + "GGGGGRRGGGGG", + "GGGFFRRGHHGG", + "GGGFFRRGHHGG", + "GGGGGRRGGGGG", + "RRRRRRRRRRRR", + "GGGGGRRGGGGG", + "GGHGGFFGGGGG", + "GGHGGFFGGWWG", + "GGGGGGGGGWWG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch2", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "xiahou_dun_ch2", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "xiahou_yuan_ch2", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "cao_ren_ch2", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "hua_xiong_vanguard", + "name": "Hua Xiong Vanguard", + "class_id": "cavalry", + "team": "enemy", + "level": 2, + "pos": [9, 4], + "base": { "hp": 44, "atk": 15, "def": 7 } + }, + { + "unit_id": "liang_spearman_1", + "name": "Liang Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 2, + "pos": [8, 3], + "base": { "hp": 34, "atk": 12, "def": 6 } + }, + { + "unit_id": "liang_spearman_2", + "name": "Liang Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 2, + "pos": [8, 5], + "base": { "hp": 34, "atk": 12, "def": 6 } + }, + { + "unit_id": "liang_archer", + "name": "Liang Archer", + "class_id": "archer", + "team": "enemy", + "level": 2, + "pos": [10, 2], + "base": { "hp": 28, "atk": 10, "def": 4 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "The coalition vanguard moves toward Sishui Gate." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Hua Xiong's vanguard is testing the coalition's nerve. We cannot give them ground." }, + { "speaker": "Xiahou Yuan", "text": "Their archers hold the ridge. I will keep pressure on their flank." } + ] + } + ] + }, + { + "id": "turn_2_warning", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { "type": "log", "text": "Hua Xiong's men tighten their formation." }, + { + "type": "set_objective", + "victory": "Defeat Hua Xiong's vanguard and reinforcements.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 9." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "liang_reinforcement_1", + "name": "Liang Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 2, + "pos": [11, 3], + "base": { "hp": 36, "atk": 12, "def": 6 } + }, + { + "unit_id": "liang_reinforcement_2", + "name": "Liang Guard", + "class_id": "infantry", + "team": "enemy", + "level": 2, + "pos": [11, 5], + "base": { "hp": 34, "atk": 11, "def": 7 } + } + ] + } + ] + } + ], + "post_battle_choices": [ + { + "id": "pursue_dong_zhuo", + "label": "Pursue Dong Zhuo", + "description": "Cao Cao presses the retreating army while Cao Ren secures Sishui.", + "leave_officers": ["cao_ren"], + "set_flags": { "pursued_dong_zhuo": true, "regrouped_after_sishui": false } + }, + { + "id": "regroup_coalition", + "label": "Regroup", + "description": "The vanguard pauses to gather supplies.", + "set_flags": { "pursued_dong_zhuo": false, "regrouped_after_sishui": true } + } + ], + "rewards": { + "gold": 500, + "items": ["bean"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Yuan", "text": "Sishui Gate is broken, but Dong Zhuo's main force is already moving west." }, + { "speaker": "Cao Cao", "text": "The coalition will argue while the enemy escapes. We must decide whether to pursue or regroup." } + ] +} diff --git a/data/scenarios/003_xingyang_ambush.json b/data/scenarios/003_xingyang_ambush.json new file mode 100644 index 0000000..d6328c6 --- /dev/null +++ b/data/scenarios/003_xingyang_ambush.json @@ -0,0 +1,330 @@ +{ + "id": "003_xingyang_ambush", + "name": "Xingyang Ambush", + "objectives": { + "victory": "Defeat Xu Rong's rearguard.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 11." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_ambush" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 11, "team": "player" } + ] + }, + "briefing": { + "title": "Xingyang Ambush", + "location": "Xingyang, 190 CE", + "lines": [ + "Dong Zhuo abandons Luoyang and retreats west.", + "Cao Cao's vanguard turns toward the enemy rearguard near Xingyang.", + "The road narrows ahead. Keep the vanguard together and watch the forest line." + ], + "conditional_lines": [ + { + "campaign_flags": { "pursued_dong_zhuo": true }, + "lines": [ + "The rapid pursuit leaves little time for scouting, but may catch Xu Rong before he regroups." + ] + }, + { + "campaign_flags": { "regrouped_after_sishui": true }, + "lines": [ + "Scouts from Sishui report suspicious movement in the trees. Expect an ambush and prepare accordingly." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "pursued_dong_zhuo": true }, + "items": ["hand_axe"] + }, + { + "campaign_flags": { "regrouped_after_sishui": true }, + "items": ["leather_armor"] + } + ] + }, + "roster": { + "max_units": 3, + "required_officers": ["cao_cao"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 3], [2, 4], [2, 5]] + }, + "map": { + "width": 12, + "height": 9, + "terrain": [ + "GGGFFRRGGGGG", + "GGGFFRRGHHGG", + "GGGGGRRGHHGG", + "RRRRRRRRRRRR", + "GGGGFRRFGGGG", + "GGGHFRRFGGGG", + "GGGHHRRGFFGG", + "GGGGGRRGFFGG", + "GGWWGRRGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch3", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "xiahou_dun_ch3", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "xiahou_yuan_ch3", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "cao_ren_ch3", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xu_rong_rearguard", + "name": "Xu Rong Rearguard", + "class_id": "cavalry", + "team": "enemy", + "level": 3, + "pos": [9, 4], + "base": { "hp": 46, "atk": 15, "def": 8 } + }, + { + "unit_id": "liang_spearman", + "name": "Liang Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 3, + "pos": [7, 3], + "base": { "hp": 36, "atk": 12, "def": 7 } + }, + { + "unit_id": "liang_strategist", + "name": "Liang Strategist", + "class_id": "strategist", + "team": "enemy", + "level": 3, + "pos": [8, 5], + "base": { "hp": 30, "mp": 14, "atk": 7, "def": 4, "int": 10 } + }, + { + "unit_id": "rearguard_archer", + "name": "Rearguard Archer", + "class_id": "archer", + "team": "enemy", + "level": 3, + "pos": [8, 2], + "base": { "hp": 30, "atk": 11, "def": 5 } + } + ], + "events": [ + { + "id": "opening_pursuit", + "once": true, + "when": { "type": "battle_start", "campaign_flags": { "pursued_dong_zhuo": true } }, + "actions": [ + { "type": "log", "text": "Cao Cao's rapid pursuit closes on Dong Zhuo's rearguard." } + ] + }, + { + "id": "opening_regroup", + "once": true, + "when": { "type": "battle_start", "campaign_flags": { "regrouped_after_sishui": true } }, + "actions": [ + { "type": "log", "text": "Cao Cao advances after gathering scouts and supplies at Sishui." } + ] + }, + { + "id": "opening_dialogue_pursuit", + "once": true, + "when": { "type": "battle_begin", "campaign_flags": { "pursued_dong_zhuo": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "The coalition hesitates while Dong Zhuo escapes. We will not let this chance pass." }, + { "speaker": "Xiahou Dun", "text": "The road is too quiet. If they mean to spring a trap, let them try it." }, + { "speaker": "Xiahou Yuan", "text": "I will watch the ridges. Their bowmen will favor the high ground." } + ] + } + ] + }, + { + "id": "opening_dialogue_regroup", + "once": true, + "when": { "type": "battle_begin", "campaign_flags": { "regrouped_after_sishui": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "The delay at Sishui bought us scouts and fresh supplies. Now we move with clear eyes." }, + { "speaker": "Cao Ren", "text": "Our riders report movement in the forest line. Xu Rong may be waiting ahead." }, + { "speaker": "Xiahou Yuan", "text": "Then we draw them out and break their trap before it closes." } + ] + } + ] + }, + { + "id": "turn_3_ambush", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { "type": "log", "text": "The forest line shifts as Xu Rong's ambush springs." } + ] + }, + { + "id": "turn_3_ambush_pursuit", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3, "campaign_flags": { "pursued_dong_zhuo": true } }, + "actions": [ + { "type": "log", "text": "Xu Rong's ambush force closes in from the forest." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Xu Rong", "text": "Cao Cao took the bait. Seal the road and cut down his vanguard." }, + { "speaker": "Cao Cao", "text": "So this was their plan. Hold formation and break through!" } + ] + }, + { + "type": "set_objective", + "victory": "Defeat Xu Rong and every ambush unit.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 11." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "ambush_bandit_1", + "name": "Ambush Raider", + "class_id": "bandit", + "team": "enemy", + "level": 3, + "pos": [5, 1], + "base": { "hp": 34, "atk": 12, "def": 5 } + }, + { + "unit_id": "ambush_bandit_2", + "name": "Ambush Raider", + "class_id": "bandit", + "team": "enemy", + "level": 3, + "pos": [5, 7], + "base": { "hp": 34, "atk": 12, "def": 5 } + }, + { + "unit_id": "ambush_archer", + "name": "Ambush Archer", + "class_id": "archer", + "team": "enemy", + "level": 3, + "pos": [10, 6], + "base": { "hp": 28, "atk": 11, "def": 4 } + } + ] + } + ] + }, + { + "id": "turn_3_ambush_regroup", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3, "campaign_flags": { "regrouped_after_sishui": true } }, + "actions": [ + { "type": "log", "text": "Scouts expose Xu Rong's ambush before the trap fully closes." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Xu Rong", "text": "They found our screen too soon. Attack before Cao Cao can withdraw!" }, + { "speaker": "Cao Cao", "text": "Their trap is revealed. Strike the exposed units and keep the road open." } + ] + }, + { + "type": "set_objective", + "victory": "Defeat Xu Rong and the exposed ambush units.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 11." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "ambush_bandit_scouted", + "name": "Exposed Raider", + "class_id": "bandit", + "team": "enemy", + "level": 3, + "pos": [5, 1], + "base": { "hp": 32, "atk": 11, "def": 5 } + }, + { + "unit_id": "ambush_archer_scouted", + "name": "Exposed Archer", + "class_id": "archer", + "team": "enemy", + "level": 3, + "pos": [10, 6], + "base": { "hp": 26, "atk": 10, "def": 4 } + } + ] + } + ] + }, + { + "id": "turn_5_pressure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 5, "campaign_flags": { "pursued_dong_zhuo": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Yuan", "text": "Their line is stretched. Push through the road before they regroup." } + ] + } + ] + }, + { + "id": "turn_5_regroup_pressure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 5, "campaign_flags": { "regrouped_after_sishui": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ren", "text": "Their hidden troops are fewer than expected. The scouts earned their pay." }, + { "speaker": "Cao Cao", "text": "Good. Keep the pressure on Xu Rong before he reforms the line." } + ] + } + ] + } + ], + "rewards": { + "gold": 700, + "items": ["bean", "wine", "iron_sword"] + }, + "post_battle_dialogue": [ + { "speaker": "Cao Cao", "text": "Xu Rong's trap failed, but Dong Zhuo has bought himself time." }, + { "speaker": "Xiahou Dun", "text": "Then we sharpen the army and wait for the next opening." } + ] +} diff --git a/data/scenarios/004_qingzhou_campaign.json b/data/scenarios/004_qingzhou_campaign.json new file mode 100644 index 0000000..e10594a --- /dev/null +++ b/data/scenarios/004_qingzhou_campaign.json @@ -0,0 +1,247 @@ +{ + "id": "004_qingzhou_campaign", + "name": "Qingzhou Campaign", + "objectives": { + "victory": "Defeat the Yellow Turban remnant leaders.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_bandit_push" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 12, "team": "player" } + ] + }, + "briefing": { + "title": "Qingzhou Campaign", + "location": "Qingzhou, 192 CE", + "lines": [ + "After the coalition fractures, Cao Cao turns toward Qingzhou.", + "Yellow Turban remnants threaten the villages and trade roads.", + "Secure the road, defeat the rebel leaders, and gather soldiers for a lasting army." + ], + "conditional_lines": [ + { + "campaign_flags": { "pursued_dong_zhuo": true }, + "lines": [ + "The army is still lean from the rapid pursuit after Sishui, but its veterans are hardened by the ambush at Xingyang." + ] + }, + { + "campaign_flags": { "regrouped_after_sishui": true }, + "lines": [ + "Supplies gathered after Sishui let Cao Cao press into Qingzhou with a steadier line." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor", + "iron_armor" + ] + }, + "roster": { + "max_units": 4, + "required_officers": ["cao_cao"] + }, + "formation": { + "cells": [[1, 4], [1, 5], [2, 3], [2, 4], [2, 5], [2, 6], [3, 4], [3, 5]] + }, + "map": { + "width": 14, + "height": 10, + "terrain": [ + "GGGGGGGGGGGGGG", + "GGGFFFGGGHHGGG", + "GGGFFFGRRHHGGG", + "GGGGGGRRRGGGGG", + "RRRRRRRRRRRRRR", + "GGGGGGRRRGGGGG", + "GGHGGGFFFGGGGG", + "GGHHGGFFFGGWWG", + "GGGGGGGFFGGWWG", + "GGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch4", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "xiahou_dun_ch4", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [2, 4] + }, + { + "unit_id": "xiahou_yuan_ch4", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch4", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "qingzhou_chief", + "name": "Qingzhou Chief", + "class_id": "bandit", + "team": "enemy", + "level": 4, + "pos": [11, 4], + "base": { "hp": 48, "atk": 15, "def": 7 } + }, + { + "unit_id": "yellow_turban_spearman_1", + "name": "Yellow Turban Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 4, + "pos": [9, 3], + "base": { "hp": 38, "atk": 13, "def": 7 } + }, + { + "unit_id": "yellow_turban_spearman_2", + "name": "Yellow Turban Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 4, + "pos": [9, 5], + "base": { "hp": 38, "atk": 13, "def": 7 } + }, + { + "unit_id": "yellow_turban_archer", + "name": "Yellow Turban Archer", + "class_id": "archer", + "team": "enemy", + "level": 4, + "pos": [10, 2], + "base": { "hp": 30, "atk": 11, "def": 5 } + }, + { + "unit_id": "yellow_turban_strategist", + "name": "Yellow Turban Mystic", + "class_id": "strategist", + "team": "enemy", + "level": 4, + "pos": [11, 6], + "base": { "hp": 32, "mp": 16, "atk": 7, "def": 4, "int": 11 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Cao Cao enters Qingzhou to pacify the Yellow Turban remnants." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "A commander needs more than victories. We need men who can hold the land after the banners move on." }, + { "speaker": "Xiahou Dun", "text": "Then we break these remnants and give the villages a reason to trust our flag." }, + { "speaker": "Xiahou Yuan", "text": "Their archers have taken the eastern ridge. I will keep them from harassing the road." } + ] + } + ] + }, + { + "id": "turn_3_bandit_push", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { "type": "log", "text": "Hidden Yellow Turban raiders rush from the woods." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Qingzhou Chief", "text": "The villages are ours to feed from. Drive Cao Cao back to the west!" }, + { "speaker": "Cao Cao", "text": "They reveal themselves. Hold the road and cut them down in detail." } + ] + }, + { + "type": "set_objective", + "victory": "Defeat the Qingzhou chief and every raider.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "qingzhou_raider_north", + "name": "Qingzhou Raider", + "class_id": "bandit", + "team": "enemy", + "level": 4, + "pos": [6, 1], + "base": { "hp": 34, "atk": 12, "def": 5 } + }, + { + "unit_id": "qingzhou_raider_south", + "name": "Qingzhou Raider", + "class_id": "bandit", + "team": "enemy", + "level": 4, + "pos": [6, 8], + "base": { "hp": 34, "atk": 12, "def": 5 } + }, + { + "unit_id": "qingzhou_raider_archer", + "name": "Qingzhou Archer", + "class_id": "archer", + "team": "enemy", + "level": 4, + "pos": [13, 7], + "base": { "hp": 28, "atk": 11, "def": 4 } + } + ] + } + ] + }, + { + "id": "turn_5_dian_wei_rumor", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 5 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "text": "My lord, a fierce warrior has been seen defending villagers east of the ridge." }, + { "speaker": "Cao Cao", "text": "Remember his name. Men who protect the weak are worth more than plunderers." } + ] + } + ] + } + ], + "rewards": { + "gold": 800, + "items": ["bean", "hand_axe", "iron_armor"], + "join_officers": ["dian_wei"] + }, + "post_battle_dialogue": [ + { "speaker": "Dian Wei", "text": "You fought the raiders instead of bargaining with them. I can respect that." }, + { "speaker": "Cao Cao", "text": "Then stand with me, Dian Wei. I need warriors who can guard more than their own fame." }, + { "speaker": "Dian Wei", "text": "Give me a place in the line, and I will hold it." } + ] +} diff --git a/data/scenarios/005_puyang_raid.json b/data/scenarios/005_puyang_raid.json new file mode 100644 index 0000000..e0ecfd7 --- /dev/null +++ b/data/scenarios/005_puyang_raid.json @@ -0,0 +1,267 @@ +{ + "id": "005_puyang_raid", + "name": "Puyang Raid", + "objectives": { + "victory": "Defeat Lu Bu's forward guard.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 11." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_lu_bu_arrives" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 11, "team": "player" } + ] + }, + "briefing": { + "title": "Puyang Raid", + "location": "Puyang, 194 CE", + "lines": [ + "Lu Bu has seized momentum in Yan Province.", + "Cao Cao launches a night raid near Puyang to break the forward guard before the main cavalry can gather.", + "Dian Wei joins the front line as Cao Cao's personal shield." + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "iron_armor" + ] + }, + "roster": { + "max_units": 4, + "required_officers": ["cao_cao", "dian_wei"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 3], [2, 4], [2, 5], [3, 4]] + }, + "map": { + "width": 14, + "height": 10, + "terrain": [ + "GGGGGHHGGGGGGG", + "GGGFFHHGGGFFGG", + "GGGFFRRRRRFFGG", + "GGGGGRGGGRGGGG", + "RRRRRRRRRRRRRR", + "GGGGGRGGGRGGGG", + "GGFFFRRRRRFFGG", + "GGFFFHHGGGFFGG", + "GGGGGHHGGGWWGG", + "GGGGGGGGGGWWGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch5", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "dian_wei_ch5", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch5", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "xiahou_yuan_ch5", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch5", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "zhang_liao_vanguard", + "name": "Zhang Liao Vanguard", + "class_id": "cavalry", + "team": "enemy", + "level": 5, + "pos": [10, 4], + "base": { "hp": 48, "atk": 16, "def": 8, "agi": 10 } + }, + { + "unit_id": "gao_shun_guard", + "name": "Gao Shun Guard", + "class_id": "infantry", + "team": "enemy", + "level": 5, + "pos": [9, 3], + "base": { "hp": 44, "atk": 14, "def": 9 } + }, + { + "unit_id": "puyang_cavalry_1", + "name": "Puyang Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 5, + "pos": [11, 3], + "base": { "hp": 38, "atk": 13, "def": 7 } + }, + { + "unit_id": "puyang_cavalry_2", + "name": "Puyang Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 5, + "pos": [11, 5], + "base": { "hp": 38, "atk": 13, "def": 7 } + }, + { + "unit_id": "puyang_archer", + "name": "Puyang Archer", + "class_id": "archer", + "team": "enemy", + "level": 5, + "pos": [10, 2], + "base": { "hp": 30, "atk": 12, "def": 5 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Cao Cao's raiding force slips toward Puyang under cover of darkness." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Lu Bu's cavalry is dangerous once it gathers speed. We strike before the whole camp wakes." }, + { "speaker": "Dian Wei", "text": "Stay behind me, my lord. Any rider who reaches you answers to my axe." }, + { "speaker": "Xiahou Dun", "text": "Good. We cut open their guard and withdraw before dawn." } + ] + } + ] + }, + { + "id": "turn_2_alarm", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { "type": "log", "text": "Puyang's forward camp sounds the alarm." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang Liao", "text": "Cao Cao comes in the night. Riders, close the road and pin him here." }, + { "speaker": "Cao Cao", "text": "The alarm is raised. Break their line before Lu Bu himself arrives." } + ] + } + ] + }, + { + "id": "turn_3_lu_bu_arrives", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { "type": "log", "text": "Lu Bu's banner appears beyond the eastern road." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Lu Bu", "text": "Cao Cao dares raid Puyang? Then let him see how fast my riders wake." }, + { "speaker": "Dian Wei", "text": "That one has a loud voice. I will hold the road while you finish the guard." } + ] + }, + { + "type": "set_objective", + "victory": "Defeat Zhang Liao's guard and the arriving riders.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 11." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "lu_bu_banner", + "name": "Lu Bu Banner Guard", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 6, + "pos": [13, 4], + "base": { "hp": 54, "atk": 18, "def": 9, "agi": 12 } + }, + { + "unit_id": "lu_bu_rider_north", + "name": "Lu Bu Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 5, + "pos": [12, 2], + "base": { "hp": 38, "atk": 14, "def": 7 } + }, + { + "unit_id": "lu_bu_rider_south", + "name": "Lu Bu Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 5, + "pos": [12, 6], + "base": { "hp": 38, "atk": 14, "def": 7 } + } + ] + } + ] + }, + { + "id": "turn_5_pressure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 5 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Dian Wei", "text": "Their riders hit hard, but their line bends. Give the word and I will split it." }, + { "speaker": "Cao Cao", "text": "Press them. We leave Puyang with their vanguard broken." } + ] + } + ] + } + ], + "rewards": { + "gold": 900, + "items": ["bean", "wine", "iron_sword"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Dun", "text": "Lu Bu's men still hold Puyang, but their forward guard will remember tonight." }, + { "speaker": "Dian Wei", "text": "They came fast. Next time, let them come closer." }, + { "speaker": "Cao Cao", "text": "We have measured their strength. Now we choose whether to fortify Yan Province or strike again." } + ], + "post_battle_choices": [ + { + "id": "fortify_yan_province", + "label": "Fortify Yan Province", + "description": "Secure supply roads before another clash with Lu Bu.", + "set_flags": { "fortified_yan_province": true, "pressed_lu_bu": false } + }, + { + "id": "press_lu_bu", + "label": "Press Lu Bu", + "description": "Keep pressure on Puyang while Lu Bu's guard is shaken.", + "set_flags": { "fortified_yan_province": false, "pressed_lu_bu": true } + } + ] +} diff --git a/data/scenarios/006_dingtao_counterattack.json b/data/scenarios/006_dingtao_counterattack.json new file mode 100644 index 0000000..dd3369c --- /dev/null +++ b/data/scenarios/006_dingtao_counterattack.json @@ -0,0 +1,305 @@ +{ + "id": "006_dingtao_counterattack", + "name": "Dingtao Counterattack", + "objectives": { + "victory": "Defeat Lu Bu's counterattack force.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_counterattack" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 12, "team": "player" } + ] + }, + "briefing": { + "title": "Dingtao Counterattack", + "location": "Dingtao, 194 CE", + "lines": [ + "After the raid at Puyang, Lu Bu moves to punish Cao Cao's army near Dingtao.", + "The field is broken by ridges and roads. Keep the line tight and blunt the cavalry charge." + ], + "conditional_lines": [ + { + "campaign_flags": { "fortified_yan_province": true }, + "lines": [ + "Fortified supply roads give Cao Cao time to choose the battlefield and prepare reserves." + ] + }, + { + "campaign_flags": { "pressed_lu_bu": true }, + "lines": [ + "Cao Cao's pressure has kept Lu Bu off balance, but the army enters Dingtao with little rest." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "fortified_yan_province": true }, + "items": ["iron_armor"] + }, + { + "campaign_flags": { "pressed_lu_bu": true }, + "items": ["war_axe"] + } + ] + }, + "roster": { + "max_units": 4, + "required_officers": ["cao_cao", "dian_wei"] + }, + "formation": { + "cells": [[1, 4], [1, 5], [2, 3], [2, 4], [2, 5], [2, 6], [3, 4], [3, 5]] + }, + "map": { + "width": 14, + "height": 10, + "terrain": [ + "GGGGGGGGGGGGGG", + "GGGFFGGGHHGGGG", + "GGGFFRRRHHGGGG", + "GGGGGRRRGGGGGG", + "RRRRRRRRRRRRRR", + "GGGGGRRRGGGGGG", + "GGHHGFFFGRRGGG", + "GGHHGFFFGGGWWG", + "GGGGGGFFGGGWWG", + "GGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch6", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "dian_wei_ch6", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch6", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "xiahou_yuan_ch6", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch6", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "lu_bu_counterattack", + "name": "Lu Bu Counterattack", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 7, + "pos": [10, 4], + "base": { "hp": 58, "atk": 19, "def": 10, "agi": 12 } + }, + { + "unit_id": "zhang_liao_pursuer", + "name": "Zhang Liao Pursuer", + "class_id": "cavalry", + "team": "enemy", + "level": 6, + "pos": [9, 3], + "base": { "hp": 46, "atk": 16, "def": 8, "agi": 11 } + }, + { + "unit_id": "gao_shun_line", + "name": "Gao Shun Line", + "class_id": "infantry", + "team": "enemy", + "level": 6, + "pos": [9, 5], + "base": { "hp": 46, "atk": 15, "def": 10 } + }, + { + "unit_id": "dingtao_archer", + "name": "Dingtao Archer", + "class_id": "archer", + "team": "enemy", + "level": 6, + "pos": [11, 2], + "base": { "hp": 32, "atk": 12, "def": 5 } + }, + { + "unit_id": "dingtao_strategist", + "name": "Dingtao Strategist", + "class_id": "strategist", + "team": "enemy", + "level": 6, + "pos": [11, 6], + "base": { "hp": 34, "mp": 18, "atk": 7, "def": 5, "int": 12 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Lu Bu's counterattack sweeps toward Dingtao." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Lu Bu wants a swift decision. We deny him speed and break his vanguard." }, + { "speaker": "Dian Wei", "text": "Let the riders come. A road is narrow when I stand in it." }, + { "speaker": "Xiahou Dun", "text": "The center road is their best charge lane. We hold there, then counter." } + ] + } + ] + }, + { + "id": "turn_3_fortified_reserve", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3, "campaign_flags": { "fortified_yan_province": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "text": "The eastern riders are moving exactly where the reserve scouts predicted." }, + { "speaker": "Cao Cao", "text": "Good. Let them spend their charge against prepared ground." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "screened_lu_bu_rider", + "name": "Screened Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 6, + "pos": [12, 3], + "base": { "hp": 38, "atk": 14, "def": 7 } + }, + { + "unit_id": "screened_lu_bu_archer", + "name": "Screened Archer", + "class_id": "archer", + "team": "enemy", + "level": 6, + "pos": [12, 6], + "base": { "hp": 30, "atk": 12, "def": 5 } + } + ] + } + ] + }, + { + "id": "turn_3_pressed_lu_bu_charge", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3, "campaign_flags": { "pressed_lu_bu": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Lu Bu", "text": "Cao Cao chased too far. Trample his tired line!" }, + { "speaker": "Dian Wei", "text": "Tired men still swing hard when cornered." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "fierce_lu_bu_rider", + "name": "Fierce Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 6, + "pos": [12, 2], + "base": { "hp": 40, "atk": 15, "def": 7 } + }, + { + "unit_id": "fierce_lu_bu_guard", + "name": "Fierce Guard", + "class_id": "infantry", + "team": "enemy", + "level": 6, + "pos": [13, 4], + "base": { "hp": 42, "atk": 14, "def": 9 } + }, + { + "unit_id": "fierce_lu_bu_rider_south", + "name": "Fierce Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 6, + "pos": [12, 6], + "base": { "hp": 40, "atk": 15, "def": 7 } + } + ] + } + ] + }, + { + "id": "turn_3_counterattack", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { "type": "log", "text": "Lu Bu commits his second wave." }, + { + "type": "set_objective", + "victory": "Defeat Lu Bu's counterattack and every second-wave unit.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + } + ] + }, + { + "id": "turn_5_hold_center", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 5 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Their charge is spent. Now strike at the officers holding the line together." }, + { "speaker": "Xiahou Yuan", "text": "I have their archers marked. One clear shot can open the road." } + ] + } + ] + } + ], + "rewards": { + "gold": 1000, + "items": ["bean", "wine", "war_axe"] + }, + "post_battle_dialogue": [ + { "speaker": "Cao Cao", "text": "Lu Bu's charge is powerful, but not invincible." }, + { "speaker": "Dian Wei", "text": "His riders bleed like anyone else." }, + { "speaker": "Xiahou Dun", "text": "Then Yan Province is not lost. We keep fighting." } + ] +} diff --git a/data/scenarios/007_xian_emperor_escort.json b/data/scenarios/007_xian_emperor_escort.json new file mode 100644 index 0000000..76a7f4e --- /dev/null +++ b/data/scenarios/007_xian_emperor_escort.json @@ -0,0 +1,319 @@ +{ + "id": "007_xian_emperor_escort", + "name": "Xian Emperor Escort", + "objectives": { + "victory": "Move Cao Cao to the eastern road marker.", + "defeat": "Cao Cao or the imperial envoy is defeated, or the battle reaches Turn 11." + }, + "conditions": { + "victory": { + "type": "unit_reaches_tile", + "team": "player", + "officer_ids": ["cao_cao"], + "pos": [13, 4] + }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "any_unit_defeated", "unit_ids": ["imperial_envoy_ch7"] }, + { "type": "turn_reached", "turn": 11, "team": "player" } + ] + }, + "briefing": { + "title": "Xian Emperor Escort", + "location": "Luoyang Road, 196 CE", + "lines": [ + "The imperial carriage has slipped out of the ruined capital under pressure from rival warlords.", + "Cao Cao must reach the eastern road marker and secure the escort route before the remnants close in." + ], + "conditional_lines": [ + { + "campaign_flags": { "fortified_yan_province": true }, + "lines": [ + "The fortified roads in Yan Province have kept supplies moving, giving the escort column a steadier pace." + ] + }, + { + "campaign_flags": { "pressed_lu_bu": true }, + "lines": [ + "The army arrives quickly after pressing Lu Bu, but the forced march leaves little time to rest." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "fortified_yan_province": true }, + "items": ["iron_armor"] + }, + { + "campaign_flags": { "pressed_lu_bu": true }, + "items": ["war_axe"] + } + ] + }, + "roster": { + "max_units": 5, + "required_officers": ["cao_cao"], + "required_units": ["imperial_envoy_ch7"] + }, + "formation": { + "cells": [[1, 4], [1, 5], [2, 3], [2, 4], [2, 5], [3, 3], [3, 4], [3, 5]] + }, + "map": { + "width": 15, + "height": 10, + "terrain": [ + "GGGGGGGGGGGGGGG", + "GGGFFGGGHHGGGGG", + "GGGFFRRRHHGGGGG", + "GGGGGRRRGGFFFGG", + "RRRRRRRRRRRRRRR", + "GGGGGRRRGGFFFGG", + "GGHHGFFFGGGRRGG", + "GGHHGFFFGGGWWGG", + "GGGGGGFFGGGWWGG", + "GGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch7", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "imperial_envoy_ch7", + "name": "Imperial Envoy", + "class_id": "infantry", + "team": "player", + "level": 1, + "controllable": false, + "persist_progression": false, + "ai_target_priority": 6, + "pos": [1, 5], + "base": { "hp": 28, "mp": 0, "atk": 1, "def": 4, "int": 8, "agi": 5 } + }, + { + "unit_id": "xiahou_dun_ch7", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [2, 4] + }, + { + "unit_id": "dian_wei_ch7", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch7", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch7", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 4] + }, + { + "unit_id": "li_jue_vanguard", + "name": "Li Jue Vanguard", + "class_id": "cavalry", + "team": "enemy", + "level": 7, + "pos": [9, 4], + "base": { "hp": 48, "atk": 16, "def": 8, "agi": 11 } + }, + { + "unit_id": "guo_si_guard", + "name": "Guo Si Guard", + "class_id": "infantry", + "team": "enemy", + "level": 7, + "pos": [10, 5], + "base": { "hp": 48, "atk": 15, "def": 10 } + }, + { + "unit_id": "escort_road_archer", + "name": "Road Archer", + "class_id": "archer", + "team": "enemy", + "level": 7, + "pos": [11, 3], + "base": { "hp": 34, "atk": 13, "def": 6 } + }, + { + "unit_id": "escort_hill_bandit", + "name": "Hill Bandit", + "class_id": "bandit", + "team": "enemy", + "level": 7, + "pos": [8, 2], + "base": { "hp": 42, "atk": 14, "def": 7 } + }, + { + "unit_id": "escort_strategist", + "name": "Court Remnant", + "class_id": "strategist", + "team": "enemy", + "level": 7, + "pos": [12, 6], + "base": { "hp": 36, "mp": 20, "atk": 7, "def": 6, "int": 13 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "The imperial escort road lies beyond the enemy vanguard." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Do not chase every remnant. Our task is the road ahead." }, + { "speaker": "Xiahou Dun", "text": "Then we punch through the center and keep you moving." }, + { "speaker": "Dian Wei", "text": "I will keep their blades away from the envoy." } + ] + } + ] + }, + { + "id": "turn_3_rear_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "text": "Enemy riders are circling behind us!" }, + { "speaker": "Cao Cao", "text": "Then the envoy cannot linger. Forward." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "rear_pressure_rider", + "name": "Rear Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 7, + "pos": [4, 1], + "base": { "hp": 40, "atk": 15, "def": 7, "agi": 10 } + }, + { + "unit_id": "rear_pressure_bandit", + "name": "Rear Bandit", + "class_id": "bandit", + "team": "enemy", + "level": 7, + "pos": [5, 7], + "base": { "hp": 40, "atk": 14, "def": 7 } + } + ] + } + ] + }, + { + "id": "midroad_ambush", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "pos": [7, 4] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Imperial Envoy", "text": "There are banners moving in the northern trees!" }, + { "speaker": "Cao Cao", "text": "So the road was bait. Keep the envoy covered and break through." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "midroad_ambush_guard", + "name": "Ambush Guard", + "class_id": "infantry", + "team": "enemy", + "level": 7, + "pos": [7, 2], + "base": { "hp": 42, "atk": 14, "def": 9 } + }, + { + "unit_id": "midroad_ambush_archer", + "name": "Ambush Archer", + "class_id": "archer", + "team": "enemy", + "level": 7, + "pos": [8, 6], + "base": { "hp": 32, "atk": 13, "def": 6 } + } + ] + } + ] + }, + { + "id": "turn_5_road_marker", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 5 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Yuan", "text": "The eastern road marker is in sight. Cao Cao only needs a clear lane." }, + { "speaker": "Cao Cao", "text": "Good. Hold the flanks and do not let the rear pressure break our pace." } + ] + } + ] + } + ], + "rewards": { + "gold": 1100, + "items": ["bean", "iron_armor"] + }, + "post_battle_dialogue": [ + { "speaker": "Cao Cao", "text": "The Son of Heaven is safe on the road. That changes every banner in the realm." }, + { "speaker": "Xiahou Dun", "text": "Then every rival will look at us differently after today." }, + { "speaker": "Cao Cao", "text": "Let them. Authority is also a battlefield." } + ], + "post_battle_choices": [ + { + "id": "secure_imperial_court", + "label": "Secure the Court", + "description": "Cao Cao focuses on stabilizing the imperial court.", + "set_flags": { "secured_imperial_court": true }, + "gold": 200, + "items": ["imperial_seal"] + }, + { + "id": "pursue_li_jue_remnants", + "label": "Pursue Remnants", + "description": "The army presses the scattered warlord remnants.", + "set_flags": { "pursued_li_jue_remnants": true }, + "items": ["war_drum", "training_spear", "bean", "wine"] + } + ] +} diff --git a/data/scenarios/008_wan_castle_escape.json b/data/scenarios/008_wan_castle_escape.json new file mode 100644 index 0000000..51f2dca --- /dev/null +++ b/data/scenarios/008_wan_castle_escape.json @@ -0,0 +1,401 @@ +{ + "id": "008_wan_castle_escape", + "name": "Wan Castle Escape", + "objectives": { + "victory": "Move Cao Cao to the western road marker.", + "defeat": "Cao Cao or Cao Ang is defeated, or the battle reaches Turn 12." + }, + "conditions": { + "victory": { + "type": "unit_reaches_tile", + "team": "player", + "officer_ids": ["cao_cao"], + "pos": [1, 4] + }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "any_unit_defeated", "unit_ids": ["cao_ang_ch8"] }, + { "type": "turn_reached", "turn": 12, "team": "player" } + ] + }, + "briefing": { + "title": "Wan Castle Escape", + "location": "Wancheng, 197 CE", + "lines": [ + "Zhang Xiu welcomes Cao Cao into Wan Castle, but Jia Xu's counsel turns the banquet into a trap.", + "The eastern gate is collapsing behind the army. Escort Cao Ang and move Cao Cao to the western road marker before the ambush closes." + ], + "conditional_lines": [ + { + "campaign_flags": { "secured_imperial_court": true }, + "lines": [ + "The imperial court's seal has opened a few guarded roads, giving Cao Cao one narrow escape lane through the west." + ] + }, + { + "campaign_flags": { "pursued_li_jue_remnants": true }, + "lines": [ + "The army's hard pursuit after the imperial escort keeps the vanguard sharp, but Zhang Xiu's scouts know Cao Cao's marching habits." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "secured_imperial_court": true }, + "items": ["imperial_seal"] + }, + { + "campaign_flags": { "pursued_li_jue_remnants": true }, + "items": ["war_drum"] + } + ] + }, + "roster": { + "max_units": 5, + "required_officers": ["cao_cao", "dian_wei"], + "required_units": ["cao_ang_ch8"] + }, + "formation": { + "cells": [[10, 3], [10, 4], [10, 5], [11, 3], [11, 4], [11, 5], [12, 3], [12, 4], [12, 5]] + }, + "map": { + "width": 15, + "height": 10, + "terrain": [ + "GGGGGHHGGGFFFFG", + "GGFFGHHGGGFFFFG", + "GGFFRRRRRRGGGGG", + "GGGGRGGGGGRRRGG", + "RRRRRRRRRRRRRRR", + "GGGGRGGGGGRRRGG", + "GGFFRRRGGGFFFFG", + "GGFFGHHGGGFFFFG", + "GGGGGHHGGGWWGGG", + "GGGGGGGGGGWWGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch8", + "officer_id": "cao_cao", + "team": "player", + "pos": [12, 4] + }, + { + "unit_id": "cao_ang_ch8", + "name": "Cao Ang", + "class_id": "cavalry", + "team": "player", + "level": 4, + "controllable": false, + "persist_progression": false, + "ai_target_priority": 5, + "pos": [12, 5], + "base": { "hp": 32, "mp": 2, "atk": 9, "def": 6, "int": 6, "agi": 8 } + }, + { + "unit_id": "dian_wei_ch8", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [11, 4] + }, + { + "unit_id": "xiahou_dun_ch8", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [12, 3] + }, + { + "unit_id": "xiahou_yuan_ch8", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [11, 3] + }, + { + "unit_id": "cao_ren_ch8", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [11, 5] + }, + { + "unit_id": "zhang_xiu_vanguard", + "name": "Zhang Xiu Vanguard", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [6, 4], + "base": { "hp": 52, "atk": 17, "def": 9, "agi": 12 } + }, + { + "unit_id": "wan_castle_guard_north", + "name": "Wan Castle Guard", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [5, 3], + "base": { "hp": 46, "atk": 15, "def": 10 } + }, + { + "unit_id": "wan_castle_guard_south", + "name": "Wan Castle Guard", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [5, 5], + "base": { "hp": 46, "atk": 15, "def": 10 } + }, + { + "unit_id": "jia_xu_strategist", + "name": "Jia Xu Strategist", + "class_id": "strategist", + "team": "enemy", + "level": 8, + "pos": [4, 6], + "skills": ["blaze"], + "base": { "hp": 38, "mp": 22, "atk": 7, "def": 6, "int": 14 } + }, + { + "unit_id": "wan_castle_archer", + "name": "Wan Castle Archer", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [6, 2], + "base": { "hp": 34, "atk": 13, "def": 6 } + }, + { + "unit_id": "southern_ambusher", + "name": "Southern Ambusher", + "class_id": "bandit", + "team": "enemy", + "level": 8, + "pos": [7, 7], + "base": { "hp": 42, "atk": 15, "def": 7 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Wan Castle's gates close behind Cao Cao's column." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "The music stopped too cleanly. This is Jia Xu's work." }, + { "speaker": "Dian Wei", "text": "Then I hold the gate while you cut west." }, + { "speaker": "Cao Ang", "text": "Father, the road is narrow. I will stay close." } + ] + } + ] + }, + { + "id": "turn_2_gate_closes", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang Xiu", "text": "Close the eastern gate. Cao Cao leaves Wan Castle only through our blades." }, + { "speaker": "Jia Xu", "text": "Do not chase the guards. Strike the road and separate him from his son." } + ] + }, + { + "type": "set_objective", + "victory": "Break through the western road and move Cao Cao to the escape marker.", + "defeat": "Cao Cao or Cao Ang is defeated, or the battle reaches Turn 12." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "east_gate_rider_north", + "name": "East Gate Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [13, 2], + "base": { "hp": 40, "atk": 15, "def": 8, "agi": 10 } + }, + { + "unit_id": "east_gate_rider_south", + "name": "East Gate Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [13, 6], + "base": { "hp": 40, "atk": 15, "def": 8, "agi": 10 } + } + ] + } + ] + }, + { + "id": "court_escape_lane", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 3, "campaign_flags": { "secured_imperial_court": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Imperial Guard", "text": "The court seal opens the west postern. We can delay their riders for a moment." }, + { "speaker": "Cao Cao", "text": "Then one moment is enough. Keep the road open." } + ] + }, + { + "type": "spawn_deployment", + "deployment": { + "unit_id": "imperial_guard_support_ch8", + "name": "Imperial Guard", + "class_id": "infantry", + "team": "player", + "level": 6, + "controllable": false, + "persist_progression": false, + "pos": [9, 4], + "base": { "hp": 34, "atk": 10, "def": 9, "int": 6, "agi": 6 } + } + } + ] + }, + { + "id": "remnant_scout_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3, "campaign_flags": { "pursued_li_jue_remnants": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Wan Scout", "text": "That is the same vanguard that chased Li Jue's remnants. Cut off their old marching path." }, + { "speaker": "Xiahou Dun", "text": "They know our pace. Then we change it by force." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "remnant_scout_rider", + "name": "Remnant Scout Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [10, 1], + "base": { "hp": 38, "atk": 14, "def": 7, "agi": 11 } + }, + { + "unit_id": "remnant_scout_archer", + "name": "Remnant Scout Archer", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [10, 7], + "base": { "hp": 32, "atk": 13, "def": 5 } + } + ] + } + ] + }, + { + "id": "midroad_trap", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "pos": [8, 4] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ang", "text": "Archers in the trees!" }, + { "speaker": "Dian Wei", "text": "Stay behind the shields. I will make them aim at me." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "midroad_wan_archer_north", + "name": "Wan Ambush Archer", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [8, 2], + "base": { "hp": 32, "atk": 13, "def": 5 } + }, + { + "unit_id": "midroad_wan_archer_south", + "name": "Wan Ambush Archer", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [8, 6], + "base": { "hp": 32, "atk": 13, "def": 5 } + } + ] + } + ] + }, + { + "id": "turn_6_last_push", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 6 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "The western road is close. Do not spend lives chasing revenge inside these walls." }, + { "speaker": "Dian Wei", "text": "Then I spend steel instead. Move." } + ] + } + ] + } + ], + "rewards": { + "gold": 1200, + "items": ["bean", "wine", "iron_sword"] + }, + "post_battle_dialogue": [ + { "speaker": "Cao Ang", "text": "The road is clear. I thought Wan Castle would swallow us." }, + { "speaker": "Dian Wei", "text": "It tried. Its teeth broke first." }, + { "speaker": "Cao Cao", "text": "A trap survived is still a lesson. We will remember Jia Xu." } + ], + "post_battle_choices": [ + { + "id": "hold_wan_gate", + "label": "Hold the Gate", + "description": "Dian Wei holds the rear long enough to recover armor and baggage.", + "set_flags": { "held_wan_gate": true, "swift_wan_escape": false }, + "gold": 100, + "items": ["iron_armor"] + }, + { + "id": "swift_wan_withdrawal", + "label": "Swift Withdrawal", + "description": "Cao Cao leaves the baggage behind to keep the army intact.", + "set_flags": { "held_wan_gate": false, "swift_wan_escape": true }, + "gold": 250, + "items": ["bean", "wine", "short_bow"] + } + ] +} diff --git a/data/scenarios/009_xiapi_siege.json b/data/scenarios/009_xiapi_siege.json new file mode 100644 index 0000000..b308d48 --- /dev/null +++ b/data/scenarios/009_xiapi_siege.json @@ -0,0 +1,369 @@ +{ + "id": "009_xiapi_siege", + "name": "Xiapi Siege", + "objectives": { + "victory": "Hold through the flood-gate operation, then defeat Lu Bu's army.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 13." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_flood_gates" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 13, "team": "player" } + ] + }, + "briefing": { + "title": "Xiapi Siege", + "location": "Xiapi, 198 CE", + "lines": [ + "Lu Bu has withdrawn behind Xiapi's wet walls, trusting his cavalry and Chen Gong's counsel to break the siege.", + "Cao Cao orders the river channels opened. Survive the countercharge until the flood-gate signals confirm that Lu Bu's army has been forced into the open." + ], + "conditional_lines": [ + { + "campaign_flags": { "held_wan_gate": true }, + "lines": [ + "The baggage recovered at Wan Castle keeps the siege line supplied with heavier shields and dry bowstrings." + ] + }, + { + "campaign_flags": { "swift_wan_escape": true }, + "lines": [ + "The army reached Xiapi quickly after leaving Wan Castle behind, but some siege stores are still catching up." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "held_wan_gate": true }, + "items": ["war_drum"] + }, + { + "campaign_flags": { "swift_wan_escape": true }, + "items": ["war_axe"] + } + ] + }, + "roster": { + "max_units": 5, + "required_officers": ["cao_cao", "dian_wei"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 4]] + }, + "map": { + "width": 15, + "height": 10, + "terrain": [ + "GGGGGHHGGGWWGGG", + "GGGFFHHGGGWWGGG", + "GGGFFRRRRGGGGGG", + "GGGGGRRRGGFFFGG", + "RRRRRRRRRRRRRRR", + "GGGGGRRRGGFFFGG", + "GGHHGFFFGGGRRGG", + "GGHHGFFFGGGWWGG", + "GGGGGGFFGGGWWGG", + "GGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch9", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "dian_wei_ch9", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch9", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "xiahou_yuan_ch9", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch9", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "lu_bu_xiapi", + "name": "Lu Bu", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 9, + "pos": [11, 4], + "base": { "hp": 64, "atk": 21, "def": 11, "agi": 13 } + }, + { + "unit_id": "chen_gong_strategist", + "name": "Chen Gong", + "class_id": "strategist", + "team": "enemy", + "level": 9, + "pos": [10, 6], + "skills": ["blaze"], + "base": { "hp": 40, "mp": 24, "atk": 7, "def": 6, "int": 15 } + }, + { + "unit_id": "gao_shun_trap_line", + "name": "Gao Shun Line", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [9, 5], + "base": { "hp": 50, "atk": 16, "def": 11 } + }, + { + "unit_id": "xiapi_rider_north", + "name": "Xiapi Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [9, 3], + "base": { "hp": 42, "atk": 15, "def": 8, "agi": 11 } + }, + { + "unit_id": "xiapi_rider_south", + "name": "Xiapi Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [10, 5], + "base": { "hp": 42, "atk": 15, "def": 8, "agi": 11 } + }, + { + "unit_id": "xiapi_wall_archer", + "name": "Wall Archer", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [12, 2], + "base": { "hp": 34, "atk": 13, "def": 6 } + }, + { + "unit_id": "xiapi_gate_guard", + "name": "Gate Guard", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [12, 6], + "base": { "hp": 46, "atk": 15, "def": 10 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "The siege lines tighten around Xiapi." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Lu Bu trusts his speed even behind walls. We will make the ground choose against him." }, + { "speaker": "Xiahou Dun", "text": "The channels are ready. We only need to hold the line until the signal rises." }, + { "speaker": "Dian Wei", "text": "Then I hold the road. Let the horsemen find me first." } + ] + } + ] + }, + { + "id": "turn_2_lu_bu_charge", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Lu Bu", "text": "Cao Cao hides behind ditches and mud. Riders, break the siege line before the water rises!" }, + { "speaker": "Chen Gong", "text": "Strike the commander if the line bends. The flood plan dies with him." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "lu_bu_flank_rider_north", + "name": "Flank Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [6, 1], + "base": { "hp": 40, "atk": 15, "def": 8, "agi": 11 } + }, + { + "unit_id": "lu_bu_flank_rider_south", + "name": "Flank Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [6, 8], + "base": { "hp": 40, "atk": 15, "def": 8, "agi": 11 } + } + ] + } + ] + }, + { + "id": "turn_3_flood_gates", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 3 }, + "actions": [ + { "type": "log", "text": "The flood-gate signal rises around Xiapi." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "text": "The water is moving. Xiapi's outer detachments are falling back from the low roads!" }, + { "speaker": "Cao Cao", "text": "Good. Now Lu Bu must fight where we can reach him." } + ] + }, + { + "type": "set_objective", + "victory": "Defeat Lu Bu, Chen Gong, and the remaining Xiapi defenders.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 13." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "flooded_gate_guard", + "name": "Sluice Gate Guard", + "class_id": "infantry", + "team": "enemy", + "level": 8, + "pos": [8, 4], + "base": { "hp": 42, "atk": 14, "def": 9 } + }, + { + "unit_id": "flooded_wall_archer", + "name": "Sluice Wall Archer", + "class_id": "archer", + "team": "enemy", + "level": 8, + "pos": [8, 6], + "base": { "hp": 32, "atk": 13, "def": 5 } + } + ] + } + ] + }, + { + "id": "wan_gate_baggage_support", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 4, "campaign_flags": { "held_wan_gate": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ren", "text": "The baggage wagons from Wan have reached the siege line. The reserves are steady." }, + { "speaker": "Cao Cao", "text": "Then press forward carefully. We can afford patience." } + ] + } + ] + }, + { + "id": "swift_wan_counterraid", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 4, "campaign_flags": { "swift_wan_escape": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Chen Gong", "text": "Cao Cao came quickly, but speed thins a siege line. Strike the loose wagons." }, + { "speaker": "Xiahou Yuan", "text": "Their raiders found the supply road. I will cut them off." } + ] + }, + { + "type": "spawn_deployment", + "deployment": { + "unit_id": "supply_road_raider", + "name": "Supply Road Raider", + "class_id": "bandit", + "team": "enemy", + "level": 8, + "pos": [4, 7], + "base": { "hp": 40, "atk": 14, "def": 7, "agi": 9 } + } + } + ] + }, + { + "id": "turn_6_lu_bu_cornered", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 6 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "His cavalry has nowhere clean to run. Close the ring and do not let Chen Gong buy another hour." }, + { "speaker": "Dian Wei", "text": "Then the road ends here." } + ] + } + ] + } + ], + "rewards": { + "gold": 1400, + "items": ["bean", "wine", "war_axe"], + "join_officers": ["guo_jia"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Dun", "text": "Xiapi is broken. Lu Bu's riders will not trouble Yan Province again." }, + { "speaker": "Dian Wei", "text": "Fast horses drown the same as slow ones." }, + { "speaker": "Guo Jia", "text": "Lu Bu's fall leaves Yuan Shao as the real measure of the north." }, + { "speaker": "Cao Cao", "text": "Speed without judgment is only a longer fall. We turn now from survival to rule." } + ], + "post_battle_choices": [ + { + "id": "integrate_xu_province", + "label": "Integrate Xu Province", + "description": "Cao Cao places officials to secure the newly won territory.", + "set_flags": { "integrated_xu_province": true, "pressed_northern_campaign": false }, + "gold": 200, + "items": ["iron_armor"] + }, + { + "id": "press_northern_campaign", + "label": "Press North", + "description": "The army prepares to turn its momentum toward the northern rivals.", + "set_flags": { "integrated_xu_province": false, "pressed_northern_campaign": true }, + "gold": 100, + "items": ["wine", "war_drum"] + } + ] +} diff --git a/data/scenarios/010_white_horse_relief.json b/data/scenarios/010_white_horse_relief.json new file mode 100644 index 0000000..df7a940 --- /dev/null +++ b/data/scenarios/010_white_horse_relief.json @@ -0,0 +1,364 @@ +{ + "id": "010_white_horse_relief", + "name": "White Horse Relief", + "objectives": { + "victory": "Defeat Yan Liang's vanguard after the decoy line is sprung.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_decoy_line" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 12, "team": "player" } + ] + }, + "briefing": { + "title": "White Horse Relief", + "location": "White Horse, 200 CE", + "lines": [ + "Yuan Shao's forward army presses toward White Horse with Yan Liang at its point.", + "Guo Jia proposes a decoy line: draw the vanguard down the road, then strike once its wings stretch too far." + ], + "conditional_lines": [ + { + "campaign_flags": { "integrated_xu_province": true }, + "lines": [ + "Officials left in Xu Province keep the rear quiet, letting Cao Cao commit steadier reserves to the northern front." + ] + }, + { + "campaign_flags": { "pressed_northern_campaign": true }, + "lines": [ + "The army marches quickly from Xiapi, hoping speed can strike Yuan Shao's vanguard before it settles." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "integrated_xu_province": true }, + "items": ["imperial_seal"] + }, + { + "campaign_flags": { "pressed_northern_campaign": true }, + "items": ["war_drum"] + } + ] + }, + "roster": { + "max_units": 6, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 3], [3, 4]] + }, + "map": { + "width": 15, + "height": 10, + "terrain": [ + "GGGGGGHHGGGGGGG", + "GGFFGGHHGGGFFGG", + "GGFFRRRRRRGFFGG", + "GGGGGRGGGRGGGGG", + "RRRRRRRRRRRRRRR", + "GGGGGRGGGRGGGGG", + "GGHHGRRRGGFFFGG", + "GGHHGFFFGGFFFGG", + "GGGGGFFFGGWWGGG", + "GGGGGGGGGGWWGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch10", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch10", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch10", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch10", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch10", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch10", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "yan_liang_vanguard", + "name": "Yan Liang Vanguard", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 10, + "pos": [11, 4], + "base": { "hp": 62, "atk": 20, "def": 11, "agi": 13 } + }, + { + "unit_id": "yuan_shao_spear_line", + "name": "Yuan Spear Line", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [9, 4], + "base": { "hp": 50, "atk": 16, "def": 10 } + }, + { + "unit_id": "yuan_shao_guard", + "name": "Yuan Guard", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [10, 5], + "base": { "hp": 48, "atk": 15, "def": 11 } + }, + { + "unit_id": "white_horse_archer_north", + "name": "White Horse Archer", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [10, 2], + "base": { "hp": 34, "atk": 14, "def": 6 } + }, + { + "unit_id": "white_horse_archer_south", + "name": "White Horse Archer", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [12, 6], + "base": { "hp": 34, "atk": 14, "def": 6 } + }, + { + "unit_id": "yuan_shao_advisor", + "name": "Yuan Advisor", + "class_id": "strategist", + "team": "enemy", + "level": 9, + "pos": [12, 5], + "skills": ["blaze"], + "base": { "hp": 38, "mp": 22, "atk": 6, "def": 6, "int": 14 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Yuan Shao's vanguard advances toward White Horse." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Yan Liang is brave enough to follow a retreating banner. That is the door we open." }, + { "speaker": "Cao Cao", "text": "Then the center yields just enough. When his wings stretch, we cut them." }, + { "speaker": "Xiahou Dun", "text": "A false retreat still needs men willing to look beaten." } + ] + } + ] + }, + { + "id": "turn_2_yan_liang_push", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yan Liang", "text": "Cao Cao's line bends already. Drive straight down the road!" }, + { "speaker": "Guo Jia", "text": "He takes the bait. Hold until the second signal." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yan_liang_rider_north", + "name": "Yan Liang Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [8, 2], + "base": { "hp": 42, "atk": 15, "def": 8, "agi": 11 } + }, + { + "unit_id": "yan_liang_rider_south", + "name": "Yan Liang Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [8, 6], + "base": { "hp": 42, "atk": 15, "def": 8, "agi": 11 } + } + ] + } + ] + }, + { + "id": "turn_3_decoy_line", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 3 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Signal the counterstroke. Yan Liang has outrun his supports." }, + { "speaker": "Guo Jia", "text": "The vanguard is isolated. Strike the horse first, then the spears." } + ] + }, + { + "type": "set_objective", + "victory": "Defeat Yan Liang and every isolated vanguard unit.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "decoy_cutoff_guard", + "name": "Cutoff Guard", + "class_id": "infantry", + "team": "enemy", + "level": 9, + "pos": [7, 4], + "base": { "hp": 44, "atk": 15, "def": 9 } + }, + { + "unit_id": "decoy_cutoff_archer", + "name": "Cutoff Archer", + "class_id": "archer", + "team": "enemy", + "level": 9, + "pos": [7, 5], + "base": { "hp": 32, "atk": 13, "def": 5 } + } + ] + } + ] + }, + { + "id": "xu_province_reserve", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 4, "campaign_flags": { "integrated_xu_province": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ren", "text": "Xu Province's roads are quiet. The reserve wagons have arrived on time." }, + { "speaker": "Cao Cao", "text": "Order and grain win battles before blades touch." } + ] + } + ] + }, + { + "id": "northern_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 4, "campaign_flags": { "pressed_northern_campaign": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Scout", "text": "Cao Cao came quickly. Send the rear riders before his men settle!" }, + { "speaker": "Xiahou Yuan", "text": "More riders from the east. They are trying to punish our speed." } + ] + }, + { + "type": "spawn_deployment", + "deployment": { + "unit_id": "yuan_rear_rider", + "name": "Yuan Rear Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 9, + "pos": [5, 8], + "base": { "hp": 40, "atk": 15, "def": 8, "agi": 11 } + } + } + ] + }, + { + "id": "turn_6_finish_vanguard", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 6 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Yan Liang's formation is no longer an army. It is a collection of frightened men." }, + { "speaker": "Cao Cao", "text": "Then finish this cleanly. White Horse must not remain under threat." } + ] + } + ] + } + ], + "rewards": { + "gold": 1500, + "items": ["bean", "wine", "iron_armor"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Dun", "text": "Yan Liang's charge broke exactly where Guo Jia said it would." }, + { "speaker": "Guo Jia", "text": "Bravery is useful. Predictable bravery is a road sign." }, + { "speaker": "Cao Cao", "text": "White Horse is relieved. Now Yuan Shao must answer us with more than numbers." } + ], + "post_battle_choices": [ + { + "id": "fortify_white_horse", + "label": "Fortify White Horse", + "description": "Cao Cao strengthens the crossing before the main Guandu confrontation.", + "set_flags": { "fortified_white_horse": true, "raided_yuan_supplies": false }, + "gold": 150, + "items": ["iron_armor"] + }, + { + "id": "raid_yuan_supplies", + "label": "Raid Supplies", + "description": "The army uses the vanguard's collapse to harass Yuan Shao's supply train.", + "set_flags": { "fortified_white_horse": false, "raided_yuan_supplies": true }, + "gold": 100, + "items": ["wine", "training_spear"] + } + ] +} diff --git a/data/scenarios/011_yan_ford_pursuit.json b/data/scenarios/011_yan_ford_pursuit.json new file mode 100644 index 0000000..1b12178 --- /dev/null +++ b/data/scenarios/011_yan_ford_pursuit.json @@ -0,0 +1,356 @@ +{ + "id": "011_yan_ford_pursuit", + "name": "Yan Ford Pursuit", + "objectives": { + "victory": "Defeat Wen Chou's countercharge after his riders commit.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_2_wen_chou_charge" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 12, "team": "player" } + ] + }, + "briefing": { + "title": "Yan Ford Pursuit", + "location": "Yan Ford, 200 CE", + "lines": [ + "Yan Liang's defeat shakes Yuan Shao's forward army, but Wen Chou rides to restore its nerve.", + "Cao Cao moves toward Yan Ford, where the road narrows between wooded banks and the river crossing." + ], + "conditional_lines": [ + { + "campaign_flags": { "fortified_white_horse": true }, + "lines": [ + "The reinforced White Horse crossing slows Yuan Shao's messengers, buying time to arrange a tighter pursuit line." + ] + }, + { + "campaign_flags": { "raided_yuan_supplies": true }, + "lines": [ + "Raids against Yuan Shao's baggage train leave Wen Chou's riders angry and hungry, but still eager to strike." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "fortified_white_horse": true }, + "items": ["imperial_seal"] + }, + { + "campaign_flags": { "raided_yuan_supplies": true }, + "items": ["war_axe"] + } + ] + }, + "roster": { + "max_units": 6, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 3], [3, 4]] + }, + "map": { + "width": 16, + "height": 10, + "terrain": [ + "GGGGHHGGGGGGGGGG", + "GGFFHHGGGFFGGGGG", + "GGRRRRRRRGGGHHGG", + "GGGGGGRGGGRRGGGG", + "RRRRRRRRRRRRRRRR", + "GGGGGGRGGGRRGGGG", + "GGHHGFFFGGWWGGGG", + "GGHHGFFFGGWWGGGG", + "GGGGGGFFGGWWGGGG", + "GGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch11", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch11", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch11", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch11", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch11", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch11", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "wen_chou_vanguard", + "name": "Wen Chou Vanguard", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 11, + "pos": [12, 4], + "base": { "hp": 66, "atk": 21, "def": 12, "agi": 14 } + }, + { + "unit_id": "yan_ford_bridge_guard", + "name": "Yan Ford Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 10, + "pos": [10, 4], + "base": { "hp": 54, "atk": 17, "def": 13 } + }, + { + "unit_id": "yuan_spear_north", + "name": "Yuan Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 10, + "pos": [10, 3], + "base": { "hp": 48, "atk": 16, "def": 10 } + }, + { + "unit_id": "yuan_spear_south", + "name": "Yuan Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 10, + "pos": [10, 5], + "base": { "hp": 48, "atk": 16, "def": 10 } + }, + { + "unit_id": "yan_ford_archer_north", + "name": "Yan Ford Archer", + "class_id": "marksman", + "team": "enemy", + "level": 10, + "pos": [12, 2], + "base": { "hp": 38, "atk": 15, "def": 7, "agi": 13 } + }, + { + "unit_id": "yan_ford_archer_south", + "name": "Yan Ford Archer", + "class_id": "marksman", + "team": "enemy", + "level": 10, + "pos": [12, 6], + "base": { "hp": 38, "atk": 15, "def": 7, "agi": 13 } + }, + { + "unit_id": "yuan_field_advisor", + "name": "Yuan Field Advisor", + "class_id": "strategist", + "team": "enemy", + "level": 10, + "pos": [13, 5], + "skills": ["blaze", "mend"], + "base": { "hp": 40, "mp": 24, "atk": 6, "def": 6, "int": 15 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Wen Chou's riders gather near Yan Ford." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Wen Chou will not accept Yan Liang's defeat as a warning. He will treat it as an insult." }, + { "speaker": "Cao Cao", "text": "Then leave him a road wide enough to charge and narrow enough to close." }, + { "speaker": "Xiahou Yuan", "text": "The ford bends the field for us. I will watch the wooded banks." } + ] + } + ] + }, + { + "id": "turn_2_wen_chou_charge", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Wen Chou", "text": "Yan Liang fell because he was alone. I bring enough horsemen to trample Cao Cao's tricks." }, + { "speaker": "Guo Jia", "text": "Good. Pride has supplied the second wing for us." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "wen_chou_rider_north", + "name": "Wen Chou Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 10, + "pos": [14, 3], + "base": { "hp": 44, "atk": 16, "def": 9, "agi": 12 } + }, + { + "unit_id": "wen_chou_rider_south", + "name": "Wen Chou Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 10, + "pos": [14, 5], + "base": { "hp": 44, "atk": 16, "def": 9, "agi": 12 } + } + ] + } + ] + }, + { + "id": "ford_ambush", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [9, 4] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Guard", "text": "Cao Cao has reached the ford. Close the bank behind him!" }, + { "speaker": "Dian Wei", "text": "They picked the wrong bank to close." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "ford_bank_guard", + "name": "Ford Bank Guard", + "class_id": "infantry", + "team": "enemy", + "level": 10, + "pos": [8, 3], + "base": { "hp": 46, "atk": 16, "def": 10 } + }, + { + "unit_id": "ford_bank_archer", + "name": "Ford Bank Archer", + "class_id": "archer", + "team": "enemy", + "level": 10, + "pos": [8, 5], + "base": { "hp": 34, "atk": 14, "def": 6, "agi": 12 } + } + ] + } + ] + }, + { + "id": "white_horse_fortified", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 3, "campaign_flags": { "fortified_white_horse": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ren", "text": "The fortified crossing behind us holds. Wen Chou cannot expect quick help from White Horse." }, + { "speaker": "Cao Cao", "text": "Then this is his battle alone." } + ] + } + ] + }, + { + "id": "yuan_supplies_raided", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3, "campaign_flags": { "raided_yuan_supplies": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Field Advisor", "text": "The supply carts are still scattered from Cao Cao's raid. The riders are charging hungry." }, + { "speaker": "Wen Chou", "text": "Hunger sharpens men. Forward!" } + ] + } + ] + }, + { + "id": "turn_6_close_ford", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 6 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "The ford is nearly sealed. Wen Chou's courage cannot carry him back across it." }, + { "speaker": "Cao Cao", "text": "Finish the countercharge and prepare the Guandu line." } + ] + } + ] + } + ], + "rewards": { + "gold": 1650, + "items": ["bean", "wine", "war_axe"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Yuan", "text": "Wen Chou's riders scattered at the ford. Yuan Shao has lost two blades in two strokes." }, + { "speaker": "Guo Jia", "text": "His numbers remain, but their certainty is damaged. That matters before Guandu." }, + { "speaker": "Cao Cao", "text": "Then we choose where the next certainty breaks." } + ], + "post_battle_choices": [ + { + "id": "secure_guandu_line", + "label": "Secure Guandu Line", + "description": "Cao Cao strengthens the main defensive camps before Yuan Shao commits his full army.", + "set_flags": { "secured_guandu_line": true, "scouted_wuchao_depots": false }, + "gold": 200, + "items": ["iron_armor"] + }, + { + "id": "scout_wuchao_depots", + "label": "Scout Wuchao", + "description": "Scouts follow the broken baggage routes toward Yuan Shao's supply depots.", + "set_flags": { "secured_guandu_line": false, "scouted_wuchao_depots": true }, + "gold": 100, + "items": ["wine", "war_drum"] + } + ] +} diff --git a/data/scenarios/012_guandu_showdown.json b/data/scenarios/012_guandu_showdown.json new file mode 100644 index 0000000..0fa0288 --- /dev/null +++ b/data/scenarios/012_guandu_showdown.json @@ -0,0 +1,395 @@ +{ + "id": "012_guandu_showdown", + "name": "Guandu Showdown", + "objectives": { + "victory": "Defeat Yuan Shao's main assault after the full line commits.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 13." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_4_yuan_main_assault" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 13, "team": "player" } + ] + }, + "briefing": { + "title": "Guandu Showdown", + "location": "Guandu, 200 CE", + "lines": [ + "Yuan Shao's vast army presses into Guandu, confident that numbers will crush Cao Cao's thin line.", + "Guo Jia urges patience: hold the camps, expose the rhythm of Yuan Shao's command, and look for the supply wound." + ], + "conditional_lines": [ + { + "campaign_flags": { "secured_guandu_line": true }, + "lines": [ + "The defensive camps prepared after Yan Ford give the front line room to bend without breaking." + ] + }, + { + "campaign_flags": { "scouted_wuchao_depots": true }, + "lines": [ + "Scouts from Yan Ford report unusually heavy traffic toward Wuchao. Yuan Shao's grain may be less protected than his banners." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "war_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "secured_guandu_line": true }, + "items": ["imperial_seal"] + }, + { + "campaign_flags": { "scouted_wuchao_depots": true }, + "items": ["war_drum"] + } + ] + }, + "roster": { + "max_units": 6, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 3], [3, 4]] + }, + "map": { + "width": 17, + "height": 11, + "terrain": [ + "GGGGGHHGGGGHHGGGG", + "GGFFGHHGGFFHHGFFG", + "GGFFRRRRRRRRRFFGG", + "GGGGGRGGGGRGGGGGG", + "RRRRRRRRRRRRRRRRR", + "GGGGGRGGGGRGGGGGG", + "GGHHGFFFGGWWWGGGG", + "GGHHGFFFGGWWWGGGG", + "GGGGGGFFGGWWWGGGG", + "GGFFGGGGGGGGGFFGG", + "GGGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch12", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch12", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch12", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch12", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch12", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch12", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "yuan_shao_main_body", + "name": "Yuan Shao Main Body", + "class_id": "commander", + "team": "enemy", + "level": 12, + "pos": [14, 4], + "base": { "hp": 70, "mp": 16, "atk": 19, "def": 13, "int": 13, "agi": 10 } + }, + { + "unit_id": "ju_shou_advisor", + "name": "Ju Shou Advisor", + "class_id": "military_advisor", + "team": "enemy", + "level": 12, + "pos": [13, 5], + "skills": ["blaze", "great_mend"], + "base": { "hp": 46, "mp": 32, "atk": 7, "def": 8, "int": 17, "agi": 10 } + }, + { + "unit_id": "zhang_he_vanguard", + "name": "Zhang He Vanguard", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 12, + "pos": [11, 4], + "base": { "hp": 60, "atk": 20, "def": 12, "agi": 15 } + }, + { + "unit_id": "yuan_center_guard", + "name": "Yuan Center Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 11, + "pos": [10, 4], + "base": { "hp": 56, "atk": 17, "def": 14 } + }, + { + "unit_id": "yuan_north_guard", + "name": "Yuan North Guard", + "class_id": "infantry", + "team": "enemy", + "level": 11, + "pos": [11, 3], + "base": { "hp": 50, "atk": 16, "def": 11 } + }, + { + "unit_id": "yuan_south_guard", + "name": "Yuan South Guard", + "class_id": "infantry", + "team": "enemy", + "level": 11, + "pos": [11, 5], + "base": { "hp": 50, "atk": 16, "def": 11 } + }, + { + "unit_id": "guandu_archer_north", + "name": "Guandu Archer", + "class_id": "marksman", + "team": "enemy", + "level": 11, + "pos": [12, 2], + "base": { "hp": 40, "atk": 16, "def": 7, "agi": 14 } + }, + { + "unit_id": "guandu_archer_south", + "name": "Guandu Archer", + "class_id": "marksman", + "team": "enemy", + "level": 11, + "pos": [13, 6], + "base": { "hp": 40, "atk": 16, "def": 7, "agi": 14 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Yuan Shao's main army bears down on Guandu." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Yuan Shao brings more banners than the field can hold." }, + { "speaker": "Guo Jia", "text": "Then each banner must be fed, signaled, and protected. A large army has larger seams." }, + { "speaker": "Xiahou Dun", "text": "Hold the front until those seams show. I understand." } + ] + } + ] + }, + { + "id": "turn_2_siege_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Shao", "text": "Press the camps from both wings. Cao Cao cannot answer everywhere." }, + { "speaker": "Ju Shou", "text": "Steady pressure, my lord. Do not let their small line choose the tempo." } + ] + } + ] + }, + { + "id": "secured_line_holds", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 3, "campaign_flags": { "secured_guandu_line": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ren", "text": "The camps we reinforced are holding. Their first push has spent itself on timber and earth." }, + { "speaker": "Cao Cao", "text": "Good. Let Yuan Shao pay for every step." } + ] + } + ] + }, + { + "id": "wuchao_traces_found", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 3, "campaign_flags": { "scouted_wuchao_depots": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "The supply marks match the Yan Ford reports. Wuchao is feeding this entire assault." }, + { "speaker": "Cao Cao", "text": "Then this battle is not only a wall. It is a lantern pointing toward their granary." } + ] + } + ] + }, + { + "id": "turn_4_yuan_main_assault", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 4 }, + "actions": [ + { + "type": "set_objective", + "victory": "Defeat Yuan Shao's main body and every committed assault unit.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 13." + }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang He", "text": "The center still stands. Send the rear cavalry in; break them before they settle." }, + { "speaker": "Guo Jia", "text": "Now the main weight is committed. If we endure this, Yuan Shao must expose his stores." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yuan_rear_cavalry", + "name": "Yuan Rear Cavalry", + "class_id": "cavalry", + "team": "enemy", + "level": 11, + "pos": [15, 4], + "base": { "hp": 46, "atk": 17, "def": 9, "agi": 12 } + }, + { + "unit_id": "yuan_north_crossbow", + "name": "Yuan Crossbow", + "class_id": "archer", + "team": "enemy", + "level": 11, + "pos": [16, 3], + "base": { "hp": 36, "atk": 15, "def": 6, "agi": 12 } + }, + { + "unit_id": "yuan_south_crossbow", + "name": "Yuan Crossbow", + "class_id": "archer", + "team": "enemy", + "level": 11, + "pos": [16, 5], + "base": { "hp": 36, "atk": 15, "def": 6, "agi": 12 } + } + ] + } + ] + }, + { + "id": "center_countertrap", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [9, 4] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Guard", "text": "Cao Cao steps into the center! Close ranks around him." }, + { "speaker": "Dian Wei", "text": "Around him? Then you are around me too." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yuan_center_spearman", + "name": "Yuan Center Spearman", + "class_id": "infantry", + "team": "enemy", + "level": 11, + "pos": [8, 3], + "base": { "hp": 48, "atk": 16, "def": 10 } + }, + { + "unit_id": "yuan_center_archer", + "name": "Yuan Center Archer", + "class_id": "archer", + "team": "enemy", + "level": 11, + "pos": [8, 5], + "base": { "hp": 36, "atk": 15, "def": 6, "agi": 12 } + } + ] + } + ] + }, + { + "id": "turn_7_guandu_holds", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 7 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Their assault no longer moves as one army. It moves as arguments." }, + { "speaker": "Cao Cao", "text": "Then answer each argument with steel. Guandu will hold." } + ] + } + ] + } + ], + "rewards": { + "gold": 1900, + "items": ["bean", "wine", "imperial_seal"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Dun", "text": "Yuan Shao's first great push is broken, but his camps still fill the horizon." }, + { "speaker": "Guo Jia", "text": "The horizon eats grain. Wuchao is now more important than the banners before us." }, + { "speaker": "Cao Cao", "text": "Then we decide whether to strike the grain or strengthen the wall before the next tide." } + ], + "post_battle_choices": [ + { + "id": "prepare_wuchao_raid", + "label": "Prepare Wuchao Raid", + "description": "Cao Cao commits scouts and fast troops toward Yuan Shao's exposed granaries.", + "set_flags": { "prepared_wuchao_raid": true, "strengthened_guandu_defense": false }, + "gold": 100, + "items": ["wine", "war_drum"] + }, + { + "id": "strengthen_guandu_defense", + "label": "Strengthen Guandu", + "description": "The army reinforces the camps before planning a deeper strike.", + "set_flags": { "prepared_wuchao_raid": false, "strengthened_guandu_defense": true }, + "gold": 250, + "items": ["iron_armor"] + } + ] +} diff --git a/data/scenarios/013_wuchao_raid.json b/data/scenarios/013_wuchao_raid.json new file mode 100644 index 0000000..868e7d7 --- /dev/null +++ b/data/scenarios/013_wuchao_raid.json @@ -0,0 +1,340 @@ +{ + "id": "013_wuchao_raid", + "name": "Wuchao Raid", + "objectives": { + "victory": "Reach Wuchao's depot with Cao Cao, then defeat the remaining defenders.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "wuchao_depot_burned" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 12, "team": "player" } + ] + }, + "briefing": { + "title": "Wuchao Raid", + "location": "Wuchao, 200 CE", + "lines": [ + "The Guandu line holds, but Yuan Shao's army will not collapse while Wuchao feeds it.", + "Cao Cao leads a night raid toward the granaries, counting on speed, fire, and confusion." + ], + "conditional_lines": [ + { + "campaign_flags": { "prepared_wuchao_raid": true }, + "lines": [ + "Scouts placed after the Guandu assault mark a narrow path through the outer camp." + ] + }, + { + "campaign_flags": { "strengthened_guandu_defense": true }, + "lines": [ + "The reinforced Guandu camps can withstand a longer absence, but the raid must still finish before dawn." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "war_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "prepared_wuchao_raid": true }, + "items": ["war_drum"] + }, + { + "campaign_flags": { "strengthened_guandu_defense": true }, + "items": ["imperial_seal"] + } + ] + }, + "roster": { + "max_units": 6, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 3], [3, 4]] + }, + "map": { + "width": 17, + "height": 11, + "terrain": [ + "GGGGGHHGGGGGGGGGG", + "GGFFGHHGGGFFGGGGG", + "GGFFRRRRRRRRGGFFG", + "GGGGGRGGGGRRGGGGG", + "RRRRRRRRRRRRRRRRG", + "GGGGGRGGGGRRGGGGG", + "GGHHGFFFGGFFFGGGG", + "GGHHGFFFGGFFFGGGG", + "GGGGGGFFGGHHHGGGG", + "GGFFGGGGGGHHHFFGG", + "GGGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch13", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch13", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch13", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch13", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch13", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch13", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "chunyu_qiong_guard", + "name": "Chunyu Qiong Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 13, + "pos": [13, 4], + "base": { "hp": 64, "atk": 19, "def": 15, "agi": 9 } + }, + { + "unit_id": "wuchao_granary_guard", + "name": "Granary Guard", + "class_id": "infantry", + "team": "enemy", + "level": 12, + "pos": [12, 4], + "base": { "hp": 52, "atk": 17, "def": 12 } + }, + { + "unit_id": "wuchao_north_archer", + "name": "Wuchao Archer", + "class_id": "marksman", + "team": "enemy", + "level": 12, + "pos": [12, 2], + "base": { "hp": 40, "atk": 16, "def": 7, "agi": 14 } + }, + { + "unit_id": "wuchao_south_archer", + "name": "Wuchao Archer", + "class_id": "marksman", + "team": "enemy", + "level": 12, + "pos": [12, 6], + "base": { "hp": 40, "atk": 16, "def": 7, "agi": 14 } + }, + { + "unit_id": "wuchao_camp_advisor", + "name": "Camp Advisor", + "class_id": "military_advisor", + "team": "enemy", + "level": 12, + "pos": [14, 5], + "skills": ["blaze", "mend"], + "base": { "hp": 44, "mp": 28, "atk": 7, "def": 7, "int": 16, "agi": 10 } + }, + { + "unit_id": "wuchao_patrol_north", + "name": "Wuchao Patrol", + "class_id": "cavalry", + "team": "enemy", + "level": 12, + "pos": [9, 3], + "base": { "hp": 46, "atk": 17, "def": 9, "agi": 12 } + }, + { + "unit_id": "wuchao_patrol_south", + "name": "Wuchao Patrol", + "class_id": "cavalry", + "team": "enemy", + "level": 12, + "pos": [9, 5], + "base": { "hp": 46, "atk": 17, "def": 9, "agi": 12 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Cao Cao's raiding force approaches Wuchao under cover of darkness." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "If Wuchao burns, Yuan Shao's numbers become mouths instead of soldiers." }, + { "speaker": "Guo Jia", "text": "Strike the granary first. The defenders will fight harder once they see the flame." }, + { "speaker": "Xiahou Dun", "text": "Then we give them fire before they give us formation." } + ] + } + ] + }, + { + "id": "prepared_path", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "prepared_wuchao_raid": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "The scout marks are where they should be. The northern patrol is slower to answer." }, + { "speaker": "Cao Cao", "text": "Then we spend that advantage before dawn spends it for us." } + ] + } + ] + }, + { + "id": "guandu_line_secure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "strengthened_guandu_defense": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Ren", "text": "The Guandu camps remain firm behind us. We can commit fully to the raid." }, + { "speaker": "Cao Cao", "text": "A strong wall is useful because it lets the blade leave the gate." } + ] + } + ] + }, + { + "id": "turn_3_alarm", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Chunyu Qiong", "text": "Cao Cao is near the stores! Wake the rear camp before the grain catches." }, + { "speaker": "Dian Wei", "text": "Too late to wake quietly." } + ] + } + ] + }, + { + "id": "wuchao_depot_burned", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [14, 4] }, + "actions": [ + { + "type": "set_objective", + "victory": "Wuchao is burning. Defeat every defender before Yuan Shao can recover the stores.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 12." + }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Light the granaries. Let Yuan Shao see dawn from an empty bowl." }, + { "speaker": "Guo Jia", "text": "Now the raid becomes an escape through panic. Keep formation." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "wuchao_rear_guard", + "name": "Wuchao Rear Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 12, + "pos": [15, 4], + "base": { "hp": 54, "atk": 17, "def": 13 } + }, + { + "unit_id": "wuchao_rear_rider", + "name": "Wuchao Rear Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 12, + "pos": [15, 5], + "base": { "hp": 46, "atk": 17, "def": 9, "agi": 12 } + } + ] + } + ] + }, + { + "id": "turn_6_granary_collapse", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 6 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Smoke is rising above the camp. Yuan Shao's army will feel this before his messengers explain it." }, + { "speaker": "Cao Cao", "text": "Then leave no guard behind to put the fire out." } + ] + } + ] + } + ], + "rewards": { + "gold": 2100, + "items": ["bean", "wine", "war_drum"] + }, + "post_battle_dialogue": [ + { "speaker": "Xiahou Dun", "text": "Wuchao burns. Yuan Shao's army will wake hungry." }, + { "speaker": "Guo Jia", "text": "Hunger breaks pride more cleanly than argument." }, + { "speaker": "Cao Cao", "text": "Now Guandu turns from endurance to pursuit." } + ], + "post_battle_choices": [ + { + "id": "press_yuan_rout", + "label": "Press the Rout", + "description": "Cao Cao prepares to chase Yuan Shao's shaken army before it reforms.", + "set_flags": { "pressed_yuan_rout": true, "secured_wuchao_stores": false }, + "gold": 100, + "items": ["war_drum"] + }, + { + "id": "secure_wuchao_stores", + "label": "Secure Stores", + "description": "The army gathers usable supplies from Wuchao before pursuing.", + "set_flags": { "pressed_yuan_rout": false, "secured_wuchao_stores": true }, + "gold": 300, + "items": ["bean", "wine"] + } + ] +} diff --git a/data/scenarios/014_cangting_pursuit.json b/data/scenarios/014_cangting_pursuit.json new file mode 100644 index 0000000..2b7974b --- /dev/null +++ b/data/scenarios/014_cangting_pursuit.json @@ -0,0 +1,373 @@ +{ + "id": "014_cangting_pursuit", + "name": "Cangting Pursuit", + "objectives": { + "victory": "Defeat Yuan Shao's rear guard after the retreat line rallies.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 13." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_yuan_rally" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 13, "team": "player" } + ] + }, + "briefing": { + "title": "Cangting Pursuit", + "location": "Cangting, 201 CE", + "lines": [ + "With Wuchao destroyed, Yuan Shao's great army breaks into columns of wounded pride and hungry soldiers.", + "Cao Cao pursues the retreating rear guard before it can gather enough order to shield the northern crossings." + ], + "conditional_lines": [ + { + "campaign_flags": { "pressed_yuan_rout": true }, + "lines": [ + "The army gave Yuan Shao no room to breathe after Wuchao, and the rear guard is already fraying." + ] + }, + { + "campaign_flags": { "secured_wuchao_stores": true }, + "lines": [ + "Captured stores from Wuchao keep the pursuit fed, letting Cao Cao press north without stretching the supply train." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "war_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "pressed_yuan_rout": true }, + "items": ["war_drum"] + }, + { + "campaign_flags": { "secured_wuchao_stores": true }, + "items": ["imperial_seal"] + } + ] + }, + "roster": { + "max_units": 6, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 2], [3, 3], [3, 4], [3, 5]] + }, + "map": { + "width": 17, + "height": 11, + "terrain": [ + "GGGGGHHGGGGGGGGGG", + "GGFFGHHGGGFFGGGGG", + "GGRRRRRRRRRRRGFFG", + "GGGGGRGGGGRRGGGGG", + "RRRRRRRRRRRRRRRRG", + "GGGGGRGGGGRRGGGGG", + "GGHHGFFFGGFFFGGGG", + "GGHHGFFFGGFFFGGGG", + "GGGGGGFFGGHHHGGGG", + "GGFFGGGGGGHHHFFGG", + "GGGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch14", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch14", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "xiahou_dun_ch14", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch14", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch14", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "cao_ren_ch14", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "yuan_shao_rearguard", + "name": "Yuan Shao Rear Guard", + "class_id": "commander", + "team": "enemy", + "level": 13, + "pos": [14, 4], + "base": { "hp": 72, "mp": 16, "atk": 20, "def": 14, "int": 13, "agi": 10 } + }, + { + "unit_id": "zhang_he_rearguard", + "name": "Zhang He Rearguard", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 13, + "pos": [11, 4], + "base": { "hp": 62, "atk": 21, "def": 12, "agi": 16 } + }, + { + "unit_id": "yuan_rally_advisor", + "name": "Yuan Rally Advisor", + "class_id": "military_advisor", + "team": "enemy", + "level": 13, + "pos": [13, 5], + "skills": ["blaze", "great_mend"], + "base": { "hp": 46, "mp": 30, "atk": 7, "def": 8, "int": 17, "agi": 10 } + }, + { + "unit_id": "cangting_center_guard", + "name": "Cangting Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 12, + "pos": [10, 4], + "base": { "hp": 56, "atk": 18, "def": 14 } + }, + { + "unit_id": "cangting_north_guard", + "name": "Cangting Guard", + "class_id": "infantry", + "team": "enemy", + "level": 12, + "pos": [11, 3], + "base": { "hp": 50, "atk": 17, "def": 11 } + }, + { + "unit_id": "cangting_south_guard", + "name": "Cangting Guard", + "class_id": "infantry", + "team": "enemy", + "level": 12, + "pos": [11, 5], + "base": { "hp": 50, "atk": 17, "def": 11 } + }, + { + "unit_id": "cangting_archer_north", + "name": "Cangting Archer", + "class_id": "marksman", + "team": "enemy", + "level": 12, + "pos": [12, 2], + "base": { "hp": 40, "atk": 16, "def": 7, "agi": 14 } + }, + { + "unit_id": "cangting_archer_south", + "name": "Cangting Archer", + "class_id": "marksman", + "team": "enemy", + "level": 12, + "pos": [13, 6], + "base": { "hp": 40, "atk": 16, "def": 7, "agi": 14 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Yuan Shao's retreating army tries to rally near Cangting." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Wuchao broke their hunger. Cangting must break their confidence." }, + { "speaker": "Guo Jia", "text": "Zhang He holds the rear with more discipline than the rest. If he wavers, the rout becomes a road." }, + { "speaker": "Xiahou Yuan", "text": "Then we strike the disciplined men first." } + ] + } + ] + }, + { + "id": "fast_pursuit_pressure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "pressed_yuan_rout": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Dun", "text": "Our pursuit caught them before their wagons even formed a wall." }, + { "speaker": "Cao Cao", "text": "Momentum is a weapon. Keep it sharp." } + ] + } + ] + }, + { + "id": "stored_supply_arrives", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "secured_wuchao_stores": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "The Wuchao stores are feeding our own pursuit now. Yuan Shao pays for both armies." }, + { "speaker": "Cao Cao", "text": "A pleasant economy." } + ] + } + ] + }, + { + "id": "turn_3_yuan_rally", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { + "type": "set_objective", + "victory": "Defeat Yuan Shao's rallied rear guard and every covering unit.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 13." + }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang He", "text": "Hold the road. If the rear collapses, the whole army becomes fugitives." }, + { "speaker": "Guo Jia", "text": "He sees the truth. That makes him dangerous, and useful." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yuan_rout_rider_north", + "name": "Rout Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 12, + "pos": [15, 3], + "base": { "hp": 46, "atk": 17, "def": 9, "agi": 12 } + }, + { + "unit_id": "yuan_rout_rider_south", + "name": "Rout Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 12, + "pos": [15, 5], + "base": { "hp": 46, "atk": 17, "def": 9, "agi": 12 } + } + ] + } + ] + }, + { + "id": "center_pursuit_trap", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [9, 4] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Guard", "text": "Cao Cao is in the center road. Close the retreat line behind him!" }, + { "speaker": "Dian Wei", "text": "Try closing it from the ground." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "cangting_road_guard", + "name": "Road Guard", + "class_id": "infantry", + "team": "enemy", + "level": 12, + "pos": [8, 3], + "base": { "hp": 48, "atk": 17, "def": 11 } + }, + { + "unit_id": "cangting_road_archer", + "name": "Road Archer", + "class_id": "archer", + "team": "enemy", + "level": 12, + "pos": [8, 5], + "base": { "hp": 36, "atk": 15, "def": 6, "agi": 12 } + } + ] + } + ] + }, + { + "id": "turn_6_zhang_he_wavers", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 6 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Zhang He is guarding a lord who no longer listens to his guards." }, + { "speaker": "Cao Cao", "text": "Then let him see which camp rewards judgment." } + ] + } + ] + } + ], + "rewards": { + "gold": 2300, + "items": ["bean", "wine", "iron_armor"], + "join_officers": ["zhang_he"] + }, + "post_battle_dialogue": [ + { "speaker": "Zhang He", "text": "Yuan Shao would rather lose an army than hear the men who tried to save it." }, + { "speaker": "Cao Cao", "text": "Then serve where clear eyes are useful." }, + { "speaker": "Guo Jia", "text": "Cangting breaks the pursuit open. The Yuan heirs will now fight over what remains." } + ], + "post_battle_choices": [ + { + "id": "advance_to_ye", + "label": "Advance to Ye", + "description": "Cao Cao prepares to press toward the Yuan heartland before the heirs can settle.", + "set_flags": { "advanced_to_ye": true, "secured_hebei_crossings": false }, + "gold": 100, + "items": ["war_drum"] + }, + { + "id": "secure_hebei_crossings", + "label": "Secure Crossings", + "description": "The army consolidates river crossings and supply lanes before moving deeper north.", + "set_flags": { "advanced_to_ye": false, "secured_hebei_crossings": true }, + "gold": 300, + "items": ["imperial_seal"] + } + ] +} diff --git a/data/scenarios/015_ye_campaign.json b/data/scenarios/015_ye_campaign.json new file mode 100644 index 0000000..7495fd0 --- /dev/null +++ b/data/scenarios/015_ye_campaign.json @@ -0,0 +1,379 @@ +{ + "id": "015_ye_campaign", + "name": "Ye Campaign", + "objectives": { + "victory": "Defeat the Yuan heirs' outer defense after their split command reveals itself.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 14." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_3_yuan_heir_split" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 14, "team": "player" } + ] + }, + "briefing": { + "title": "Ye Campaign", + "location": "Ye Outskirts, 202 CE", + "lines": [ + "Yuan Shao's heirs inherit banners, grudges, and a northern capital that cannot obey two masters.", + "Cao Cao advances toward Ye, while Zhang He explains where the Yuan defense is strong and where pride has hollowed it out." + ], + "conditional_lines": [ + { + "campaign_flags": { "advanced_to_ye": true }, + "lines": [ + "The rapid march from Cangting catches Yuan Tan's scouts arguing over orders instead of watching the road." + ] + }, + { + "campaign_flags": { "secured_hebei_crossings": true }, + "lines": [ + "Secured crossings behind the army keep Cao Cao's supply line steady as the campaign enters Yuan territory." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "war_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "advanced_to_ye": true }, + "items": ["war_drum"] + }, + { + "campaign_flags": { "secured_hebei_crossings": true }, + "items": ["imperial_seal"] + } + ] + }, + "roster": { + "max_units": 7, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 2], [3, 3], [3, 4], [3, 5], [3, 6]] + }, + "map": { + "width": 18, + "height": 11, + "terrain": [ + "GGGGGHHGGGGGGGGGGG", + "GGFFGHHGGGFFGGGFFG", + "GGRRRRRRRRRRRRGFFG", + "GGGGGRGGGGRRGGGGGG", + "RRRRRRRRRRRRRRRRRR", + "GGGGGRGGGGRRGGGGGG", + "GGHHGFFFGGFFFGGHHG", + "GGHHGFFFGGFFFGGHHG", + "GGGGGGFFGGHHHGGGGG", + "GGFFGGGGGGHHHFFGGG", + "GGGGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch15", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch15", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "zhang_he_ch15", + "officer_id": "zhang_he", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "xiahou_dun_ch15", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch15", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch15", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "cao_ren_ch15", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 4] + }, + { + "unit_id": "yuan_tan_outer_guard", + "name": "Yuan Tan Guard", + "class_id": "commander", + "team": "enemy", + "level": 14, + "pos": [14, 4], + "base": { "hp": 72, "mp": 16, "atk": 20, "def": 14, "int": 12, "agi": 11 } + }, + { + "unit_id": "yuan_shang_inner_guard", + "name": "Yuan Shang Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 14, + "pos": [13, 5], + "base": { "hp": 64, "atk": 19, "def": 15, "agi": 10 } + }, + { + "unit_id": "ye_council_advisor", + "name": "Ye Council Advisor", + "class_id": "military_advisor", + "team": "enemy", + "level": 14, + "pos": [15, 5], + "skills": ["blaze", "great_mend"], + "base": { "hp": 48, "mp": 34, "atk": 7, "def": 8, "int": 18, "agi": 10 } + }, + { + "unit_id": "ye_center_guard", + "name": "Ye Center Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 13, + "pos": [11, 4], + "base": { "hp": 58, "atk": 18, "def": 14 } + }, + { + "unit_id": "ye_north_guard", + "name": "Ye Spear Guard", + "class_id": "infantry", + "team": "enemy", + "level": 13, + "pos": [12, 3], + "base": { "hp": 52, "atk": 17, "def": 12 } + }, + { + "unit_id": "ye_south_guard", + "name": "Ye Spear Guard", + "class_id": "infantry", + "team": "enemy", + "level": 13, + "pos": [12, 5], + "base": { "hp": 52, "atk": 17, "def": 12 } + }, + { + "unit_id": "ye_archer_north", + "name": "Ye Archer", + "class_id": "marksman", + "team": "enemy", + "level": 13, + "pos": [13, 2], + "base": { "hp": 42, "atk": 17, "def": 7, "agi": 14 } + }, + { + "unit_id": "ye_archer_south", + "name": "Ye Archer", + "class_id": "marksman", + "team": "enemy", + "level": 13, + "pos": [14, 6], + "base": { "hp": 42, "atk": 17, "def": 7, "agi": 14 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "The Yuan heirs argue behind Ye's outer defense." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang He", "text": "Ye's outer road looks strong, but Yuan Tan and Yuan Shang will not agree who commands it." }, + { "speaker": "Cao Cao", "text": "Then their wall already has two gates." }, + { "speaker": "Guo Jia", "text": "Let them quarrel loudly enough for our blades to hear." } + ] + } + ] + }, + { + "id": "rapid_march_confusion", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2, "campaign_flags": { "advanced_to_ye": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Tan Guard", "text": "Cao Cao is already here? Yuan Shang promised another day!" }, + { "speaker": "Zhang He", "text": "They are blaming one another before the first order is given." } + ] + } + ] + }, + { + "id": "crossings_secure_supply", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "secured_hebei_crossings": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Yuan", "text": "The crossings behind us are secure. We can keep pressure on Ye without starving the rear." }, + { "speaker": "Cao Cao", "text": "A campaign is won by the road as much as the sword." } + ] + } + ] + }, + { + "id": "turn_3_yuan_heir_split", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { + "type": "set_objective", + "victory": "Exploit the Yuan heirs' split command and defeat every outer defender.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 14." + }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Shang Guard", "text": "Hold for Yuan Shang! Do not yield command to Yuan Tan's men." }, + { "speaker": "Guo Jia", "text": "There it is. Two drums, one army, no rhythm." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yuan_tan_rider", + "name": "Yuan Tan Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 13, + "pos": [16, 3], + "base": { "hp": 48, "atk": 17, "def": 9, "agi": 12 } + }, + { + "unit_id": "yuan_shang_rider", + "name": "Yuan Shang Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 13, + "pos": [16, 5], + "base": { "hp": 48, "atk": 17, "def": 9, "agi": 12 } + } + ] + } + ] + }, + { + "id": "outer_wall_trap", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [10, 4] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Ye Guard", "text": "Cao Cao has pushed to the outer wall. Seal the road!" }, + { "speaker": "Zhang He", "text": "They seal late and from bad ground. Press through the center." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "ye_road_blocker", + "name": "Ye Road Blocker", + "class_id": "infantry", + "team": "enemy", + "level": 13, + "pos": [9, 3], + "base": { "hp": 50, "atk": 17, "def": 11 } + }, + { + "unit_id": "ye_road_archer", + "name": "Ye Road Archer", + "class_id": "archer", + "team": "enemy", + "level": 13, + "pos": [9, 5], + "base": { "hp": 38, "atk": 16, "def": 6, "agi": 12 } + } + ] + } + ] + }, + { + "id": "turn_7_ye_outer_breaks", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 7 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Their outer command breaks before the wall does." }, + { "speaker": "Guo Jia", "text": "A city divided is already partly open." } + ] + } + ] + } + ], + "rewards": { + "gold": 2500, + "items": ["bean", "wine", "war_axe"] + }, + "post_battle_dialogue": [ + { "speaker": "Zhang He", "text": "Ye's outer defense is broken. The heirs will blame each other before they blame us." }, + { "speaker": "Guo Jia", "text": "Good. Their family quarrel is now a campaign map." }, + { "speaker": "Cao Cao", "text": "Then we decide whether to strike the city or widen the fracture." } + ], + "post_battle_choices": [ + { + "id": "press_ye_siege", + "label": "Press Ye Siege", + "description": "Cao Cao prepares a direct siege before the Yuan heirs can reorganize.", + "set_flags": { "pressed_ye_siege": true, "split_yuan_heirs": false }, + "gold": 100, + "items": ["war_drum"] + }, + { + "id": "split_yuan_heirs", + "label": "Split Heirs", + "description": "The army spreads rumors and pressure to deepen Yuan Tan and Yuan Shang's rivalry.", + "set_flags": { "pressed_ye_siege": false, "split_yuan_heirs": true }, + "gold": 250, + "items": ["imperial_seal"] + } + ] +} diff --git a/data/scenarios/016_ye_siege.json b/data/scenarios/016_ye_siege.json new file mode 100644 index 0000000..5ba9417 --- /dev/null +++ b/data/scenarios/016_ye_siege.json @@ -0,0 +1,347 @@ +{ + "id": "016_ye_siege", + "name": "Ye Siege", + "objectives": { + "victory": "Reach Ye's inner gate with Cao Cao, then defeat the palace defenders.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 15." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "inner_gate_breached" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 15, "team": "player" } + ] + }, + "briefing": { + "title": "Ye Siege", + "location": "Ye, 202 CE", + "lines": [ + "Ye's outer line has broken, but the Yuan heirs still command the inner city through suspicion and fear.", + "Cao Cao orders a direct push toward the inner gate while Zhang He marks the streets where the defenders will try to seal the advance." + ], + "conditional_lines": [ + { + "campaign_flags": { "pressed_ye_siege": true }, + "lines": [ + "The rapid siege leaves Yuan Shang's inner guard scrambling to reinforce streets that were still being repaired." + ] + }, + { + "campaign_flags": { "split_yuan_heirs": true }, + "lines": [ + "Rumors spread through Ye that Yuan Tan has been abandoned, and some defenders hesitate before answering Yuan Shang's orders." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "war_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "pressed_ye_siege": true }, + "items": ["war_drum"] + }, + { + "campaign_flags": { "split_yuan_heirs": true }, + "items": ["imperial_seal"] + } + ] + }, + "roster": { + "max_units": 7, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 2], [3, 3], [3, 4], [3, 5], [3, 6]] + }, + "map": { + "width": 18, + "height": 11, + "terrain": [ + "GGGGGHHGGGGGGGGGGG", + "GGFFGHHGGGFFGGGFFG", + "GGRRRRRRRRRRRRGFFG", + "GGGGGRGGGGRRGGGGGG", + "RRRRRRRRRRRRRRRRRR", + "GGGGGRGGGGRRGGGGGG", + "GGHHGFFFGGFFFGGHHG", + "GGHHGFFFGGFFFGGHHG", + "GGGGGGFFGGHHHGGGGG", + "GGFFGGGGGGHHHFFGGG", + "GGGGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch16", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch16", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "zhang_he_ch16", + "officer_id": "zhang_he", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "xiahou_dun_ch16", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch16", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch16", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "cao_ren_ch16", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 4] + }, + { + "unit_id": "yuan_shang_inner_guard", + "name": "Yuan Shang Inner Guard", + "class_id": "commander", + "team": "enemy", + "level": 15, + "pos": [14, 4], + "base": { "hp": 76, "mp": 18, "atk": 21, "def": 15, "int": 13, "agi": 11 } + }, + { + "unit_id": "ye_gate_captain", + "name": "Ye Gate Captain", + "class_id": "guard_captain", + "team": "enemy", + "level": 14, + "pos": [11, 4], + "base": { "hp": 62, "atk": 19, "def": 15 } + }, + { + "unit_id": "ye_inner_advisor", + "name": "Inner City Advisor", + "class_id": "military_advisor", + "team": "enemy", + "level": 14, + "pos": [15, 5], + "skills": ["blaze", "great_mend"], + "base": { "hp": 50, "mp": 34, "atk": 7, "def": 8, "int": 18, "agi": 10 } + }, + { + "unit_id": "ye_north_wall_guard", + "name": "Ye Wall Guard", + "class_id": "infantry", + "team": "enemy", + "level": 14, + "pos": [12, 3], + "base": { "hp": 54, "atk": 18, "def": 12 } + }, + { + "unit_id": "ye_south_wall_guard", + "name": "Ye Wall Guard", + "class_id": "infantry", + "team": "enemy", + "level": 14, + "pos": [12, 5], + "base": { "hp": 54, "atk": 18, "def": 12 } + }, + { + "unit_id": "ye_wall_archer_north", + "name": "Ye Wall Archer", + "class_id": "marksman", + "team": "enemy", + "level": 14, + "pos": [13, 2], + "base": { "hp": 44, "atk": 18, "def": 8, "agi": 14 } + }, + { + "unit_id": "ye_wall_archer_south", + "name": "Ye Wall Archer", + "class_id": "marksman", + "team": "enemy", + "level": 14, + "pos": [14, 6], + "base": { "hp": 44, "atk": 18, "def": 8, "agi": 14 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Cao Cao's army enters the streets outside Ye's inner gate." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang He", "text": "The inner gate is not Ye's strongest point. It is simply where the heirs trust each other least." }, + { "speaker": "Cao Cao", "text": "Then we press where mistrust must give orders." }, + { "speaker": "Guo Jia", "text": "A siege ladder is useful. A frightened messenger is faster." } + ] + } + ] + }, + { + "id": "direct_siege_pressure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "pressed_ye_siege": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Dun", "text": "The rapid assault has them reinforcing with half-formed companies." }, + { "speaker": "Cao Cao", "text": "Then do not give the other half time to arrive." } + ] + } + ] + }, + { + "id": "heirs_split_inside", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2, "campaign_flags": { "split_yuan_heirs": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Ye Guard", "text": "Yuan Tan's men are refusing the inner gate order. They say Yuan Shang means to abandon them." }, + { "speaker": "Guo Jia", "text": "Good. Every rumor is now doing the work of a ram." } + ] + } + ] + }, + { + "id": "turn_3_inner_gate_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Yuan Shang Inner Guard", "text": "Hold the gate. If Cao Cao takes the inner road, Ye will hear his drums before ours." }, + { "speaker": "Zhang He", "text": "He is defending the sound of command, not the city." } + ] + } + ] + }, + { + "id": "inner_gate_breached", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [10, 4] }, + "actions": [ + { + "type": "set_objective", + "victory": "The inner gate is breached. Defeat Yuan Shang's palace defenders.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 15." + }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "The gate is open. Drive through before the city remembers how to obey." }, + { "speaker": "Guo Jia", "text": "The palace guard is moving. This is the last hard knot." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "ye_palace_guard", + "name": "Ye Palace Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 14, + "pos": [16, 4], + "base": { "hp": 58, "atk": 18, "def": 14 } + }, + { + "unit_id": "ye_palace_rider", + "name": "Ye Palace Rider", + "class_id": "cavalry", + "team": "enemy", + "level": 14, + "pos": [16, 5], + "base": { "hp": 50, "atk": 18, "def": 10, "agi": 12 } + } + ] + } + ] + }, + { + "id": "turn_7_city_wavers", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 7 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang He", "text": "Ye's defenders are waiting to see which heir survives the day." }, + { "speaker": "Cao Cao", "text": "Then give them an easier answer." } + ] + } + ] + } + ], + "rewards": { + "gold": 2800, + "items": ["bean", "wine", "imperial_seal"] + }, + "post_battle_dialogue": [ + { "speaker": "Zhang He", "text": "The inner gate is broken. Ye cannot pretend the Yuan house is whole." }, + { "speaker": "Guo Jia", "text": "One city remains, but the family that held it has become several smaller enemies." }, + { "speaker": "Cao Cao", "text": "Then we choose whether to take the city by force or make the heirs surrender it piece by piece." } + ], + "post_battle_choices": [ + { + "id": "force_ye_surrender", + "label": "Force Surrender", + "description": "Cao Cao prepares a final direct pressure on Ye's remaining defenders.", + "set_flags": { "forced_ye_surrender": true, "negotiated_yuan_defections": false }, + "gold": 150, + "items": ["war_drum"] + }, + { + "id": "negotiate_yuan_defections", + "label": "Negotiate Defections", + "description": "The army courts frightened Yuan officers and turns the city's divisions inward.", + "set_flags": { "forced_ye_surrender": false, "negotiated_yuan_defections": true }, + "gold": 300, + "items": ["iron_armor"] + } + ] +} diff --git a/data/scenarios/017_ye_surrender.json b/data/scenarios/017_ye_surrender.json new file mode 100644 index 0000000..f4fafc4 --- /dev/null +++ b/data/scenarios/017_ye_surrender.json @@ -0,0 +1,347 @@ +{ + "id": "017_ye_surrender", + "name": "Ye Surrender", + "objectives": { + "victory": "Break the last Yuan holdouts after the surrender signal reaches the palace road.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 14." + }, + "conditions": { + "victory": { "type": "all_units_defeated", "team": "enemy", "after_event": "surrender_signal_sent" }, + "defeat": [ + { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, + { "type": "turn_reached", "turn": 14, "team": "player" } + ] + }, + "briefing": { + "title": "Ye Surrender", + "location": "Ye Palace Road, 202 CE", + "lines": [ + "Ye's inner gate has fallen. Only palace loyalists and frightened Yuan officers remain between Cao Cao and the city's surrender.", + "Zhang He warns that the last defenders will either break quickly or fight wildly to prove loyalty to absent heirs." + ], + "conditional_lines": [ + { + "campaign_flags": { "forced_ye_surrender": true }, + "lines": [ + "Direct pressure on Ye leaves the palace guard short of time, but their cornered officers are ready for a final stand." + ] + }, + { + "campaign_flags": { "negotiated_yuan_defections": true }, + "lines": [ + "Negotiations have drawn several Yuan captains away from the palace, leaving the loyalists angry and isolated." + ] + } + ] + }, + "shop": { + "items": [ + "bean", + "wine", + "iron_sword", + "training_spear", + "short_bow", + "hand_axe", + "war_axe", + "leather_armor", + "iron_armor" + ], + "conditional_items": [ + { + "campaign_flags": { "forced_ye_surrender": true }, + "items": ["war_drum"] + }, + { + "campaign_flags": { "negotiated_yuan_defections": true }, + "items": ["imperial_seal"] + } + ] + }, + "roster": { + "max_units": 7, + "required_officers": ["cao_cao", "guo_jia"] + }, + "formation": { + "cells": [[1, 3], [1, 4], [1, 5], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [3, 2], [3, 3], [3, 4], [3, 5], [3, 6]] + }, + "map": { + "width": 18, + "height": 11, + "terrain": [ + "GGGGGHHGGGGGGGGGGG", + "GGFFGHHGGGFFGGGFFG", + "GGRRRRRRRRRRRRGFFG", + "GGGGGRGGGGRRGGGGGG", + "RRRRRRRRRRRRRRRRRR", + "GGGGGRGGGGRRGGGGGG", + "GGHHGFFFGGFFFGGHHG", + "GGHHGFFFGGFFFGGHHG", + "GGGGGGFFGGHHHGGGGG", + "GGFFGGGGGGHHHFFGGG", + "GGGGGGGGGGGGGGGGGG" + ] + }, + "deployments": [ + { + "unit_id": "cao_cao_ch17", + "officer_id": "cao_cao", + "team": "player", + "pos": [1, 4] + }, + { + "unit_id": "guo_jia_ch17", + "officer_id": "guo_jia", + "team": "player", + "requires_joined": true, + "pos": [2, 4] + }, + { + "unit_id": "zhang_he_ch17", + "officer_id": "zhang_he", + "team": "player", + "requires_joined": true, + "pos": [2, 3] + }, + { + "unit_id": "xiahou_dun_ch17", + "officer_id": "xiahou_dun", + "team": "player", + "pos": [1, 5] + }, + { + "unit_id": "dian_wei_ch17", + "officer_id": "dian_wei", + "team": "player", + "requires_joined": true, + "pos": [2, 5] + }, + { + "unit_id": "xiahou_yuan_ch17", + "officer_id": "xiahou_yuan", + "team": "player", + "requires_joined": true, + "pos": [3, 3] + }, + { + "unit_id": "cao_ren_ch17", + "officer_id": "cao_ren", + "team": "player", + "requires_joined": true, + "pos": [3, 4] + }, + { + "unit_id": "ye_last_loyalist", + "name": "Ye Last Loyalist", + "class_id": "commander", + "team": "enemy", + "level": 16, + "pos": [14, 4], + "base": { "hp": 78, "mp": 18, "atk": 22, "def": 16, "int": 12, "agi": 11 } + }, + { + "unit_id": "yuan_palace_guard", + "name": "Yuan Palace Guard", + "class_id": "guard_captain", + "team": "enemy", + "level": 15, + "pos": [12, 4], + "base": { "hp": 62, "atk": 19, "def": 15 } + }, + { + "unit_id": "yuan_palace_advisor", + "name": "Palace Advisor", + "class_id": "military_advisor", + "team": "enemy", + "level": 15, + "pos": [15, 5], + "skills": ["blaze", "great_mend"], + "base": { "hp": 50, "mp": 34, "atk": 7, "def": 8, "int": 18, "agi": 10 } + }, + { + "unit_id": "ye_palace_spear_north", + "name": "Palace Spear Guard", + "class_id": "infantry", + "team": "enemy", + "level": 15, + "pos": [12, 3], + "base": { "hp": 54, "atk": 18, "def": 12 } + }, + { + "unit_id": "ye_palace_spear_south", + "name": "Palace Spear Guard", + "class_id": "infantry", + "team": "enemy", + "level": 15, + "pos": [12, 5], + "base": { "hp": 54, "atk": 18, "def": 12 } + }, + { + "unit_id": "ye_palace_archer_north", + "name": "Palace Archer", + "class_id": "marksman", + "team": "enemy", + "level": 15, + "pos": [13, 2], + "base": { "hp": 44, "atk": 18, "def": 8, "agi": 14 } + }, + { + "unit_id": "ye_palace_archer_south", + "name": "Palace Archer", + "class_id": "marksman", + "team": "enemy", + "level": 15, + "pos": [14, 6], + "base": { "hp": 44, "atk": 18, "def": 8, "agi": 14 } + } + ], + "events": [ + { + "id": "opening", + "once": true, + "when": { "type": "battle_start" }, + "actions": [ + { "type": "log", "text": "Ye's last loyalists gather near the palace road." } + ] + }, + { + "id": "opening_dialogue", + "once": true, + "when": { "type": "battle_begin" }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang He", "text": "The loyalists have no plan left, only the need to be seen dying for one." }, + { "speaker": "Cao Cao", "text": "Then let the city see survival rewarded as clearly as resistance is ended." }, + { "speaker": "Guo Jia", "text": "A surrender must look inevitable before it is signed." } + ] + } + ] + }, + { + "id": "forced_pressure", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 2, "campaign_flags": { "forced_ye_surrender": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Dun", "text": "The direct pressure has them packed against the palace road." }, + { "speaker": "Cao Cao", "text": "Good. A cornered guard can no longer pretend to command a city." } + ] + } + ] + }, + { + "id": "defection_pressure", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 2, "campaign_flags": { "negotiated_yuan_defections": true } }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Palace Advisor", "text": "Two more captains have opened their gates to Cao Cao. The loyalists are fighting alone." }, + { "speaker": "Guo Jia", "text": "A city can surrender one door at a time." } + ] + } + ] + }, + { + "id": "turn_3_last_stand", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 3 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Ye Last Loyalist", "text": "If the Yuan name must leave Ye, let Cao Cao pay for every stone." }, + { "speaker": "Zhang He", "text": "That is no longer strategy. It is grief with a spear." } + ] + } + ] + }, + { + "id": "surrender_signal_sent", + "once": true, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [10, 4] }, + "actions": [ + { + "type": "set_objective", + "victory": "The surrender signal is sent. Defeat the loyalists who refuse it.", + "defeat": "Cao Cao is defeated or the battle reaches Turn 14." + }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Cao Cao", "text": "Raise the surrender banner. Those who lay down arms will live under law." }, + { "speaker": "Ye Last Loyalist", "text": "Then we die outside that law." } + ] + }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "ye_last_rider", + "name": "Last Rider", + "class_id": "elite_cavalry", + "team": "enemy", + "level": 15, + "pos": [16, 4], + "base": { "hp": 56, "atk": 19, "def": 11, "agi": 14 } + }, + { + "unit_id": "ye_last_archer", + "name": "Last Archer", + "class_id": "archer", + "team": "enemy", + "level": 15, + "pos": [16, 5], + "base": { "hp": 38, "atk": 16, "def": 6, "agi": 12 } + } + ] + } + ] + }, + { + "id": "turn_7_city_surrenders", + "once": true, + "when": { "type": "turn_start", "team": "player", "turn": 7 }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "The palace road is quieting. Ye is deciding that tomorrow matters more than yesterday." }, + { "speaker": "Cao Cao", "text": "Then end the last yesterday standing in our way." } + ] + } + ] + } + ], + "rewards": { + "gold": 3000, + "items": ["bean", "wine", "iron_armor"] + }, + "post_battle_dialogue": [ + { "speaker": "Zhang He", "text": "Ye has surrendered. The Yuan house still runs north, but its capital is no longer theirs." }, + { "speaker": "Guo Jia", "text": "A capital lost is a family argument made permanent." }, + { "speaker": "Cao Cao", "text": "Then Hebei must learn whether it belongs to heirs, rebels, or order." } + ], + "post_battle_choices": [ + { + "id": "pursue_yuan_shang", + "label": "Pursue Yuan Shang", + "description": "Cao Cao turns immediately toward the fleeing Yuan heir.", + "set_flags": { "pursued_yuan_shang": true, "stabilized_ye": false }, + "gold": 100, + "items": ["war_drum"] + }, + { + "id": "stabilize_ye", + "label": "Stabilize Ye", + "description": "The army secures Ye's administration before marching farther north.", + "set_flags": { "pursued_yuan_shang": false, "stabilized_ye": true }, + "gold": 350, + "items": ["imperial_seal"] + } + ] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9a5fcc6 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,60 @@ +# Architecture + +## Principles + +- Keep rules data-driven. +- Keep battle state independent from rendering. +- Build one complete vertical slice before expanding systems. +- Prefer simple Godot nodes and GDScript until a system proves it needs more structure. + +## Current Layout + +- `project.godot`: Godot 4 project configuration. +- `scenes/battle_scene.tscn`: Main playable battle scene. +- `scripts/core/data_catalog.gd`: JSON definition loader and deployment hydrator. +- `scripts/core/campaign_state.gd`: Thin campaign save state for gold, inventory, roster, completed scenarios, and campaign flags. +- `scripts/core/battle_state.gd`: Battle rules, unit state, turns, AI, and victory checks. +- `scripts/scenes/battle_scene.gd`: Rendering, input handling, and HUD wiring. +- `data/campaign/campaign.json`: Campaign order and scenario paths. +- `data/defs/*.json`: Officers, classes, terrain, items, and skills. +- `data/scenarios/*.json`: Scenario definitions. + +## Data Files + +- `data/defs/classes.json`: Class stats, movement types, skill access. +- `data/defs/officers.json`: Named officer base stats and growth. +- `data/defs/items.json`: Weapons, armor, accessories, and consumables. +- `data/defs/skills.json`: MP tactic definitions for damage, healing, target rules, and range. +- `data/defs/terrain.json`: Terrain colors, defense, avoid, and movement costs. +- `data/campaign/campaign.json`: Scenario sequence, titles, and resource paths. +- `data/scenarios/*.json`: Dialogue, deployments, rewards, and branching flags. + +## Battle Loop + +1. Load battle data. +2. Hydrate deployments through `DataCatalog`. +3. Dispatch battle-start scenario events. +4. Show scenario briefing, including the optional pre-battle shop, Armory, Roster, and Formation setup. +5. Player selects a unit. +6. Briefing completion dispatches battle-begin events, including opening dialogue. +7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, and power. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. +8. Player ends turn. +9. Enemy AI moves, attacks, and can cast damage or healing tactics through the same combat and skill resolvers. +10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. +11. Turn-start and movement-triggered scenario events run as phases change or units enter marked cells, and may update objectives or deploy reinforcements. +12. On victory, `CampaignState` snapshots player progression and the battle inventory, applies rewards once, advances `current_scenario_id`, and writes `user://campaign_save.json`. +13. If the scenario defines post-battle dialogue, it plays once before the victory result panel opens. +14. The victory result panel summarizes rewards plus saved player level-ups and promotions. +15. If the scenario defines post-battle choices, the result panel waits for the player to select one, then saves the selected flags and optional choice rewards before enabling the next battle. +16. The result panel can load the next scenario, passing saved roster and inventory overlays back into `BattleState`. +17. Once all campaign scenarios are complete, the battle scene shows a campaign completion panel instead of loading another map. + +## Save Boundary + +`BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Formation choices, action flags, or transient battle occupancy. + +Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. + +Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Purchases are campaign transactions: they spend saved gold immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects. + +The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..990283c --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,378 @@ +# 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 +``` + +## 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" } + ] +} +``` + +## 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. + +```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. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md new file mode 100644 index 0000000..036b155 --- /dev/null +++ b/docs/GODOT_4_6_MIGRATION.md @@ -0,0 +1,56 @@ +# Godot 4.6 Migration + +This project targets Godot 4 and is being moved toward Godot 4.6 as the active editor baseline. + +## Current Risk Profile + +- The project is a simple Godot 4 `Node2D` application with data-driven JSON content. +- There are no addons, imported art assets, shaders, TileMaps, 3D physics scenes, or export presets yet. +- The current renderer is explicitly set to `gl_compatibility`, so new-project rendering defaults in Godot 4.6 should not silently change the existing project renderer. +- `project.godot` still advertises `config/features=PackedStringArray("4.2")`; let the Godot 4.6 editor update project files instead of editing generated scene/resource formats by hand. + +## Before Opening In 4.6 + +1. Create a backup or initialize version control before the editor writes migration changes. +2. Run the data/readiness check: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readiness.ps1 +``` + +3. If the Godot executable is not on `PATH`, set `GODOT_BIN` for command-line checks: + +```powershell +$env:GODOT_BIN = "C:\Path\To\Godot_v4.6-stable_win64.exe" +``` + +## Editor Migration Steps + +1. Open the project folder in Godot 4.6. +2. Let the editor import the project and accept any project-file migration prompt. +3. Run `Project > Tools > Upgrade Project Files...`. +4. Save, close, and reopen the project once. +5. Run the readiness check again. If `GODOT_BIN` is set, include the optional headless editor import: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readiness.ps1 -RunGodotImport +``` + +## Manual Smoke Test + +- Main scene starts without script errors. +- Current campaign save loads or a new campaign starts cleanly. +- Briefing opens and can enter battle. +- Shop, Armory, Roster, and Formation open and close. +- A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear. +- Enemy turn advances and AI acts. +- Victory and defeat panels still appear. +- Post-battle dialogue and post-battle choices still block progression until a choice is saved. +- Next battle loads with saved roster, inventory, joined officers, and campaign flags. +- New Campaign clears the save and returns to the opening scenario. + +## Watch Items + +- If the editor updates `config/features`, review the diff before making content changes. +- If UI colors or canvas rendering look different, compare the battle board, overlays, and panels before changing art direction. +- If a save file behaves oddly after migration, test both a fresh campaign and an existing `user://campaign_save.json`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..209b2e2 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,92 @@ +# Roadmap + +## Milestone 0: Project Foundation + +- Godot 4 project opens and runs. +- Main battle scene loads a JSON battle. +- Rules and presentation are separated enough to keep iterating. + +## Milestone 1: Playable Battle Prototype + +- Scenario briefing before battle. +- Select player units. +- Show move and attack ranges. +- Show hover tile and unit information. +- Show basic damage forecast. +- Move, attack, wait, and end turn. +- Enemy units take AI turns with movement, attacks, and basic tactic use. +- Victory and defeat states are visible. +- Scenario-defined victory and defeat conditions exist. +- Destination victory conditions and objective map markers exist. +- Turn-limit conditions and HUD turn-limit display exist. +- Victory reward summary is visible. +- Next-battle flow exists across the current seventeen-scenario campaign. +- Campaign completion and new-campaign reset exist. +- Reward inventory can be brought into the next battle. + +## Milestone 2: Cao Cao Zhuan-Style Combat Depth + +- Unit classes and class growth tables. Basic version exists. +- Automatic class promotion routes. Basic level-threshold promotion exists for core first-tier battle classes. +- Weapon, armor, and accessory slots. Basic stat bonuses exist. +- Equipment rewards can be stored, displayed, and swapped from the battle HUD. +- Terrain movement, defense, and avoid modifiers affect combat. +- Skills, tactics, and MP. Multiple single-target damage/heal tactics, a skill list menu with range/power hints, and enemy tactic AI exist. +- Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, item menu, preview, and target overlay exist. +- Counterattacks exist. Support effects are still planned. +- Battle EXP, level-ups, class promotion, and campaign roster persistence exist. + +## Milestone 3: Scenario Layer + +- Pre-battle briefing exists. Full dialogue scenes are still planned. +- Scenario events exist for battle start, battle begin, turn start, and movement-triggered map tiles. +- Mid-battle map/action events have a first action system with movement ambushes. +- Opening battle dialogue exists through event actions. +- Post-battle dialogue exists before the victory result panel. +- Reinforcements and objective changes exist in a basic form. +- Post-battle reward save, campaign choice flags, and first flag-driven briefing/shop/event branches exist in a basic form. Rich reward screens and wider branch support are still planned. + +## Milestone 4: Campaign Progression + +- Party roster persistence has a first save-file skeleton. +- Level, experience, and equipment persistence. +- Chapter selection and save/load. +- Linear seventeen-scenario campaign order exists. +- Save reset exists. +- Victory rewards, consumable counts, equipment stock, and equipped gear persist. +- A basic pre-battle shop with scenario-specific stock exists. Sell-back and richer item management are still planned. +- A basic pre-battle Armory exists for saved equipment changes before combat. +- A basic pre-battle Roster exists for required officers, required scenario units, optional officers, and sortie limits. +- A basic pre-battle Formation screen exists for scenario-defined starting cells. +- Officer join/leave rewards, choice membership changes, and joined-officer deployment gates exist in a basic form. +- Completed-save officer membership reconciliation exists for newly added join/leave rewards. +- Post-battle choice ids are saved in a campaign ledger for safer branch reconciliation. +- Pending post-battle choices survive reloads without replaying victory rewards. +- Fourth-scenario content exists with Qingzhou recruitment for Dian Wei. +- Fifth-scenario content exists with Dian Wei required in the Puyang raid and a new Lu Bu branch choice. +- Sixth-scenario content exists with Dingtao branch reactions to the Puyang choice. +- Seventh-scenario content exists with an emperor escort route, a protected envoy, a midroad ambush, and a destination victory condition. +- Eighth-scenario content exists with a Wan Castle escape route, protected Cao Ang, 007 branch reactions, and a destination victory condition. +- Ninth-scenario content exists with a Xiapi siege, flood-gate objective update, Lu Bu/Chen Gong pressure, and 008 branch reactions. +- Tenth-scenario content exists with White Horse relief, Guo Jia as a joined strategist, Yan Liang's vanguard, and 009 branch reactions. +- Eleventh-scenario content exists with Yan Ford pursuit, Wen Chou's countercharge, and 010 branch reactions. +- Twelfth-scenario content exists with Guandu's main assault, Yuan Shao's army, and 011 branch reactions. +- Thirteenth-scenario content exists with Wuchao depot raid, a burn-and-clear objective, and 012 branch reactions. +- Fourteenth-scenario content exists with Cangting pursuit, Zhang He recruitment, and 013 branch reactions. +- Fifteenth-scenario content exists with Ye outer defense, Zhang He as a deployable officer, and 014 branch reactions. +- Sixteenth-scenario content exists with Ye inner siege, Zhang He deployment, and 015 branch reactions. +- Seventeenth-scenario content exists with Ye surrender, last loyalist resistance, and 016 branch reactions. + +## Milestone 5: Presentation + +- Portrait pipeline. +- Unit sprites and animations. +- Battle effects. +- Music and sound. +- Chapter UI and visual novel scenes. + +## Milestone 6: Content Expansion + +- Recreate a long campaign structure chapter by chapter. +- Add historical officers, unique classes, and named equipment. +- Balance maps through repeated playtesting. diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..fd0c347 --- /dev/null +++ b/project.godot @@ -0,0 +1,22 @@ +; Engine configuration file. +; Prefer editing through the Godot editor when project settings become more complex. + +config_version=5 + +[application] + +config/name="Heroes of Three Kingdoms" +run/main_scene="res://scenes/battle_scene.tscn" +config/features=PackedStringArray("4.2") + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/stretch/mode="canvas_items" + +[rendering] + +renderer/rendering_method="gl_compatibility" +textures/canvas_textures/default_texture_filter=0 + diff --git a/scenes/battle_scene.tscn b/scenes/battle_scene.tscn new file mode 100644 index 0000000..216e4a1 --- /dev/null +++ b/scenes/battle_scene.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/scenes/battle_scene.gd" id="1_battle"] + +[node name="BattleScene" type="Node2D"] +script = ExtResource("1_battle") diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd new file mode 100644 index 0000000..9c77d8e --- /dev/null +++ b/scripts/core/battle_state.gd @@ -0,0 +1,2388 @@ +extends RefCounted +class_name BattleState + +const DataCatalogScript := preload("res://scripts/core/data_catalog.gd") + +signal changed +signal log_added(message: String) +signal dialogue_requested(lines: Array) + +const TEAM_PLAYER := "player" +const TEAM_ENEMY := "enemy" +const STATUS_ACTIVE := "active" +const STATUS_VICTORY := "victory" +const STATUS_DEFEAT := "defeat" + +const EXP_ATTACK := 10 +const EXP_COUNTER := 5 +const EXP_MISS := 2 +const EXP_SKILL := 12 +const EXP_HEAL := 8 +const EXP_DEFEAT_BONUS := 30 +const EXP_LEVEL := 100 +const BASE_HIT_CHANCE := 90 +const MIN_HIT_CHANCE := 25 + +const CARDINAL_DIRECTIONS := [ + Vector2i(1, 0), + Vector2i(-1, 0), + Vector2i(0, 1), + Vector2i(0, -1) +] + +const GROWTH_GRADE_GAINS := { + "hp": {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1}, + "mp": {"A": 3, "B": 2, "C": 1, "D": 1, "E": 0}, + "atk": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, + "def": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, + "int": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, + "agi": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0} +} + +const DEFAULT_TERRAIN_DEFS := { + "G": { + "name": "Plain", + "move_cost": 1, + "defense": 0, + "avoid": 0, + "color": Color(0.43, 0.62, 0.31) + }, + "F": { + "name": "Forest", + "move_cost": 2, + "defense": 2, + "avoid": 10, + "color": Color(0.20, 0.45, 0.23) + }, + "H": { + "name": "Hill", + "move_cost": 2, + "defense": 1, + "avoid": 5, + "color": Color(0.55, 0.50, 0.34) + }, + "R": { + "name": "Road", + "move_cost": 1, + "defense": 0, + "avoid": 0, + "color": Color(0.64, 0.55, 0.42) + }, + "W": { + "name": "Water", + "move_cost": 99, + "defense": 0, + "avoid": 0, + "color": Color(0.18, 0.36, 0.62) + } +} + +var battle_id := "" +var battle_name := "" +var objectives := {} +var battle_conditions := {} +var briefing := {} +var rewards := {} +var post_battle_choices: Array = [] +var post_battle_dialogue: Array = [] +var shop := {} +var deployment_rules := {} +var formation_cells: Array[Vector2i] = [] +var map_size := Vector2i.ZERO +var terrain_rows: Array[String] = [] +var terrain_defs := DEFAULT_TERRAIN_DEFS.duplicate(true) +var units: Array[Dictionary] = [] +var current_team := TEAM_PLAYER +var turn_number := 1 +var selected_unit_id := "" +var battle_status := STATUS_ACTIVE +var battle_events: Array[Dictionary] = [] +var fired_event_ids := {} +var roster_overrides_snapshot := {} +var battle_inventory := {} +var campaign_flags := {} +var campaign_joined_officers := {} +var progression_events: Array[Dictionary] = [] +var data_catalog := DataCatalogScript.new() +var rng := RandomNumberGenerator.new() + + +func load_battle(path: String, roster_overrides := {}, inventory_overrides := {}, flag_overrides := {}, joined_officer_overrides := []) -> bool: + var text := FileAccess.get_file_as_string(path) + if text.is_empty(): + push_error("Battle file is empty or missing: %s" % path) + return false + + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + push_error("Battle file is not valid JSON: %s" % path) + return false + + data_catalog.load_defaults() + rng.randomize() + if typeof(roster_overrides) == TYPE_DICTIONARY: + roster_overrides_snapshot = roster_overrides.duplicate(true) + else: + roster_overrides_snapshot = {} + if typeof(inventory_overrides) == TYPE_DICTIONARY: + battle_inventory = inventory_overrides.duplicate(true) + else: + battle_inventory = {} + if typeof(flag_overrides) == TYPE_DICTIONARY: + campaign_flags = flag_overrides.duplicate(true) + else: + campaign_flags = {} + campaign_joined_officers = _joined_officer_lookup(joined_officer_overrides) + terrain_defs = data_catalog.get_runtime_terrain_defs(DEFAULT_TERRAIN_DEFS) + _apply_battle_data(parsed, roster_overrides_snapshot) + _emit_log("Battle loaded: %s" % battle_name) + _run_events("battle_start") + _run_events("turn_start", current_team, turn_number) + _notify_changed() + return true + + +func run_battle_begin_events() -> void: + if battle_status != STATUS_ACTIVE: + return + _run_events("battle_begin") + _notify_changed() + + +func _apply_battle_data(data: Dictionary, roster_overrides := {}) -> void: + battle_id = String(data.get("id", "unknown")) + battle_name = String(data.get("name", "Untitled Battle")) + objectives = data.get("objectives", {}) + battle_conditions = _normalized_battle_conditions(data.get("conditions", {})) + briefing = _normalized_briefing(data.get("briefing", {})) + rewards = data.get("rewards", {}) + post_battle_choices = _normalized_post_battle_choices(data.get("post_battle_choices", [])) + post_battle_dialogue = _normalized_dialogue_lines(data.get("post_battle_dialogue", [])) + shop = _normalized_shop(data.get("shop", {})) + deployment_rules = _normalized_deployment_rules(data.get("roster", {})) + formation_cells = _normalized_formation_cells(data.get("formation", {})) + battle_events.clear() + for event in data.get("events", []): + if typeof(event) == TYPE_DICTIONARY: + battle_events.append(event) + fired_event_ids.clear() + progression_events.clear() + + var map_data: Dictionary = data.get("map", {}) + map_size = Vector2i(int(map_data.get("width", 0)), int(map_data.get("height", 0))) + terrain_rows.clear() + for row in map_data.get("terrain", []): + terrain_rows.append(String(row)) + + units.clear() + var source_units: Array = [] + if data.has("deployments"): + for deployment in data.get("deployments", []): + if typeof(deployment) != TYPE_DICTIONARY: + push_error("Skipping malformed deployment in %s." % battle_id) + continue + var hydrated_unit := data_catalog.hydrate_deployment(deployment) + _apply_roster_overlay(hydrated_unit, roster_overrides) + if not _is_deployment_available(hydrated_unit): + continue + source_units.append(hydrated_unit) + else: + source_units = data.get("units", []) + + for source_unit in source_units: + var unit := _prepare_unit(source_unit) + if unit.is_empty(): + continue + units.append(unit) + + _apply_deployment_rules() + + if formation_cells.is_empty(): + for unit in units: + if unit.get("team", "") == TEAM_PLAYER and unit.get("deployed", true): + formation_cells.append(unit["pos"]) + + current_team = TEAM_PLAYER + turn_number = 1 + selected_unit_id = "" + battle_status = STATUS_ACTIVE + + +func _normalized_battle_conditions(source) -> Dictionary: + var conditions := {} + if typeof(source) == TYPE_DICTIONARY: + conditions = source.duplicate(true) + if not conditions.has("victory") or not _is_condition_shape_valid(conditions["victory"]): + conditions["victory"] = {"type": "all_enemies_defeated"} + if not conditions.has("defeat") or not _is_condition_shape_valid(conditions["defeat"]): + conditions["defeat"] = {"type": "all_players_defeated"} + return conditions + + +func _normalized_briefing(source) -> Dictionary: + if typeof(source) != TYPE_DICTIONARY: + return {} + var result := source.duplicate(true) + var lines := [] + for line in result.get("lines", []): + lines.append(str(line)) + for block in result.get("conditional_lines", []): + if typeof(block) != TYPE_DICTIONARY: + continue + if not _campaign_flags_match(block.get("campaign_flags", {})): + continue + for line in block.get("lines", []): + lines.append(str(line)) + result["lines"] = lines + result.erase("conditional_lines") + return result + + +func _is_condition_shape_valid(condition) -> bool: + return typeof(condition) == TYPE_DICTIONARY or typeof(condition) == TYPE_ARRAY + + +func _normalized_shop(source) -> Dictionary: + var item_ids := [] + var seen := {} + if typeof(source) != TYPE_DICTIONARY: + return {"items": item_ids} + for entry in source.get("items", []): + var item_id := "" + if typeof(entry) == TYPE_DICTIONARY: + item_id = str(entry.get("id", "")) + else: + item_id = str(entry) + if item_id.is_empty() or seen.has(item_id): + continue + seen[item_id] = true + item_ids.append(item_id) + for block in source.get("conditional_items", []): + if typeof(block) != TYPE_DICTIONARY: + continue + if not _campaign_flags_match(block.get("campaign_flags", {})): + continue + for entry in block.get("items", []): + var item_id := "" + if typeof(entry) == TYPE_DICTIONARY: + item_id = str(entry.get("id", "")) + else: + item_id = str(entry) + if item_id.is_empty() or seen.has(item_id): + continue + seen[item_id] = true + item_ids.append(item_id) + return {"items": item_ids} + + +func _normalized_deployment_rules(source) -> Dictionary: + var rules := { + "max_units": 0, + "required_officers": [], + "required_units": [] + } + if typeof(source) != TYPE_DICTIONARY: + return rules + rules["max_units"] = max(0, int(source.get("max_units", 0))) + var required_officers := [] + var seen_officers := {} + for officer_id in source.get("required_officers", []): + var normalized := str(officer_id) + if normalized.is_empty() or seen_officers.has(normalized): + continue + seen_officers[normalized] = true + required_officers.append(normalized) + rules["required_officers"] = required_officers + + var required_units := [] + var seen_units := {} + for unit_id in source.get("required_units", []): + var normalized := str(unit_id) + if normalized.is_empty() or seen_units.has(normalized): + continue + seen_units[normalized] = true + required_units.append(normalized) + rules["required_units"] = required_units + return rules + + +func _normalized_post_battle_choices(source) -> Array: + var result := [] + if typeof(source) != TYPE_ARRAY: + return result + var seen := {} + for choice in source: + if typeof(choice) != TYPE_DICTIONARY: + continue + var choice_id := str(choice.get("id", "")) + if choice_id.is_empty() or seen.has(choice_id): + continue + seen[choice_id] = true + result.append(choice.duplicate(true)) + return result + + +func _normalized_dialogue_lines(source) -> Array: + var result := [] + if typeof(source) != TYPE_ARRAY: + return result + for line in source: + var normalized := _normalize_dialogue_line(line) + if not normalized.is_empty(): + result.append(normalized) + return result + + +func _normalized_formation_cells(source) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + if typeof(source) != TYPE_DICTIONARY: + return result + var seen := {} + for entry in source.get("cells", []): + if typeof(entry) != TYPE_ARRAY or entry.size() < 2: + continue + var cell := Vector2i(int(entry[0]), int(entry[1])) + var key := "%d,%d" % [cell.x, cell.y] + if seen.has(key): + continue + seen[key] = true + result.append(cell) + return result + + +func _joined_officer_lookup(source) -> Dictionary: + var result := {} + if typeof(source) == TYPE_DICTIONARY: + for officer_id in source.keys(): + if bool(source[officer_id]): + result[str(officer_id)] = true + return result + if typeof(source) == TYPE_ARRAY: + for officer_id in source: + var normalized := str(officer_id) + if normalized.is_empty(): + continue + result[normalized] = true + return result + + +func _is_deployment_available(unit: Dictionary) -> bool: + if unit.get("team", "") != TEAM_PLAYER: + return true + if not bool(unit.get("requires_joined", false)): + return true + var officer_id := str(unit.get("officer_id", "")) + return not officer_id.is_empty() and campaign_joined_officers.has(officer_id) + + +func _apply_deployment_rules() -> void: + var player_units := get_player_units(true) + var max_units := get_deployment_max_units() + var required_officers: Array = deployment_rules.get("required_officers", []) + var required_units: Array = deployment_rules.get("required_units", []) + var deployed_count := 0 + for unit in player_units: + var required := required_units.has(str(unit.get("id", ""))) or required_officers.has(str(unit.get("officer_id", ""))) + unit["required_deployment"] = required + unit["deployed"] = required + if required: + deployed_count += 1 + for unit in player_units: + if unit.get("deployed", false): + continue + var deploy := max_units <= 0 or deployed_count < max_units + unit["deployed"] = deploy + if deploy: + deployed_count += 1 + + +func _prepare_unit(source_unit: Dictionary) -> Dictionary: + if source_unit.is_empty(): + return {} + + var unit: Dictionary = source_unit.duplicate(true) + if not unit.has("id") and unit.has("unit_id"): + unit["id"] = unit["unit_id"] + if str(unit.get("id", "")).is_empty(): + push_error("Unit is missing id: %s" % str(source_unit)) + return {} + + var pos_array: Array = unit.get("pos", [0, 0]) + if pos_array.size() < 2: + push_error("Unit has malformed position: %s" % str(source_unit)) + return {} + unit["pos"] = Vector2i(int(pos_array[0]), int(pos_array[1])) + unit["class"] = str(unit.get("class", unit.get("class_id", "Unit"))).capitalize() + unit["hp"] = int(unit.get("hp", unit.get("max_hp", 1))) + unit["max_hp"] = int(unit.get("max_hp", unit["hp"])) + unit["max_mp"] = int(unit.get("max_mp", unit.get("mp", 0))) + unit["mp"] = int(unit.get("mp", unit["max_mp"])) + unit["atk"] = int(unit.get("atk", 1)) + unit["def"] = int(unit.get("def", 0)) + unit["int"] = int(unit.get("int", 0)) + unit["agi"] = int(unit.get("agi", 0)) + unit["move"] = int(unit.get("move", 3)) + unit["move_type"] = str(unit.get("move_type", "foot")) + if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY: + unit["skills"] = [] + else: + unit["skills"] = unit["skills"].duplicate(true) + if typeof(unit.get("range", 1)) == TYPE_ARRAY: + var range_array: Array = unit["range"] + if range_array.size() >= 2: + unit["min_range"] = max(1, int(unit.get("min_range", range_array[0]))) + unit["range"] = max(int(unit["min_range"]), int(range_array[1])) + elif range_array.size() == 1: + unit["range"] = max(1, int(range_array[0])) + unit["min_range"] = int(unit["range"]) + else: + unit["range"] = 1 + unit["min_range"] = 1 + else: + unit["range"] = max(1, int(unit.get("range", 1))) + unit["min_range"] = clampi(int(unit.get("min_range", min(1, int(unit["range"])))), 1, int(unit["range"])) + unit["level"] = int(unit.get("level", 1)) + unit["exp"] = int(unit.get("exp", 0)) + if not unit.has("growth") or typeof(unit["growth"]) != TYPE_DICTIONARY: + unit["growth"] = {} + if not unit.has("growth_bonus") or typeof(unit["growth_bonus"]) != TYPE_DICTIONARY: + unit["growth_bonus"] = {} + unit["alive"] = bool(unit.get("alive", true)) + unit["deployed"] = bool(unit.get("deployed", true)) + unit["required_deployment"] = bool(unit.get("required_deployment", false)) + unit["controllable"] = bool(unit.get("controllable", true)) + unit["persist_progression"] = bool(unit.get("persist_progression", true)) + unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0))) + unit["acted"] = false + unit["moved"] = false + return unit + + +func _apply_roster_overlay(unit: Dictionary, roster_overrides) -> void: + if typeof(roster_overrides) != TYPE_DICTIONARY: + return + if unit.get("team", "") != TEAM_PLAYER: + return + + var overlay := _find_roster_overlay(unit, roster_overrides) + if overlay.is_empty(): + return + + for key in ["class_id", "class", "level", "exp", "max_hp", "max_mp", "atk", "def", "int", "agi"]: + if overlay.has(key): + unit[key] = overlay[key] + + if overlay.has("equipment") and typeof(overlay["equipment"]) == TYPE_DICTIONARY: + unit["equipment"] = overlay["equipment"].duplicate(true) + if overlay.has("skills") and typeof(overlay["skills"]) == TYPE_ARRAY: + unit["skills"] = overlay["skills"].duplicate(true) + if overlay.has("class_id"): + _apply_class_runtime_fields(unit, str(unit.get("class_id", ""))) + else: + _refresh_attack_range_from_equipment(unit) + + unit["hp"] = int(unit.get("max_hp", unit.get("hp", 1))) + unit["mp"] = int(unit.get("max_mp", unit.get("mp", 0))) + unit["loaded_from_roster"] = true + + +func _find_roster_overlay(unit: Dictionary, roster_overrides: Dictionary) -> Dictionary: + var unit_id := str(unit.get("id", "")) + if roster_overrides.has(unit_id) and typeof(roster_overrides[unit_id]) == TYPE_DICTIONARY: + return roster_overrides[unit_id] + + var officer_id := str(unit.get("officer_id", "")) + if not officer_id.is_empty() and roster_overrides.has(officer_id) and typeof(roster_overrides[officer_id]) == TYPE_DICTIONARY: + return roster_overrides[officer_id] + + return {} + + +func select_unit(unit_id: String) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty() or not unit.get("alive", false): + return false + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return false + if not bool(unit.get("controllable", true)): + return false + if unit.get("team", "") != current_team: + return false + if unit.get("acted", false): + _emit_log("%s has already acted." % unit["name"]) + return false + + selected_unit_id = unit_id + _emit_log("Selected %s." % unit["name"]) + _notify_changed() + return true + + +func clear_selection() -> void: + selected_unit_id = "" + _notify_changed() + + +func try_move_selected(cell: Vector2i) -> bool: + var unit := get_selected_unit() + if unit.is_empty() or battle_status != STATUS_ACTIVE: + return false + if not bool(unit.get("controllable", true)): + return false + if unit.get("moved", false) or unit.get("acted", false): + return false + if not get_movement_range(unit["id"]).has(cell): + return false + if not get_unit_at(cell).is_empty(): + return false + + unit["pos"] = cell + unit["moved"] = true + _emit_log("%s moved to %s." % [unit["name"], _format_cell(cell)]) + _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) + _check_battle_status() + _notify_changed() + return true + + +func try_attack_selected(target_id: String) -> bool: + var attacker := get_selected_unit() + var target := get_unit(target_id) + if attacker.is_empty() or target.is_empty() or battle_status != STATUS_ACTIVE: + return false + if not bool(attacker.get("controllable", true)): + return false + if attacker.get("acted", false): + return false + if attacker.get("team", "") == target.get("team", ""): + return false + if not _is_in_attack_range(attacker, target["pos"]): + return false + + _resolve_combat(attacker, target) + attacker["acted"] = true + attacker["moved"] = true + selected_unit_id = "" + _check_battle_status() + _notify_changed() + return true + + +func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool: + var caster := get_selected_unit() + var skill := get_skill_def(skill_id) + if caster.is_empty() or skill.is_empty() or battle_status != STATUS_ACTIVE: + return false + if not bool(caster.get("controllable", true)): + return false + if caster.get("acted", false): + return false + if not _unit_has_skill(caster, skill_id): + return false + if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): + _emit_log("%s does not have enough MP." % caster["name"]) + return false + if not _skill_cells_from(caster, skill).has(target_cell): + return false + + var target := get_unit_at(target_cell) + if not _skill_target_valid(caster, target, skill): + return false + + var skill_kind := str(skill.get("kind", "damage")) + if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: + _emit_log("%s is already at full HP." % target["name"]) + return false + + caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) + if skill_kind == "heal": + var healed := _resolve_skill_heal(caster, target, skill) + if healed > 0: + _grant_experience(caster, EXP_HEAL) + else: + var defeated := _resolve_skill_damage(caster, target, skill) + _grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0)) + + caster["acted"] = true + caster["moved"] = true + selected_unit_id = "" + _check_battle_status() + _notify_changed() + return true + + +func try_use_selected_item_on_cell(item_id: String, target_cell: Vector2i) -> bool: + var user := get_selected_unit() + var item := get_item_def(item_id) + if user.is_empty() or item.is_empty() or battle_status != STATUS_ACTIVE: + return false + if not bool(user.get("controllable", true)): + return false + if user.get("acted", false): + return false + if int(battle_inventory.get(item_id, 0)) <= 0: + _emit_log("No %s remains." % item.get("name", item_id)) + return false + if str(item.get("kind", "")) != "consumable": + return false + if not _item_target_cells_from(user, item).has(target_cell): + return false + + var target := get_unit_at(target_cell) + if not _item_target_valid(user, target, item): + return false + + var applied := _apply_item_effects(user, target, item) + if not applied: + return false + + battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1) + user["acted"] = true + user["moved"] = true + selected_unit_id = "" + _emit_log("%s uses %s on %s." % [user["name"], item.get("name", item_id), target["name"]]) + _check_battle_status() + _notify_changed() + return true + + +func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: + var attacker := get_unit(attacker_id) + var target := get_unit(target_id) + if attacker.is_empty() or target.is_empty(): + return {} + if not attacker.get("alive", false) or not target.get("alive", false): + return {} + if attacker.get("team", "") == target.get("team", ""): + return {} + + var damage := calculate_damage(attacker, target) + var target_hp_after := max(0, int(target["hp"]) - damage) + var counter_damage := 0 + var attacker_hp_after := int(attacker["hp"]) + var hit_chance := calculate_hit_chance(attacker, target) + var counter_hit_chance := 0 + var counter_in_range := false + if target_hp_after > 0 or hit_chance < 100: + counter_in_range = _is_in_attack_range(target, attacker["pos"]) + if counter_in_range: + counter_damage = calculate_damage(target, attacker) + counter_hit_chance = calculate_hit_chance(target, attacker) + attacker_hp_after = max(0, int(attacker["hp"]) - counter_damage) + return { + "attacker_id": attacker_id, + "target_id": target_id, + "damage": damage, + "hit_chance": hit_chance, + "target_hp_after": target_hp_after, + "would_defeat": int(target["hp"]) - damage <= 0, + "in_range": _is_in_attack_range(attacker, target["pos"]), + "counter_in_range": counter_in_range, + "counter_damage": counter_damage, + "counter_hit_chance": counter_hit_chance, + "attacker_hp_after": attacker_hp_after, + "counter_would_defeat": attacker_hp_after <= 0 + } + + +func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2i) -> Dictionary: + var caster := get_unit(caster_id) + var skill := get_skill_def(skill_id) + if caster.is_empty() or skill.is_empty() or not is_inside(target_cell): + return {} + if not caster.get("alive", false) or not _unit_has_skill(caster, skill_id): + return {} + + var target := get_unit_at(target_cell) + var skill_kind := str(skill.get("kind", "damage")) + var preview := { + "skill_id": skill_id, + "skill_name": str(skill.get("name", skill_id)), + "kind": skill_kind, + "mp_cost": int(skill.get("mp_cost", 0)), + "has_mp": int(caster.get("mp", 0)) >= int(skill.get("mp_cost", 0)), + "in_range": _skill_cells_from(caster, skill).has(target_cell), + "valid_target": _skill_target_valid(caster, target, skill) + } + if target.is_empty(): + return preview + + preview["target_id"] = target.get("id", "") + if skill_kind == "heal": + var heal_amount := calculate_skill_heal(caster, target, skill) + preview["heal"] = heal_amount + preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + heal_amount) + else: + var damage := calculate_skill_damage(caster, target, skill) + preview["damage"] = damage + preview["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage) + preview["would_defeat"] = int(target.get("hp", 0)) - damage <= 0 + return preview + + +func get_item_preview(user_id: String, item_id: String, target_cell: Vector2i) -> Dictionary: + var user := get_unit(user_id) + var item := get_item_def(item_id) + if user.is_empty() or item.is_empty() or not is_inside(target_cell): + return {} + + var target := get_unit_at(target_cell) + var preview := { + "item_id": item_id, + "item_name": str(item.get("name", item_id)), + "count": int(battle_inventory.get(item_id, 0)), + "in_range": _item_target_cells_from(user, item).has(target_cell), + "valid_target": _item_target_valid(user, target, item) + } + if target.is_empty(): + return preview + + var hp_heal_amount := _item_hp_heal_amount(target, item) + var mp_heal_amount := _item_mp_heal_amount(target, item) + preview["heal"] = hp_heal_amount + preview["mp_heal"] = mp_heal_amount + preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + hp_heal_amount) + preview["target_mp_after"] = min(int(target.get("max_mp", 0)), int(target.get("mp", 0)) + mp_heal_amount) + return preview + + +func calculate_damage(attacker: Dictionary, target: Dictionary) -> int: + var terrain_defense := get_terrain_defense(target["pos"]) + return max(1, int(attacker["atk"]) - int(target["def"]) - terrain_defense) + + +func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int: + var terrain_avoid := get_terrain_avoid(target["pos"]) + var agility_delta := int(attacker.get("agi", 0)) - int(target.get("agi", 0)) + return clampi(BASE_HIT_CHANCE + agility_delta - terrain_avoid, MIN_HIT_CHANCE, 100) + + +func calculate_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: + var terrain_defense := get_terrain_defense(target["pos"]) + var stat_name := str(skill.get("stat", "int")) + var stat_value := int(caster.get(stat_name, 0)) + return max(1, int(skill.get("power", 1)) + stat_value - int(target.get("def", 0)) - terrain_defense) + + +func calculate_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: + var stat_name := str(skill.get("stat", "int")) + var raw_heal := int(skill.get("power", 1)) + int(caster.get(stat_name, 0)) + var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) + return max(0, min(raw_heal, missing_hp)) + + +func try_wait_selected() -> bool: + var unit := get_selected_unit() + if unit.is_empty() or battle_status != STATUS_ACTIVE: + return false + if not bool(unit.get("controllable", true)): + return false + + unit["acted"] = true + unit["moved"] = true + selected_unit_id = "" + _emit_log("%s waits." % unit["name"]) + _notify_changed() + return true + + +func end_player_turn() -> void: + if battle_status != STATUS_ACTIVE or current_team != TEAM_PLAYER: + return + + selected_unit_id = "" + current_team = TEAM_ENEMY + _reset_team_actions(TEAM_ENEMY) + _emit_log("Enemy phase begins.") + _run_events("turn_start", current_team, turn_number) + _check_battle_status() + if battle_status == STATUS_ACTIVE: + _run_enemy_turn() + + +func get_unit(unit_id: String) -> Dictionary: + for unit in units: + if unit.get("id", "") == unit_id: + return unit + return {} + + +func get_selected_unit() -> Dictionary: + if selected_unit_id.is_empty(): + return {} + return get_unit(selected_unit_id) + + +func get_unit_at(cell: Vector2i, include_dead := false) -> Dictionary: + for unit in units: + if not unit.get("deployed", true): + continue + if unit.get("pos", Vector2i(-99, -99)) == cell: + if include_dead or unit.get("alive", false): + return unit + return {} + + +func get_player_units(include_reserve := false) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for unit in units: + if unit.get("team", "") != TEAM_PLAYER: + continue + if not include_reserve and not unit.get("deployed", true): + continue + result.append(unit) + return result + + +func get_controllable_player_units(include_reserve := false) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for unit in get_player_units(include_reserve): + if not unit.get("alive", false): + continue + if not bool(unit.get("controllable", true)): + continue + result.append(unit) + return result + + +func get_living_units(team := "") -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for unit in units: + if not unit.get("deployed", true): + continue + if not unit.get("alive", false): + continue + if not team.is_empty() and unit.get("team", "") != team: + continue + result.append(unit) + return result + + +func get_movement_range(unit_id: String) -> Array[Vector2i]: + var unit := get_unit(unit_id) + if unit.is_empty() or unit.get("acted", false) or unit.get("moved", false): + return [] + if not bool(unit.get("controllable", true)): + return [] + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return [] + return _movement_range_for_unit(unit) + + +func get_attack_cells(unit_id: String) -> Array[Vector2i]: + var unit := get_unit(unit_id) + if unit.is_empty() or unit.get("acted", false): + return [] + if not bool(unit.get("controllable", true)): + return [] + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return [] + return _attack_cells_from(unit, unit["pos"]) + + +func get_skill_cells(unit_id: String, skill_id: String) -> Array[Vector2i]: + var unit := get_unit(unit_id) + var skill := get_skill_def(skill_id) + if unit.is_empty() or skill.is_empty() or unit.get("acted", false): + return [] + if not bool(unit.get("controllable", true)): + return [] + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return [] + if not _unit_has_skill(unit, skill_id): + return [] + if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): + return [] + return _skill_cells_from(unit, skill) + + +func get_item_target_cells(unit_id: String, item_id: String) -> Array[Vector2i]: + var unit := get_unit(unit_id) + var item := get_item_def(item_id) + if unit.is_empty() or item.is_empty() or unit.get("acted", false): + return [] + if not bool(unit.get("controllable", true)): + return [] + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return [] + if int(battle_inventory.get(item_id, 0)) <= 0: + return [] + return _item_target_cells_from(unit, item) + + +func get_objective_cells() -> Array[Vector2i]: + var result: Array[Vector2i] = [] + _collect_objective_cells(battle_conditions.get("victory", {}), result) + return result + + +func get_first_skill_id(unit_id: String) -> String: + var unit := get_unit(unit_id) + if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: + return "" + for skill_id in unit.get("skills", []): + if not get_skill_def(str(skill_id)).is_empty(): + return str(skill_id) + return "" + + +func get_skill_ids(unit_id: String) -> Array: + var result := [] + var unit := get_unit(unit_id) + if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: + return result + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return result + for skill_id in unit.get("skills", []): + var normalized := str(skill_id) + if not get_skill_def(normalized).is_empty(): + result.append(normalized) + return result + + +func get_first_usable_skill_id(unit_id: String) -> String: + var unit := get_unit(unit_id) + if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: + return "" + for skill_id in unit.get("skills", []): + var normalized := str(skill_id) + var skill := get_skill_def(normalized) + if skill.is_empty(): + continue + if int(unit.get("mp", 0)) >= int(skill.get("mp_cost", 0)): + return normalized + return "" + + +func get_usable_skill_ids(unit_id: String) -> Array: + var result := [] + var unit := get_unit(unit_id) + if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: + return result + if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): + return result + for skill_id in unit.get("skills", []): + var normalized := str(skill_id) + var skill := get_skill_def(normalized) + if skill.is_empty(): + continue + if int(unit.get("mp", 0)) >= int(skill.get("mp_cost", 0)): + result.append(normalized) + return result + + +func get_skill_def(skill_id: String) -> Dictionary: + return data_catalog.get_skill(skill_id) + + +func get_item_def(item_id: String) -> Dictionary: + return data_catalog.get_item(item_id) + + +func get_shop_item_ids() -> Array: + var result := [] + for item_id in shop.get("items", []): + var normalized := str(item_id) + var item := get_item_def(normalized) + if item.is_empty() or int(item.get("price", 0)) <= 0: + continue + result.append(normalized) + result.sort() + return result + + +func get_formation_cells() -> Array[Vector2i]: + return formation_cells.duplicate() + + +func get_deployment_max_units() -> int: + var configured := int(deployment_rules.get("max_units", 0)) + if configured > 0: + return configured + return get_player_units(true).size() + + +func has_deployment_roster() -> bool: + return ( + int(deployment_rules.get("max_units", 0)) > 0 + or not Array(deployment_rules.get("required_officers", [])).is_empty() + or not Array(deployment_rules.get("required_units", [])).is_empty() + ) + + +func get_deployed_player_count() -> int: + return get_player_units(false).size() + + +func is_required_deployment(unit_id: String) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty(): + return false + return bool(unit.get("required_deployment", false)) + + +func try_set_unit_deployed(unit_id: String, deployed: bool) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: + return false + if bool(unit.get("required_deployment", false)) and not deployed: + _emit_log("%s must deploy." % unit["name"]) + return false + if bool(unit.get("deployed", true)) == deployed: + return true + if deployed: + if get_deployed_player_count() >= get_deployment_max_units(): + _emit_log("Deployment limit reached.") + return false + var target_cell := _formation_cell_for_deployment(unit) + if not is_inside(target_cell): + _emit_log("No open formation cell for %s." % unit["name"]) + return false + unit["pos"] = target_cell + unit["alive"] = true + unit["deployed"] = true + _emit_log("%s joins the sortie." % unit["name"]) + else: + unit["deployed"] = false + _emit_log("%s moves to reserve." % unit["name"]) + _notify_changed() + return true + + +func is_formation_cell(cell: Vector2i) -> bool: + return formation_cells.has(cell) + + +func try_set_prebattle_formation(unit_id: String, cell: Vector2i) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: + return false + if not bool(unit.get("controllable", true)): + return false + if not unit.get("deployed", true): + return false + if not is_formation_cell(unit.get("pos", Vector2i(-99, -99))): + return false + if not is_formation_cell(cell) or not is_inside(cell): + return false + if get_move_cost(cell, str(unit.get("move_type", "foot"))) >= 99: + return false + + var occupant := get_unit_at(cell) + if not occupant.is_empty(): + if occupant.get("team", "") != TEAM_PLAYER: + return false + if not bool(occupant.get("controllable", true)): + return false + if occupant.get("id", "") == unit_id: + return true + occupant["pos"] = unit["pos"] + unit["pos"] = cell + _emit_log("%s takes formation at %s." % [unit["name"], _format_cell(cell)]) + _notify_changed() + return true + + +func _formation_cell_for_deployment(unit: Dictionary) -> Vector2i: + var current: Vector2i = unit.get("pos", Vector2i(-99, -99)) + if is_formation_cell(current) and get_unit_at(current).is_empty() and get_move_cost(current, str(unit.get("move_type", "foot"))) < 99: + return current + for cell in formation_cells: + if not get_unit_at(cell).is_empty(): + continue + if get_move_cost(cell, str(unit.get("move_type", "foot"))) >= 99: + continue + return cell + return Vector2i(-99, -99) + + +func try_equip_item(unit_id: String, item_id: String) -> bool: + var unit := get_unit(unit_id) + var item := get_item_def(item_id) + if unit.is_empty() or item.is_empty() or battle_status != STATUS_ACTIVE: + return false + if unit.get("team", "") != TEAM_PLAYER or current_team != TEAM_PLAYER: + return false + if not bool(unit.get("controllable", true)): + return false + if unit.get("moved", false) or unit.get("acted", false): + _emit_log("%s can change equipment before moving or acting." % unit["name"]) + return false + + var slot := _equipment_slot_for_item(item) + if slot.is_empty(): + return false + if int(battle_inventory.get(item_id, 0)) <= 0: + _emit_log("No %s is in inventory." % item.get("name", item_id)) + return false + if not _unit_can_equip_item(unit, item): + _emit_log("%s cannot equip %s." % [unit["name"], item.get("name", item_id)]) + return false + + var equipment: Dictionary = unit.get("equipment", {}).duplicate(true) + var old_item_id := str(equipment.get(slot, "")) + if old_item_id == item_id: + _emit_log("%s already has %s equipped." % [unit["name"], item.get("name", item_id)]) + return false + + battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1) + var old_item := get_item_def(old_item_id) + if not old_item_id.is_empty() and not old_item.is_empty(): + battle_inventory[old_item_id] = int(battle_inventory.get(old_item_id, 0)) + 1 + + equipment[slot] = item_id + unit["equipment"] = equipment + _apply_equipment_stat_delta(unit, old_item, item) + _refresh_attack_range_from_equipment(unit) + _emit_log("%s equips %s." % [unit["name"], item.get("name", item_id)]) + _notify_changed() + return true + + +func get_equippable_item_ids(unit_id: String) -> Array: + var result := [] + var unit := get_unit(unit_id) + if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: + return result + if not bool(unit.get("controllable", true)): + return result + if not unit.get("deployed", true): + return result + var equipment: Dictionary = unit.get("equipment", {}) + for item_id in battle_inventory.keys(): + var normalized := str(item_id) + if int(battle_inventory.get(normalized, 0)) <= 0: + continue + var item := get_item_def(normalized) + if item.is_empty() or _equipment_slot_for_item(item).is_empty(): + continue + var slot := _equipment_slot_for_item(item) + if str(equipment.get(slot, "")) == normalized: + continue + if not _unit_can_equip_item(unit, item): + continue + result.append(normalized) + result.sort() + return result + + +func get_equipment_snapshot(unit_id: String) -> Dictionary: + var unit := get_unit(unit_id) + if unit.is_empty(): + return {} + return unit.get("equipment", {}).duplicate(true) + + +func get_usable_item_ids() -> Array: + var result := [] + for item_id in battle_inventory.keys(): + var normalized := str(item_id) + if int(battle_inventory.get(normalized, 0)) <= 0: + continue + var item := get_item_def(normalized) + if item.is_empty() or str(item.get("kind", "")) != "consumable": + continue + result.append(normalized) + result.sort() + return result + + +func get_inventory_snapshot() -> Dictionary: + return battle_inventory.duplicate(true) + + +func set_inventory_snapshot(inventory_snapshot: Dictionary) -> void: + battle_inventory = inventory_snapshot.duplicate(true) + _notify_changed() + + +func get_terrain_key(cell: Vector2i) -> String: + if not is_inside(cell): + return "G" + if cell.y >= terrain_rows.size(): + return "G" + var row := terrain_rows[cell.y] + if cell.x >= row.length(): + return "G" + return row.substr(cell.x, 1) + + +func get_terrain_name(cell: Vector2i) -> String: + return String(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("name", "Plain")) + + +func get_terrain_color(cell: Vector2i) -> Color: + return terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("color", DEFAULT_TERRAIN_DEFS["G"]["color"]) + + +func get_move_cost(cell: Vector2i, move_type := "foot") -> int: + var move_cost = terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("move_cost", 1) + if typeof(move_cost) == TYPE_DICTIONARY: + return int(move_cost.get(move_type, move_cost.get("foot", 1))) + return int(move_cost) + + +func get_terrain_defense(cell: Vector2i) -> int: + return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("defense", 0)) + + +func get_terrain_avoid(cell: Vector2i) -> int: + return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("avoid", 0)) + + +func get_cell_summary(cell: Vector2i) -> Dictionary: + if not is_inside(cell): + return {} + + var unit := get_unit_at(cell) + return { + "cell": cell, + "terrain": get_terrain_name(cell), + "move_cost": get_move_cost(cell), + "defense": get_terrain_defense(cell), + "avoid": get_terrain_avoid(cell), + "unit": unit + } + + +func get_briefing() -> Dictionary: + return briefing.duplicate(true) + + +func get_rewards() -> Dictionary: + return rewards.duplicate(true) + + +func get_post_battle_choices() -> Array: + return post_battle_choices.duplicate(true) + + +func get_post_battle_dialogue() -> Array: + return post_battle_dialogue.duplicate(true) + + +func get_progression_events() -> Array: + return progression_events.duplicate(true) + + +func get_player_roster_snapshot() -> Dictionary: + var snapshot := {} + for unit in units: + if unit.get("team", "") != TEAM_PLAYER: + continue + if not bool(unit.get("persist_progression", true)): + continue + var roster_key := str(unit.get("officer_id", unit["id"])) + if roster_key.is_empty(): + roster_key = str(unit["id"]) + snapshot[roster_key] = { + "id": unit["id"], + "officer_id": roster_key, + "name": unit["name"], + "class_id": unit.get("class_id", ""), + "class": unit["class"], + "move": int(unit.get("move", 3)), + "move_type": str(unit.get("move_type", "foot")), + "min_range": int(unit.get("min_range", 1)), + "range": int(unit.get("range", 1)), + "level": int(unit.get("level", 1)), + "exp": int(unit.get("exp", 0)), + "hp": int(unit.get("hp", 0)), + "max_hp": int(unit.get("max_hp", 1)), + "mp": int(unit.get("mp", 0)), + "max_mp": int(unit.get("max_mp", 0)), + "atk": int(unit.get("atk", 1)), + "def": int(unit.get("def", 0)), + "int": int(unit.get("int", 0)), + "agi": int(unit.get("agi", 0)), + "growth": unit.get("growth", {}).duplicate(true), + "skills": unit.get("skills", []).duplicate(true), + "equipment": unit.get("equipment", {}).duplicate(true) + } + return snapshot + + +func is_inside(cell: Vector2i) -> bool: + return cell.x >= 0 and cell.y >= 0 and cell.x < map_size.x and cell.y < map_size.y + + +func can_player_act() -> bool: + return battle_status == STATUS_ACTIVE and current_team == TEAM_PLAYER + + +func get_status_text() -> String: + if battle_status == STATUS_VICTORY: + return "Victory" + if battle_status == STATUS_DEFEAT: + return "Defeat" + var turn_limit := get_turn_limit() + if turn_limit > 0: + return "Turn %d/%d - %s" % [turn_number, turn_limit, current_team.capitalize()] + return "Turn %d - %s" % [turn_number, current_team.capitalize()] + + +func get_turn_limit() -> int: + return _find_turn_limit(battle_conditions.get("defeat", {})) + + +func _movement_range_for_unit(unit: Dictionary) -> Array[Vector2i]: + var start: Vector2i = unit["pos"] + var frontier: Array[Vector2i] = [start] + var costs := {start: 0} + var result: Array[Vector2i] = [] + + while not frontier.is_empty(): + var current: Vector2i = frontier.pop_front() + for direction in CARDINAL_DIRECTIONS: + var next_cell: Vector2i = current + direction + if not is_inside(next_cell): + continue + + var terrain_cost := get_move_cost(next_cell, str(unit.get("move_type", "foot"))) + if terrain_cost >= 99: + continue + if next_cell != start and not get_unit_at(next_cell).is_empty(): + continue + + var new_cost := int(costs[current]) + terrain_cost + if new_cost > int(unit["move"]): + continue + if not costs.has(next_cell) or new_cost < int(costs[next_cell]): + costs[next_cell] = new_cost + frontier.append(next_cell) + if next_cell != start and not result.has(next_cell): + result.append(next_cell) + + return result + + +func _attack_cells_from(unit: Dictionary, origin: Vector2i) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + var attack_range := int(unit.get("range", 1)) + var min_range := int(unit.get("min_range", min(1, attack_range))) + for y in range(origin.y - attack_range, origin.y + attack_range + 1): + for x in range(origin.x - attack_range, origin.x + attack_range + 1): + var cell := Vector2i(x, y) + if cell == origin or not is_inside(cell): + continue + var distance := _manhattan(origin, cell) + if distance >= min_range and distance <= attack_range: + result.append(cell) + return result + + +func _is_in_attack_range(attacker: Dictionary, target_cell: Vector2i) -> bool: + return _attack_cells_from(attacker, attacker["pos"]).has(target_cell) + + +func _skill_cells_from(unit: Dictionary, skill: Dictionary) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + var range_pair := _skill_range_pair(skill) + var max_range := int(range_pair["max"]) + var min_range := int(range_pair["min"]) + var origin: Vector2i = unit["pos"] + for y in range(origin.y - max_range, origin.y + max_range + 1): + for x in range(origin.x - max_range, origin.x + max_range + 1): + var cell := Vector2i(x, y) + if not is_inside(cell): + continue + var distance := _manhattan(origin, cell) + if distance >= min_range and distance <= max_range: + result.append(cell) + return result + + +func _skill_range_pair(skill: Dictionary) -> Dictionary: + var value = skill.get("range", 1) + if typeof(value) == TYPE_ARRAY: + if value.size() >= 2: + return _normalized_skill_range(int(value[0]), int(value[1])) + if value.size() == 1: + return _normalized_skill_range(int(value[0]), int(value[0])) + return _normalized_skill_range(1, 1) + if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: + return _normalized_skill_range(1, 1) + var scalar := max(0, int(value)) + return _normalized_skill_range(scalar, scalar) + + +func _normalized_skill_range(min_range: int, max_range: int) -> Dictionary: + var safe_min := max(0, min_range) + var safe_max := max(safe_min, max_range) + return {"min": safe_min, "max": safe_max} + + +func _unit_has_skill(unit: Dictionary, skill_id: String) -> bool: + if typeof(unit.get("skills", [])) != TYPE_ARRAY: + return false + for known_skill_id in unit.get("skills", []): + if str(known_skill_id) == skill_id: + return true + return false + + +func _skill_target_valid(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool: + if target.is_empty() or not target.get("alive", false): + return false + var target_rule := str(skill.get("target", "enemy")) + if target_rule == "ally": + return target.get("team", "") == caster.get("team", "") + if target_rule == "self": + return target.get("id", "") == caster.get("id", "") + if target_rule == "any": + return true + return target.get("team", "") != caster.get("team", "") + + +func _item_target_cells_from(unit: Dictionary, item: Dictionary) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + var range_pair := _item_range_pair(item) + var max_range := int(range_pair["max"]) + var min_range := int(range_pair["min"]) + var origin: Vector2i = unit["pos"] + for y in range(origin.y - max_range, origin.y + max_range + 1): + for x in range(origin.x - max_range, origin.x + max_range + 1): + var cell := Vector2i(x, y) + if not is_inside(cell): + continue + var distance := _manhattan(origin, cell) + if distance >= min_range and distance <= max_range: + result.append(cell) + return result + + +func _item_range_pair(item: Dictionary) -> Dictionary: + var value = item.get("range", [0, 1]) + if typeof(value) == TYPE_ARRAY: + if value.size() >= 2: + return _normalized_skill_range(int(value[0]), int(value[1])) + if value.size() == 1: + return _normalized_skill_range(int(value[0]), int(value[0])) + return _normalized_skill_range(0, 1) + if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: + return _normalized_skill_range(0, 1) + var scalar := max(0, int(value)) + return _normalized_skill_range(scalar, scalar) + + +func _item_target_valid(user: Dictionary, target: Dictionary, item: Dictionary) -> bool: + if target.is_empty() or not target.get("alive", false): + return false + var target_rule := str(item.get("target", "ally")) + if target_rule == "ally": + return target.get("team", "") == user.get("team", "") + if target_rule == "self": + return target.get("id", "") == user.get("id", "") + if target_rule == "any": + return true + return target.get("team", "") != user.get("team", "") + + +func _apply_item_effects(user: Dictionary, target: Dictionary, item: Dictionary) -> bool: + var applied := false + for effect in item.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + var effect_type := str(effect.get("type", "")) + if effect_type == "heal_hp": + var healed := _apply_item_heal(target, int(effect.get("amount", 0))) + if healed > 0: + applied = true + _emit_log("%s recovers %d HP." % [target["name"], healed]) + elif effect_type == "heal_mp": + var restored := _apply_item_mp_heal(target, int(effect.get("amount", 0))) + if restored > 0: + applied = true + _emit_log("%s recovers %d MP." % [target["name"], restored]) + if not applied: + _emit_log("%s has no effect on %s." % [item.get("name", "Item"), target["name"]]) + return applied + + +func _apply_item_heal(target: Dictionary, amount: int) -> int: + if amount <= 0: + return 0 + var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) + var healed = min(amount, max(0, missing_hp)) + target["hp"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + healed) + return healed + + +func _apply_item_mp_heal(target: Dictionary, amount: int) -> int: + if amount <= 0: + return 0 + var missing_mp := int(target.get("max_mp", 0)) - int(target.get("mp", 0)) + var restored = min(amount, max(0, missing_mp)) + target["mp"] = min(int(target.get("max_mp", 0)), int(target.get("mp", 0)) + restored) + return restored + + +func _item_hp_heal_amount(target: Dictionary, item: Dictionary) -> int: + var total := 0 + for effect in item.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) == "heal_hp": + total += int(effect.get("amount", 0)) + var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) + return max(0, min(total, missing_hp)) + + +func _item_mp_heal_amount(target: Dictionary, item: Dictionary) -> int: + var total := 0 + for effect in item.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) == "heal_mp": + total += int(effect.get("amount", 0)) + var missing_mp := int(target.get("max_mp", 0)) - int(target.get("mp", 0)) + return max(0, min(total, missing_mp)) + + +func _equipment_slot_for_item(item: Dictionary) -> String: + var kind := str(item.get("kind", "")) + if kind == "weapon" or kind == "armor" or kind == "accessory": + return kind + return "" + + +func _unit_can_equip_item(unit: Dictionary, item: Dictionary) -> bool: + var slot := _equipment_slot_for_item(item) + if slot.is_empty(): + return false + var class_def := data_catalog.get_class_def(str(unit.get("class_id", ""))) + var equipment_slots: Dictionary = class_def.get("equipment_slots", {}) + var allowed = equipment_slots.get(slot, []) + if typeof(allowed) != TYPE_ARRAY: + return false + if slot == "weapon": + return allowed.has(str(item.get("weapon_type", ""))) + if slot == "armor": + return allowed.has(str(item.get("armor_type", ""))) + if slot == "accessory": + var accessory_type := str(item.get("accessory_type", "accessory")) + return allowed.has(accessory_type) + return false + + +func _apply_equipment_stat_delta(unit: Dictionary, old_item: Dictionary, new_item: Dictionary) -> void: + var old_bonuses: Dictionary = old_item.get("bonuses", {}) if not old_item.is_empty() else {} + var new_bonuses: Dictionary = new_item.get("bonuses", {}) if not new_item.is_empty() else {} + _apply_stat_bonus_delta(unit, old_bonuses, new_bonuses) + + +func _apply_stat_bonus_delta(unit: Dictionary, old_bonuses: Dictionary, new_bonuses: Dictionary) -> void: + for stat in ["hp", "mp", "atk", "def", "int", "agi"]: + var delta := int(new_bonuses.get(stat, 0)) - int(old_bonuses.get(stat, 0)) + if delta == 0: + continue + if stat == "hp": + unit["max_hp"] = max(1, int(unit.get("max_hp", 1)) + delta) + unit["hp"] = clampi(int(unit.get("hp", 1)) + delta, 1, int(unit["max_hp"])) + elif stat == "mp": + unit["max_mp"] = max(0, int(unit.get("max_mp", 0)) + delta) + unit["mp"] = clampi(int(unit.get("mp", 0)) + delta, 0, int(unit["max_mp"])) + elif stat == "atk": + unit[stat] = max(1, int(unit.get(stat, 1)) + delta) + else: + unit[stat] = max(0, int(unit.get(stat, 0)) + delta) + + +func _apply_class_runtime_fields(unit: Dictionary, class_id: String) -> void: + var class_def := data_catalog.get_class_def(class_id) + if class_def.is_empty(): + return + unit["class"] = str(class_def.get("name", class_id.capitalize())) + unit["move"] = int(class_def.get("move", unit.get("move", 3))) + unit["move_type"] = str(class_def.get("move_type", unit.get("move_type", "foot"))) + unit["growth"] = class_def.get("growth", {}).duplicate(true) + _add_missing_skills(unit, class_def.get("skills", [])) + _refresh_attack_range_from_equipment(unit) + + +func _refresh_attack_range_from_equipment(unit: Dictionary) -> void: + var class_def := data_catalog.get_class_def(str(unit.get("class_id", ""))) + var range_pair := _equipment_attack_range_pair(class_def, unit.get("equipment", {})) + unit["min_range"] = int(range_pair["min"]) + unit["range"] = int(range_pair["max"]) + + +func _equipment_attack_range_pair(class_def: Dictionary, equipment: Dictionary) -> Dictionary: + var attack_range := _attack_range_pair_from_value(class_def.get("attack_range", 1)) + for slot in equipment.keys(): + var item_id = equipment[slot] + if item_id == null: + continue + var item := get_item_def(str(item_id)) + if item.has("range"): + var item_range := _attack_range_pair_from_value(item["range"]) + attack_range["min"] = min(int(attack_range["min"]), int(item_range["min"])) + attack_range["max"] = max(int(attack_range["max"]), int(item_range["max"])) + return attack_range + + +func _attack_range_pair_from_value(value) -> Dictionary: + if typeof(value) == TYPE_ARRAY: + if value.size() >= 2: + return _normalized_attack_range_pair(int(value[0]), int(value[1])) + if value.size() == 1: + return _normalized_attack_range_pair(int(value[0]), int(value[0])) + return _normalized_attack_range_pair(1, 1) + if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: + return _normalized_attack_range_pair(1, 1) + var scalar := max(1, int(value)) + return _normalized_attack_range_pair(scalar, scalar) + + +func _normalized_attack_range_pair(min_range: int, max_range: int) -> Dictionary: + var safe_min := max(1, min_range) + var safe_max := max(safe_min, max_range) + return {"min": safe_min, "max": safe_max} + + +func _run_enemy_turn() -> void: + for enemy in get_living_units(TEAM_ENEMY): + if battle_status != STATUS_ACTIVE: + break + if enemy.get("acted", false): + continue + _enemy_take_action(enemy) + enemy["acted"] = true + enemy["moved"] = true + _check_battle_status() + + if battle_status == STATUS_ACTIVE: + turn_number += 1 + current_team = TEAM_PLAYER + _reset_team_actions(TEAM_PLAYER) + _emit_log("Player phase begins.") + _run_events("turn_start", current_team, turn_number) + _check_battle_status() + + _notify_changed() + + +func _enemy_take_action(enemy: Dictionary) -> void: + if _try_enemy_skill_action(enemy): + return + + var target := _find_nearest_target(enemy, TEAM_PLAYER) + if target.is_empty(): + return + + if _is_in_attack_range(enemy, target["pos"]): + _resolve_combat(enemy, target) + return + + var best_cell: Vector2i = enemy["pos"] + var best_distance := _manhattan(best_cell, target["pos"]) + for cell in _movement_range_for_unit(enemy): + var distance := _manhattan(cell, target["pos"]) + if distance < best_distance: + best_distance = distance + best_cell = cell + + if best_cell != enemy["pos"]: + enemy["pos"] = best_cell + enemy["moved"] = true + _emit_log("%s advances to %s." % [enemy["name"], _format_cell(best_cell)]) + _run_events("unit_reaches_tile", str(enemy.get("team", "")), turn_number, enemy) + if battle_status != STATUS_ACTIVE: + return + + if _try_enemy_skill_action(enemy): + return + + target = _find_nearest_target(enemy, TEAM_PLAYER) + if not target.is_empty() and _is_in_attack_range(enemy, target["pos"]): + _resolve_combat(enemy, target) + + +func _try_enemy_skill_action(enemy: Dictionary) -> bool: + if enemy.is_empty() or enemy.get("acted", false): + return false + if typeof(enemy.get("skills", [])) != TYPE_ARRAY: + return false + + var finishing_action := _best_ai_damage_skill_action(enemy, true) + if not finishing_action.is_empty() and _execute_ai_skill_action(enemy, finishing_action): + return true + + var heal_action := _best_ai_heal_skill_action(enemy) + if not heal_action.is_empty() and _execute_ai_skill_action(enemy, heal_action): + return true + + var damage_action := _best_ai_damage_skill_action(enemy, false) + if not damage_action.is_empty() and _execute_ai_skill_action(enemy, damage_action): + return true + + return false + + +func _best_ai_heal_skill_action(caster: Dictionary) -> Dictionary: + var best_action := {} + var best_score := -1 + for skill_id in caster.get("skills", []): + var normalized_skill_id := str(skill_id) + var skill := get_skill_def(normalized_skill_id) + if skill.is_empty() or str(skill.get("kind", "")) != "heal": + continue + if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): + continue + for ally in get_living_units(str(caster.get("team", ""))): + if not _skill_cells_from(caster, skill).has(ally["pos"]): + continue + if not _skill_target_valid(caster, ally, skill): + continue + var heal_amount := calculate_skill_heal(caster, ally, skill) + if heal_amount <= 0: + continue + var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0)) + var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1)))) + if hp_ratio > 0.5 and missing_hp < 10: + continue + var score := heal_amount * 10 + missing_hp + if score > best_score: + best_score = score + best_action = { + "skill_id": normalized_skill_id, + "skill": skill, + "target": ally + } + return best_action + + +func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) -> Dictionary: + var best_action := {} + var best_score := -1 + var target_team := _opposing_team(str(caster.get("team", ""))) + if target_team.is_empty(): + return {} + + for skill_id in caster.get("skills", []): + var normalized_skill_id := str(skill_id) + var skill := get_skill_def(normalized_skill_id) + if skill.is_empty() or str(skill.get("kind", "")) != "damage": + continue + if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): + continue + for target in get_living_units(target_team): + if not _skill_cells_from(caster, skill).has(target["pos"]): + continue + if not _skill_target_valid(caster, target, skill): + continue + var damage := calculate_skill_damage(caster, target, skill) + var would_defeat := int(target.get("hp", 0)) - damage <= 0 + if defeating_only and not would_defeat: + continue + var score := damage + _ai_target_priority_score(target) + if would_defeat: + score += 1000 + score -= _manhattan(caster["pos"], target["pos"]) + if score > best_score: + best_score = score + best_action = { + "skill_id": normalized_skill_id, + "skill": skill, + "target": target + } + return best_action + + +func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: + var skill_id := str(action.get("skill_id", "")) + var skill: Dictionary = action.get("skill", {}) + var target: Dictionary = action.get("target", {}) + if skill_id.is_empty() or skill.is_empty() or target.is_empty(): + return false + if not _unit_has_skill(caster, skill_id): + return false + if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): + return false + if not target.get("alive", false): + return false + if not _skill_cells_from(caster, skill).has(target["pos"]): + return false + if not _skill_target_valid(caster, target, skill): + return false + if str(skill.get("kind", "")) == "heal" and calculate_skill_heal(caster, target, skill) <= 0: + return false + + caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) + if str(skill.get("kind", "")) == "heal": + var healed := _resolve_skill_heal(caster, target, skill) + if healed > 0: + _grant_experience(caster, EXP_HEAL) + else: + var defeated := _resolve_skill_damage(caster, target, skill) + _grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0)) + _emit_log("%s spends %d MP." % [caster["name"], int(skill.get("mp_cost", 0))]) + return true + + +func _find_nearest_target(unit: Dictionary, target_team: String) -> Dictionary: + var best_target := {} + var best_score := 9999 + for target in get_living_units(target_team): + var distance := _manhattan(unit["pos"], target["pos"]) + var score := distance - int(target.get("ai_target_priority", 0)) + if score < best_score: + best_score = score + best_target = target + return best_target + + +func _ai_target_priority_score(target: Dictionary) -> int: + return max(0, int(target.get("ai_target_priority", 0))) * 8 + + +func _opposing_team(team: String) -> String: + if team == TEAM_PLAYER: + return TEAM_ENEMY + if team == TEAM_ENEMY: + return TEAM_PLAYER + return "" + + +func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void: + var attack_result := _resolve_attack(attacker, target, false) + var attack_exp := EXP_ATTACK if bool(attack_result.get("hit", false)) else EXP_MISS + if bool(attack_result.get("defeated", false)) or battle_status != STATUS_ACTIVE: + _grant_experience(attacker, attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0)) + return + + var counter_exp := 0 + if bool(target.get("controllable", true)) and _is_in_attack_range(target, attacker["pos"]): + var counter_result := _resolve_attack(target, attacker, true) + counter_exp = (EXP_COUNTER if bool(counter_result.get("hit", false)) else EXP_MISS) + (EXP_DEFEAT_BONUS if bool(counter_result.get("defeated", false)) else 0) + + _grant_experience(attacker, attack_exp) + _grant_experience(target, counter_exp) + + +func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary: + var hit_chance := calculate_hit_chance(attacker, target) + var verb := "counterattacks" if is_counter else "attacks" + if rng.randi_range(1, 100) > hit_chance: + _emit_log("%s %s %s but misses. Hit %d%%." % [attacker["name"], verb, target["name"], hit_chance]) + return {"hit": false, "defeated": false} + var damage := calculate_damage(attacker, target) + target["hp"] = max(0, int(target["hp"]) - damage) + _emit_log("%s %s %s for %d damage. Hit %d%%." % [attacker["name"], verb, target["name"], damage, hit_chance]) + + if int(target["hp"]) <= 0: + target["alive"] = false + _emit_log("%s is defeated." % target["name"]) + return {"hit": true, "defeated": true} + return {"hit": true, "defeated": false} + + +func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool: + var damage := calculate_skill_damage(caster, target, skill) + target["hp"] = max(0, int(target["hp"]) - damage) + _emit_log("%s casts %s on %s for %d damage." % [ + caster["name"], + skill.get("name", "Skill"), + target["name"], + damage + ]) + + if int(target["hp"]) <= 0: + target["alive"] = false + _emit_log("%s is defeated." % target["name"]) + return true + return false + + +func _resolve_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: + var healed := calculate_skill_heal(caster, target, skill) + target["hp"] = min(int(target["max_hp"]), int(target["hp"]) + healed) + _emit_log("%s casts %s on %s, restoring %d HP." % [ + caster["name"], + skill.get("name", "Skill"), + target["name"], + healed + ]) + return healed + + +func _grant_experience(unit: Dictionary, amount: int) -> void: + if unit.is_empty() or not unit.get("alive", false) or amount <= 0: + return + unit["exp"] = int(unit.get("exp", 0)) + amount + _emit_log("%s gains %d EXP." % [unit["name"], amount]) + while int(unit["exp"]) >= EXP_LEVEL: + unit["exp"] = int(unit["exp"]) - EXP_LEVEL + _level_up(unit) + + +func _level_up(unit: Dictionary) -> void: + var previous_level := int(unit.get("level", 1)) + unit["level"] = int(unit.get("level", 1)) + 1 + var hp_gain := _growth_gain(unit, "hp", 3) + var mp_gain := _growth_gain(unit, "mp", 1) + unit["max_hp"] = int(unit.get("max_hp", 1)) + hp_gain + unit["hp"] = min(int(unit["max_hp"]), int(unit.get("hp", 1)) + hp_gain) + unit["max_mp"] = int(unit.get("max_mp", 0)) + mp_gain + unit["mp"] = min(int(unit["max_mp"]), int(unit.get("mp", 0)) + mp_gain) + for stat in ["atk", "def", "int", "agi"]: + unit[stat] = int(unit.get(stat, 0)) + _growth_gain(unit, stat, 1) + _emit_log("%s reaches Lv.%d." % [unit["name"], unit["level"]]) + if str(unit.get("team", "")) == TEAM_PLAYER: + _record_progression_event({ + "type": "level_up", + "unit_id": str(unit.get("id", "")), + "officer_id": str(unit.get("officer_id", "")), + "name": str(unit.get("name", "Unit")), + "from_level": previous_level, + "to_level": int(unit.get("level", previous_level + 1)), + "class_id": str(unit.get("class_id", "")), + "class": str(unit.get("class", "")) + }) + _try_promote_unit(unit) + + +func _try_promote_unit(unit: Dictionary) -> void: + var class_id := str(unit.get("class_id", "")) + var class_def := data_catalog.get_class_def(class_id) + if class_def.is_empty(): + return + var promotion = class_def.get("promotion", {}) + if typeof(promotion) != TYPE_DICTIONARY: + return + var required_level := int(promotion.get("level", 0)) + var next_class_id := str(promotion.get("to", "")) + if required_level <= 0 or next_class_id.is_empty() or int(unit.get("level", 1)) < required_level: + return + var next_class_def := data_catalog.get_class_def(next_class_id) + if next_class_def.is_empty(): + return + var previous_class_id := class_id + var previous_class_name := str(class_def.get("name", class_id.capitalize())) + _apply_stat_bonus_delta(unit, class_def.get("base_bonus", {}), next_class_def.get("base_bonus", {})) + unit["class_id"] = next_class_id + _apply_class_runtime_fields(unit, next_class_id) + _emit_log("%s promotes to %s." % [unit["name"], unit["class"]]) + if str(unit.get("team", "")) == TEAM_PLAYER: + _record_progression_event({ + "type": "promotion", + "unit_id": str(unit.get("id", "")), + "officer_id": str(unit.get("officer_id", "")), + "name": str(unit.get("name", "Unit")), + "level": int(unit.get("level", 1)), + "from_class_id": previous_class_id, + "from_class": previous_class_name, + "to_class_id": next_class_id, + "to_class": str(unit.get("class", next_class_id.capitalize())) + }) + + +func _add_missing_skills(unit: Dictionary, source) -> void: + if typeof(source) != TYPE_ARRAY: + return + if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY: + unit["skills"] = [] + for skill_id in source: + var normalized := str(skill_id) + if normalized.is_empty() or unit["skills"].has(normalized): + continue + unit["skills"].append(normalized) + + +func _record_progression_event(event: Dictionary) -> void: + if str(event.get("name", "")).is_empty() or str(event.get("type", "")).is_empty(): + return + progression_events.append(event.duplicate(true)) + + +func _growth_gain(unit: Dictionary, stat: String, fallback: int) -> int: + var growth: Dictionary = unit.get("growth", {}) + var grade := str(growth.get(stat, "C")) + var table: Dictionary = GROWTH_GRADE_GAINS.get(stat, {}) + var gain := int(table.get(grade, fallback)) + var bonus: Dictionary = unit.get("growth_bonus", {}) + return max(0, gain + int(bonus.get(stat, 0))) + + +func _check_battle_status() -> void: + if _is_condition_group_met(battle_conditions.get("defeat", {})): + battle_status = STATUS_DEFEAT + current_team = TEAM_PLAYER + selected_unit_id = "" + _emit_log("Defeat condition met.") + elif _is_condition_group_met(battle_conditions.get("victory", {})): + battle_status = STATUS_VICTORY + current_team = TEAM_PLAYER + selected_unit_id = "" + _emit_log("Victory condition met.") + + +func _is_condition_group_met(condition_group) -> bool: + if typeof(condition_group) == TYPE_ARRAY: + for condition in condition_group: + if _is_condition_met(condition): + return true + return false + return _is_condition_met(condition_group) + + +func _is_condition_met(condition) -> bool: + if typeof(condition) != TYPE_DICTIONARY: + return false + if not _condition_gate_open(condition): + return false + + var condition_type := str(condition.get("type", "")) + if condition_type == "all": + return _all_conditions_met(condition.get("conditions", [])) + if condition_type == "any": + return _is_condition_group_met(condition.get("conditions", [])) + if condition_type == "all_units_defeated": + return get_living_units(str(condition.get("team", ""))).is_empty() + if condition_type == "all_enemies_defeated": + return get_living_units(TEAM_ENEMY).is_empty() + if condition_type == "all_players_defeated": + return get_living_units(TEAM_PLAYER).is_empty() + if condition_type == "any_unit_defeated": + return _any_unit_defeated(condition.get("unit_ids", [])) + if condition_type == "any_officer_defeated": + return _any_officer_defeated(condition.get("officer_ids", []), str(condition.get("team", ""))) + if condition_type == "unit_reaches_tile": + return _unit_reaches_tile(condition) + if condition_type == "turn_limit": + return _is_turn_limit_exceeded(condition) + if condition_type == "turn_reached": + return _is_turn_reached(condition) + return false + + +func _is_turn_limit_exceeded(condition: Dictionary) -> bool: + var limit := int(condition.get("turn", condition.get("limit", 0))) + if limit <= 0: + return false + return turn_number > limit and current_team == TEAM_PLAYER + + +func _is_turn_reached(condition: Dictionary) -> bool: + var target_turn := int(condition.get("turn", 0)) + if target_turn <= 0: + return false + var target_team := str(condition.get("team", "")) + if not target_team.is_empty() and current_team != target_team: + return false + return turn_number >= target_turn + + +func _unit_reaches_tile(condition: Dictionary) -> bool: + var target := _condition_cell(condition.get("pos", [])) + if not is_inside(target): + return false + var team := str(condition.get("team", "")) + var unit_ids = condition.get("unit_ids", []) + var officer_ids = condition.get("officer_ids", []) + for unit in units: + if not unit.get("deployed", true): + continue + if not unit.get("alive", false): + continue + if not team.is_empty() and unit.get("team", "") != team: + continue + if not _unit_matches_condition_ids(unit, unit_ids, officer_ids): + continue + if unit.get("pos", Vector2i(-9999, -9999)) == target: + return true + return false + + +func _unit_matches_condition_ids(unit: Dictionary, unit_ids, officer_ids) -> bool: + var has_unit_filter := typeof(unit_ids) == TYPE_ARRAY and not unit_ids.is_empty() + var has_officer_filter := typeof(officer_ids) == TYPE_ARRAY and not officer_ids.is_empty() + if not has_unit_filter and not has_officer_filter: + return true + + var unit_id := str(unit.get("id", "")) + if has_unit_filter: + for allowed_unit_id in unit_ids: + if unit_id == str(allowed_unit_id): + return true + + var officer_id := str(unit.get("officer_id", "")) + if has_officer_filter: + for allowed_officer_id in officer_ids: + if officer_id == str(allowed_officer_id): + return true + return false + + +func _condition_cell(pos_value) -> Vector2i: + if typeof(pos_value) != TYPE_ARRAY or pos_value.size() < 2: + return Vector2i(-9999, -9999) + return Vector2i(int(pos_value[0]), int(pos_value[1])) + + +func _condition_gate_open(condition: Dictionary) -> bool: + var after_event := str(condition.get("after_event", "")) + if after_event.is_empty(): + return true + return fired_event_ids.has(after_event) + + +func _all_conditions_met(conditions) -> bool: + if typeof(conditions) != TYPE_ARRAY or conditions.is_empty(): + return false + for condition in conditions: + if not _is_condition_met(condition): + return false + return true + + +func _collect_objective_cells(condition_group, result: Array[Vector2i]) -> void: + if typeof(condition_group) == TYPE_ARRAY: + for condition in condition_group: + _collect_objective_cells(condition, result) + return + if typeof(condition_group) != TYPE_DICTIONARY: + return + if not _condition_gate_open(condition_group): + return + + var condition_type := str(condition_group.get("type", "")) + if condition_type == "all" or condition_type == "any": + _collect_objective_cells(condition_group.get("conditions", []), result) + return + if condition_type != "unit_reaches_tile": + return + + var cell := _condition_cell(condition_group.get("pos", [])) + if is_inside(cell) and not result.has(cell): + result.append(cell) + + +func _find_turn_limit(condition_group) -> int: + if typeof(condition_group) == TYPE_ARRAY: + var best_limit := 0 + for condition in condition_group: + var limit := _find_turn_limit(condition) + if limit > 0 and (best_limit == 0 or limit < best_limit): + best_limit = limit + return best_limit + if typeof(condition_group) != TYPE_DICTIONARY: + return 0 + var condition_type := str(condition_group.get("type", "")) + if condition_type == "turn_limit": + return int(condition_group.get("turn", condition_group.get("limit", 0))) + if condition_type == "turn_reached": + var target_team := str(condition_group.get("team", "")) + if target_team == TEAM_PLAYER: + return max(0, int(condition_group.get("turn", 0)) - 1) + return int(condition_group.get("turn", 0)) + if condition_type == "all" or condition_type == "any": + return _find_turn_limit(condition_group.get("conditions", [])) + return 0 + + +func _any_unit_defeated(unit_ids) -> bool: + if typeof(unit_ids) != TYPE_ARRAY: + return false + for unit_id in unit_ids: + var unit := get_unit(str(unit_id)) + if not unit.is_empty() and unit.get("deployed", true) and not unit.get("alive", false): + return true + return false + + +func _any_officer_defeated(officer_ids, team := "") -> bool: + if typeof(officer_ids) != TYPE_ARRAY: + return false + for officer_id in officer_ids: + if _is_officer_defeated(str(officer_id), team): + return true + return false + + +func _is_officer_defeated(officer_id: String, team := "") -> bool: + if officer_id.is_empty(): + return false + for unit in units: + if not team.is_empty() and unit.get("team", "") != team: + continue + if not unit.get("deployed", true): + continue + if str(unit.get("officer_id", "")) == officer_id: + return not unit.get("alive", false) + return false + + +func _run_events(trigger_type: String, team := "", turn := -1, trigger_unit: Dictionary = {}) -> void: + for event_index in range(battle_events.size()): + if battle_status != STATUS_ACTIVE: + break + var event := battle_events[event_index] + var event_id := str(event.get("id", "")) + if event_id.is_empty(): + event_id = "%s_%d" % [trigger_type, event_index] + if bool(event.get("once", false)) and fired_event_ids.has(event_id): + continue + var when: Dictionary = event.get("when", {}) + if str(when.get("type", "")) != trigger_type: + continue + if when.has("team") and str(when["team"]) != team: + continue + if when.has("turn") and int(when["turn"]) != turn: + continue + if trigger_type == "unit_reaches_tile" and not _unit_reaches_event_tile(trigger_unit, when): + continue + if not _campaign_flags_match(when.get("campaign_flags", {})): + continue + _execute_event_actions(event.get("actions", [])) + if bool(event.get("once", false)) and not event_id.is_empty(): + fired_event_ids[event_id] = true + _check_battle_status() + + +func _unit_reaches_event_tile(unit: Dictionary, when: Dictionary) -> bool: + if unit.is_empty() or not unit.get("deployed", true) or not unit.get("alive", false): + return false + var target := _condition_cell(when.get("pos", [])) + if not is_inside(target): + return false + if unit.get("pos", Vector2i(-9999, -9999)) != target: + return false + return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) + + +func _campaign_flags_match(required_flags) -> bool: + if typeof(required_flags) != TYPE_DICTIONARY: + return true + for flag_name in required_flags.keys(): + var key := str(flag_name) + if key.is_empty(): + return false + if not campaign_flags.has(key): + return false + if campaign_flags[key] != required_flags[flag_name]: + return false + return true + + +func _execute_event_actions(actions: Array) -> void: + for action in actions: + if typeof(action) != TYPE_DICTIONARY: + continue + _execute_event_action(action) + + +func _execute_event_action(action: Dictionary) -> void: + var action_type := str(action.get("type", "")) + if action_type == "log": + _emit_log(str(action.get("text", ""))) + elif action_type == "dialogue": + var lines := _dialogue_lines_from_action(action) + if not lines.is_empty(): + dialogue_requested.emit(lines) + elif action_type == "set_objective": + _apply_objective_event(action) + elif action_type == "spawn_deployment": + _spawn_event_deployment(action.get("deployment", {})) + elif action_type == "spawn_deployments": + var deployments = action.get("deployments", []) + if typeof(deployments) != TYPE_ARRAY: + push_error("Skipping malformed deployment list in %s." % battle_id) + return + for deployment in deployments: + _spawn_event_deployment(deployment) + + +func _apply_objective_event(action: Dictionary) -> void: + if action.has("victory"): + objectives["victory"] = str(action["victory"]) + if action.has("defeat"): + objectives["defeat"] = str(action["defeat"]) + _emit_log("Objective updated.") + + +func _dialogue_lines_from_action(action: Dictionary) -> Array: + var result := [] + if action.has("lines") and typeof(action["lines"]) == TYPE_ARRAY: + for line in action["lines"]: + var normalized := _normalize_dialogue_line(line) + if not normalized.is_empty(): + result.append(normalized) + else: + var normalized := _normalize_dialogue_line(action) + if not normalized.is_empty(): + result.append(normalized) + return result + + +func _normalize_dialogue_line(line) -> Dictionary: + if typeof(line) == TYPE_STRING: + var text := str(line) + if text.is_empty(): + return {} + return {"speaker": "", "text": text} + if typeof(line) == TYPE_DICTIONARY: + var text := str(line.get("text", "")) + if text.is_empty(): + return {} + return { + "speaker": str(line.get("speaker", "")), + "text": text + } + return {} + + +func _spawn_event_deployment(deployment) -> bool: + if typeof(deployment) != TYPE_DICTIONARY: + push_error("Skipping malformed event deployment in %s." % battle_id) + return false + + var hydrated_unit := data_catalog.hydrate_deployment(deployment) + _apply_roster_overlay(hydrated_unit, roster_overrides_snapshot) + if not _is_deployment_available(hydrated_unit): + _emit_log("Reinforcement %s is not available." % hydrated_unit.get("name", hydrated_unit.get("id", "unit"))) + return false + var unit := _prepare_unit(hydrated_unit) + if unit.is_empty(): + return false + if not is_inside(unit["pos"]): + _emit_log("Reinforcement %s could not deploy outside the battlefield." % unit["name"]) + return false + if get_move_cost(unit["pos"], str(unit.get("move_type", "foot"))) >= 99: + _emit_log("Reinforcement %s could not deploy on impassable terrain." % unit["name"]) + return false + if not get_unit(unit["id"]).is_empty(): + _emit_log("Reinforcement %s already exists." % unit["name"]) + return false + if not get_unit_at(unit["pos"]).is_empty(): + _emit_log("Reinforcement %s could not deploy at %s." % [unit["name"], _format_cell(unit["pos"])]) + return false + + unit["acted"] = current_team == unit.get("team", "") + unit["moved"] = current_team == unit.get("team", "") + units.append(unit) + _emit_log("%s arrives at %s." % [unit["name"], _format_cell(unit["pos"])]) + return true + + +func _reset_team_actions(team: String) -> void: + for unit in units: + if unit.get("team", "") == team and unit.get("alive", false): + unit["acted"] = false + unit["moved"] = false + + +func _manhattan(a: Vector2i, b: Vector2i) -> int: + return absi(a.x - b.x) + absi(a.y - b.y) + + +func _format_cell(cell: Vector2i) -> String: + return "(%d, %d)" % [cell.x + 1, cell.y + 1] + + +func _emit_log(message: String) -> void: + log_added.emit(message) + + +func _notify_changed() -> void: + changed.emit() diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd new file mode 100644 index 0000000..45fd095 --- /dev/null +++ b/scripts/core/campaign_state.gd @@ -0,0 +1,592 @@ +extends RefCounted +class_name CampaignState + +const SAVE_PATH := "user://campaign_save.json" +const SAVE_VERSION := 3 + +var save_version := SAVE_VERSION +var campaign_id := "" +var campaign_title := "" +var start_scenario_id := "" +var scenario_order: Array[String] = [] +var scenario_paths := {} +var scenario_titles := {} +var initial_joined_officers: Array[String] = [] +var current_scenario_id := "" +var completed_scenarios: Array[String] = [] +var gold := 0 +var inventory := {} +var roster := {} +var flags := {} +var joined_officers: Array[String] = [] +var applied_post_battle_choices := {} +var pending_post_battle_choice_scenario_id := "" + + +func load_campaign(path: String) -> bool: + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) + if typeof(parsed) != TYPE_DICTIONARY: + push_error("Campaign file is not valid JSON: %s" % path) + return false + + campaign_id = str(parsed.get("id", "")) + campaign_title = str(parsed.get("title", "Campaign")) + start_scenario_id = str(parsed.get("start_scenario", "")) + scenario_order.clear() + scenario_paths.clear() + scenario_titles.clear() + initial_joined_officers.clear() + + for officer_id in parsed.get("initial_joined_officers", []): + var normalized := str(officer_id) + if normalized.is_empty() or initial_joined_officers.has(normalized): + continue + initial_joined_officers.append(normalized) + + for scenario in parsed.get("scenarios", []): + if typeof(scenario) != TYPE_DICTIONARY: + continue + var scenario_id := str(scenario.get("id", "")) + var scenario_path := str(scenario.get("path", "")) + if scenario_id.is_empty() or scenario_path.is_empty(): + continue + scenario_order.append(scenario_id) + scenario_paths[scenario_id] = scenario_path + scenario_titles[scenario_id] = str(scenario.get("title", scenario_id)) + + if start_scenario_id.is_empty() and not scenario_order.is_empty(): + start_scenario_id = scenario_order[0] + return not scenario_order.is_empty() + + +func load_or_start(scenario_id: String) -> void: + var loaded_save := load_game() + if not loaded_save: + start_new(scenario_id) + else: + if current_scenario_id.is_empty(): + current_scenario_id = scenario_id + var save_needs_update := false + if loaded_save: + save_needs_update = _clear_invalid_pending_post_battle_choice() or save_needs_update + _normalize_current_scenario() + if loaded_save: + save_needs_update = _backfill_applied_post_battle_choices() or save_needs_update + save_needs_update = _reconcile_completed_officer_transitions() or save_needs_update + if save_needs_update and not save_game(): + push_error("Unable to save reconciled campaign officer state.") + + +func start_new(scenario_id: String) -> void: + current_scenario_id = scenario_id + completed_scenarios.clear() + gold = 0 + inventory.clear() + roster.clear() + flags.clear() + joined_officers.clear() + applied_post_battle_choices.clear() + pending_post_battle_choice_scenario_id = "" + for officer_id in initial_joined_officers: + joined_officers.append(officer_id) + + +func reset_save(scenario_id: String) -> bool: + var save_file := ProjectSettings.globalize_path(SAVE_PATH) + if FileAccess.file_exists(SAVE_PATH): + var error := DirAccess.remove_absolute(save_file) + if error != OK: + push_error("Unable to delete campaign save: %s" % save_file) + return false + start_new(scenario_id) + return save_game() + + +func load_game() -> bool: + if not FileAccess.file_exists(SAVE_PATH): + return false + + var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH)) + if typeof(parsed) != TYPE_DICTIONARY: + return false + + save_version = int(parsed.get("save_version", 1)) + current_scenario_id = str(parsed.get("current_scenario_id", "")) + completed_scenarios.clear() + for scenario_id in parsed.get("completed_scenarios", []): + completed_scenarios.append(str(scenario_id)) + gold = int(parsed.get("gold", 0)) + inventory = _copy_dictionary(parsed.get("inventory", {})) + roster = _copy_dictionary(parsed.get("roster", {})) + flags = _copy_dictionary(parsed.get("flags", {})) + applied_post_battle_choices = _copy_dictionary(parsed.get("applied_post_battle_choices", {})) + pending_post_battle_choice_scenario_id = str(parsed.get("pending_post_battle_choice_scenario_id", "")) + joined_officers.clear() + var saved_joined = parsed.get("joined_officers", initial_joined_officers) + for officer_id in saved_joined: + var normalized := str(officer_id) + if normalized.is_empty() or joined_officers.has(normalized): + continue + joined_officers.append(normalized) + return true + + +func save_game() -> bool: + var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if file == null: + push_error("Unable to save campaign state: %s" % SAVE_PATH) + return false + file.store_string(JSON.stringify(to_dict())) + return true + + +func apply_battle_result(battle_state) -> Dictionary: + if battle_state.battle_status != "victory": + return { + "saved": false, + "rewards_applied": false, + "already_completed": false, + "gold": 0, + "items": [], + "joined_officers": [], + "left_officers": [], + "progression_events": [] + } + + var already_completed := completed_scenarios.has(battle_state.battle_id) + var rewards := battle_state.get_rewards() + var post_battle_choices := battle_state.get_post_battle_choices() + var previous_completed := completed_scenarios.duplicate() + var previous_gold := gold + var previous_inventory := inventory.duplicate(true) + var previous_roster := roster.duplicate(true) + var previous_current_scenario_id := current_scenario_id + var previous_joined_officers := joined_officers.duplicate() + var previous_pending_post_battle_choice_scenario_id := pending_post_battle_choice_scenario_id + var rewarded_items: Array = [] + var joined_reward_officers: Array = [] + var left_reward_officers: Array = [] + var reward_gold := 0 + var next_scenario_id := get_next_scenario_id(battle_state.battle_id) + + if not already_completed: + roster = battle_state.get_player_roster_snapshot() + inventory = battle_state.get_inventory_snapshot() + + if not already_completed: + completed_scenarios.append(battle_state.battle_id) + reward_gold = int(rewards.get("gold", 0)) + gold += reward_gold + + for item_id in rewards.get("items", []): + var key := str(item_id) + inventory[key] = int(inventory.get(key, 0)) + 1 + rewarded_items.append(key) + joined_reward_officers = _apply_join_officers(rewards.get("join_officers", [])) + left_reward_officers = _apply_leave_officers(rewards.get("leave_officers", [])) + pending_post_battle_choice_scenario_id = battle_state.battle_id if not post_battle_choices.is_empty() else "" + + if not next_scenario_id.is_empty(): + current_scenario_id = next_scenario_id + else: + current_scenario_id = battle_state.battle_id + + var saved := save_game() + if not saved: + completed_scenarios.clear() + for scenario_id in previous_completed: + completed_scenarios.append(str(scenario_id)) + gold = previous_gold + inventory = previous_inventory + roster = previous_roster + current_scenario_id = previous_current_scenario_id + pending_post_battle_choice_scenario_id = previous_pending_post_battle_choice_scenario_id + joined_officers.clear() + for officer_id in previous_joined_officers: + joined_officers.append(str(officer_id)) + reward_gold = 0 + rewarded_items.clear() + joined_reward_officers.clear() + left_reward_officers.clear() + + return { + "saved": saved, + "rewards_applied": not already_completed and saved, + "already_completed": already_completed, + "gold": reward_gold, + "items": rewarded_items, + "joined_officers": joined_reward_officers, + "left_officers": left_reward_officers, + "progression_events": battle_state.get_progression_events() if saved and not already_completed else [], + "next_scenario_id": next_scenario_id, + "next_scenario_title": get_scenario_title(next_scenario_id), + "scenario_id": battle_state.battle_id, + "campaign_complete": next_scenario_id.is_empty(), + "post_battle_choices": post_battle_choices if saved and not already_completed else [], + "choice_applied": not saved or already_completed or post_battle_choices.is_empty(), + "choice_label": "" + } + + +func get_roster_overrides() -> Dictionary: + return roster.duplicate(true) + + +func get_joined_officers_snapshot() -> Array: + return joined_officers.duplicate() + + +func get_inventory_snapshot() -> Dictionary: + return inventory.duplicate(true) + + +func try_buy_item(item_id: String, price: int) -> bool: + if item_id.is_empty() or price <= 0 or gold < price: + return false + var previous_count := int(inventory.get(item_id, 0)) + gold -= price + inventory[item_id] = previous_count + 1 + if save_game(): + return true + gold += price + if previous_count > 0: + inventory[item_id] = previous_count + else: + inventory.erase(item_id) + return false + + +func try_save_prebattle_loadout(roster_snapshot: Dictionary, inventory_snapshot: Dictionary) -> bool: + var previous_roster := roster.duplicate(true) + var previous_inventory := inventory.duplicate(true) + for roster_key in roster_snapshot.keys(): + var entry = roster_snapshot[roster_key] + if typeof(entry) == TYPE_DICTIONARY: + roster[str(roster_key)] = entry.duplicate(true) + inventory = inventory_snapshot.duplicate(true) + if save_game(): + return true + roster = previous_roster + inventory = previous_inventory + return false + + +func try_apply_post_battle_choice(choice: Dictionary, scenario_id: String = "") -> bool: + var previous_flags := flags.duplicate(true) + var previous_gold := gold + var previous_inventory := inventory.duplicate(true) + var previous_joined_officers := joined_officers.duplicate() + var previous_applied_post_battle_choices := applied_post_battle_choices.duplicate(true) + var previous_pending_post_battle_choice_scenario_id := pending_post_battle_choice_scenario_id + var previous_current_scenario_id := current_scenario_id + + var set_flags: Dictionary = choice.get("set_flags", {}) + for flag_name in set_flags.keys(): + flags[str(flag_name)] = set_flags[flag_name] + + var reward_gold := int(choice.get("gold", 0)) + if reward_gold > 0: + gold += reward_gold + for item_id in choice.get("items", []): + var key := str(item_id) + if key.is_empty(): + continue + inventory[key] = int(inventory.get(key, 0)) + 1 + _apply_join_officers(choice.get("join_officers", [])) + _apply_leave_officers(choice.get("leave_officers", [])) + var choice_id := str(choice.get("id", "")) + if not scenario_id.is_empty() and not choice_id.is_empty(): + applied_post_battle_choices[scenario_id] = choice_id + if pending_post_battle_choice_scenario_id == scenario_id: + pending_post_battle_choice_scenario_id = "" + var next_scenario_id := get_next_scenario_id(scenario_id) + current_scenario_id = next_scenario_id if not next_scenario_id.is_empty() else scenario_id + + if save_game(): + return true + flags = previous_flags + gold = previous_gold + inventory = previous_inventory + applied_post_battle_choices = previous_applied_post_battle_choices + pending_post_battle_choice_scenario_id = previous_pending_post_battle_choice_scenario_id + current_scenario_id = previous_current_scenario_id + joined_officers.clear() + for officer_id in previous_joined_officers: + joined_officers.append(str(officer_id)) + return false + + +func has_pending_post_battle_choice() -> bool: + if pending_post_battle_choice_scenario_id.is_empty(): + return false + if not scenario_paths.has(pending_post_battle_choice_scenario_id): + return false + if not completed_scenarios.has(pending_post_battle_choice_scenario_id): + return false + if applied_post_battle_choices.has(pending_post_battle_choice_scenario_id): + return false + return true + + +func get_pending_post_battle_choice_scenario_id() -> String: + return pending_post_battle_choice_scenario_id + + +func get_pending_post_battle_choice_summary() -> Dictionary: + if pending_post_battle_choice_scenario_id.is_empty(): + return {} + var scenario_data := _load_scenario_definition(pending_post_battle_choice_scenario_id) + if scenario_data.is_empty(): + return {} + var choices = scenario_data.get("post_battle_choices", []) + if typeof(choices) != TYPE_ARRAY or choices.is_empty(): + return {} + var next_scenario_id := get_next_scenario_id(pending_post_battle_choice_scenario_id) + return { + "saved": true, + "rewards_applied": false, + "already_completed": false, + "pending_choice": true, + "gold": 0, + "items": [], + "joined_officers": [], + "left_officers": [], + "progression_events": [], + "next_scenario_id": next_scenario_id, + "next_scenario_title": get_scenario_title(next_scenario_id), + "scenario_id": pending_post_battle_choice_scenario_id, + "campaign_complete": next_scenario_id.is_empty(), + "post_battle_choices": choices, + "choice_applied": false, + "choice_label": "" + } + + +func _clear_invalid_pending_post_battle_choice() -> bool: + if pending_post_battle_choice_scenario_id.is_empty(): + return false + if not scenario_paths.has(pending_post_battle_choice_scenario_id): + pending_post_battle_choice_scenario_id = "" + return true + if not completed_scenarios.has(pending_post_battle_choice_scenario_id): + pending_post_battle_choice_scenario_id = "" + return true + if applied_post_battle_choices.has(pending_post_battle_choice_scenario_id): + pending_post_battle_choice_scenario_id = "" + return true + if get_pending_post_battle_choice_summary().is_empty(): + pending_post_battle_choice_scenario_id = "" + return true + return false + + +func _apply_join_officers(source) -> Array: + var result := [] + if typeof(source) != TYPE_ARRAY: + return result + for officer_id in source: + var key := str(officer_id) + if key.is_empty() or joined_officers.has(key): + continue + joined_officers.append(key) + result.append(key) + return result + + +func _apply_leave_officers(source) -> Array: + var result := [] + if typeof(source) != TYPE_ARRAY: + return result + for officer_id in source: + var key := str(officer_id) + if key.is_empty() or not joined_officers.has(key): + continue + joined_officers.erase(key) + result.append(key) + return result + + +func _reconcile_completed_officer_transitions() -> bool: + var changed := false + for scenario_id in scenario_order: + if not completed_scenarios.has(scenario_id): + continue + var scenario_data := _load_scenario_definition(scenario_id) + if scenario_data.is_empty(): + continue + + var rewards = scenario_data.get("rewards", {}) + if typeof(rewards) == TYPE_DICTIONARY: + changed = _apply_officer_transitions_from_block(rewards) or changed + + var applied_choice := _get_reconciled_post_battle_choice(scenario_id, scenario_data) + if not applied_choice.is_empty(): + changed = _apply_officer_transitions_from_block(applied_choice) or changed + return changed + + +func _backfill_applied_post_battle_choices() -> bool: + var changed := false + for scenario_id in scenario_order: + if not completed_scenarios.has(scenario_id) or applied_post_battle_choices.has(scenario_id): + continue + if scenario_id == pending_post_battle_choice_scenario_id: + continue + var scenario_data := _load_scenario_definition(scenario_id) + if scenario_data.is_empty(): + continue + var choices = scenario_data.get("post_battle_choices", []) + if typeof(choices) != TYPE_ARRAY: + continue + var matching_choices := _matching_choices_from_flags(choices) + if matching_choices.size() == 1: + var choice_id := str(matching_choices[0].get("id", "")) + if not choice_id.is_empty(): + applied_post_battle_choices[scenario_id] = choice_id + changed = true + return changed + + +func _get_reconciled_post_battle_choice(scenario_id: String, scenario_data: Dictionary) -> Dictionary: + if scenario_id == pending_post_battle_choice_scenario_id: + return {} + var choices = scenario_data.get("post_battle_choices", []) + if typeof(choices) != TYPE_ARRAY: + return {} + var applied_choice_id := str(applied_post_battle_choices.get(scenario_id, "")) + if not applied_choice_id.is_empty(): + for choice in choices: + if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == applied_choice_id: + return choice + return {} + var matching_choices := _matching_choices_from_flags(choices) + if matching_choices.size() == 1: + return matching_choices[0] + return {} + + +func _load_scenario_definition(scenario_id: String) -> Dictionary: + var path := get_scenario_path(scenario_id) + if path.is_empty(): + return {} + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) + if typeof(parsed) != TYPE_DICTIONARY: + push_error("Scenario file is not valid JSON: %s" % path) + return {} + return parsed + + +func _matching_choices_from_flags(choices: Array) -> Array: + var result := [] + for choice in choices: + if typeof(choice) != TYPE_DICTIONARY or not _choice_flags_match(choice): + continue + result.append(choice) + return result + + +func _choice_flags_match(choice: Dictionary) -> bool: + var set_flags = choice.get("set_flags", {}) + if typeof(set_flags) != TYPE_DICTIONARY or set_flags.is_empty(): + return false + for flag_name in set_flags.keys(): + var key := str(flag_name) + if not flags.has(key) or flags[key] != set_flags[flag_name]: + return false + return true + + +func _apply_officer_transitions_from_block(block: Dictionary) -> bool: + var joined := _apply_join_officers(block.get("join_officers", [])) + var left := _apply_leave_officers(block.get("leave_officers", [])) + return not joined.is_empty() or not left.is_empty() + + +func get_flags_snapshot() -> Dictionary: + return flags.duplicate(true) + + +func is_scenario_completed(scenario_id: String) -> bool: + return completed_scenarios.has(scenario_id) + + +func get_start_scenario_id() -> String: + return start_scenario_id + + +func get_scenario_path(scenario_id: String) -> String: + return str(scenario_paths.get(scenario_id, "")) + + +func get_current_scenario_path() -> String: + if current_scenario_id.is_empty(): + current_scenario_id = get_start_scenario_id() + return get_scenario_path(current_scenario_id) + + +func get_scenario_title(scenario_id: String) -> String: + if scenario_id.is_empty(): + return "" + return str(scenario_titles.get(scenario_id, scenario_id)) + + +func get_next_scenario_id(scenario_id: String) -> String: + var index := scenario_order.find(scenario_id) + if index == -1 or index + 1 >= scenario_order.size(): + return "" + return scenario_order[index + 1] + + +func is_campaign_complete() -> bool: + if has_pending_post_battle_choice(): + return false + if scenario_order.is_empty(): + return false + for scenario_id in scenario_order: + if not completed_scenarios.has(scenario_id): + return false + return true + + +func get_progress_text() -> String: + var current_title := get_scenario_title(current_scenario_id) + if current_title.is_empty(): + current_title = get_scenario_title(get_start_scenario_id()) + var complete_text := "complete" if is_campaign_complete() else "in progress" + return "%s | %s | Gold %d | %d/%d complete" % [ + campaign_title, + complete_text, + gold, + completed_scenarios.size(), + scenario_order.size() + ] + + +func _normalize_current_scenario() -> void: + if current_scenario_id.is_empty(): + current_scenario_id = get_start_scenario_id() + if completed_scenarios.has(current_scenario_id): + var next_id := get_next_scenario_id(current_scenario_id) + if not next_id.is_empty(): + current_scenario_id = next_id + + +func to_dict() -> Dictionary: + return { + "save_version": SAVE_VERSION, + "current_scenario_id": current_scenario_id, + "completed_scenarios": completed_scenarios.duplicate(), + "gold": gold, + "inventory": inventory.duplicate(true), + "roster": roster.duplicate(true), + "flags": flags.duplicate(true), + "joined_officers": joined_officers.duplicate(), + "applied_post_battle_choices": applied_post_battle_choices.duplicate(true), + "pending_post_battle_choice_scenario_id": pending_post_battle_choice_scenario_id + } + + +func _copy_dictionary(value) -> Dictionary: + if typeof(value) == TYPE_DICTIONARY: + return value.duplicate(true) + return {} diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd new file mode 100644 index 0000000..6fe8784 --- /dev/null +++ b/scripts/core/data_catalog.gd @@ -0,0 +1,234 @@ +extends RefCounted +class_name DataCatalog + +const OFFICERS_PATH := "res://data/defs/officers.json" +const CLASSES_PATH := "res://data/defs/classes.json" +const TERRAIN_PATH := "res://data/defs/terrain.json" +const ITEMS_PATH := "res://data/defs/items.json" +const SKILLS_PATH := "res://data/defs/skills.json" + +var officers: Dictionary = {} +var classes: Dictionary = {} +var terrain: Dictionary = {} +var items: Dictionary = {} +var skills: Dictionary = {} + + +func load_defaults() -> bool: + officers = _load_json_object(OFFICERS_PATH) + classes = _load_json_object(CLASSES_PATH) + terrain = _load_json_object(TERRAIN_PATH) + items = _load_json_object(ITEMS_PATH) + skills = _load_json_object(SKILLS_PATH) + return not officers.is_empty() and not classes.is_empty() and not terrain.is_empty() + + +func hydrate_deployment(deployment: Dictionary) -> Dictionary: + var officer := _get_dict(officers, deployment.get("officer_id", "")) + var class_id := str(deployment.get("class_id", officer.get("class_id", "infantry"))) + var class_def := _get_dict(classes, class_id) + var equipment := _merged_equipment(officer.get("equipment", {}), deployment.get("equipment", {})) + var range_pair := _resolve_attack_range(class_def, equipment) + + var stats := {} + _overlay_stats(stats, officer.get("base", {})) + _overlay_stats(stats, deployment.get("base", {})) + _add_stats(stats, class_def.get("base_bonus", {})) + _add_equipment_bonuses(stats, equipment) + + var unit := {} + unit["id"] = str(deployment.get("unit_id", deployment.get("id", deployment.get("officer_id", "")))) + unit["officer_id"] = str(deployment.get("officer_id", "")) + unit["name"] = str(deployment.get("name", officer.get("name", unit["id"]))) + unit["class_id"] = class_id + unit["class"] = str(class_def.get("name", class_id.capitalize())) + unit["team"] = str(deployment.get("team", "enemy")) + unit["pos"] = deployment.get("pos", [0, 0]) + unit["requires_joined"] = bool(deployment.get("requires_joined", false)) + unit["controllable"] = bool(deployment.get("controllable", true)) + unit["persist_progression"] = bool(deployment.get("persist_progression", true)) + unit["ai_target_priority"] = int(deployment.get("ai_target_priority", 0)) + unit["level"] = int(deployment.get("level", officer.get("level", 1))) + unit["exp"] = int(deployment.get("exp", officer.get("exp", 0))) + unit["max_hp"] = int(stats.get("hp", stats.get("max_hp", 1))) + unit["hp"] = int(deployment.get("hp", unit["max_hp"])) + unit["max_mp"] = int(stats.get("mp", 0)) + unit["mp"] = int(deployment.get("mp", unit["max_mp"])) + unit["atk"] = int(stats.get("atk", 1)) + unit["def"] = int(stats.get("def", 0)) + unit["int"] = int(stats.get("int", 0)) + unit["agi"] = int(stats.get("agi", 0)) + unit["move"] = int(deployment.get("move", class_def.get("move", 3))) + unit["move_type"] = str(deployment.get("move_type", class_def.get("move_type", "foot"))) + if deployment.has("range"): + range_pair = _range_pair_from_value(deployment["range"]) + unit["min_range"] = int(deployment.get("min_range", range_pair["min"])) + unit["range"] = int(range_pair["max"]) + unit["growth"] = class_def.get("growth", {}).duplicate(true) + unit["growth_bonus"] = officer.get("growth_bonus", {}).duplicate(true) + if deployment.has("growth_bonus") and typeof(deployment["growth_bonus"]) == TYPE_DICTIONARY: + for stat in deployment["growth_bonus"].keys(): + unit["growth_bonus"][stat] = deployment["growth_bonus"][stat] + unit["equipment"] = equipment + unit["skills"] = _merged_skill_list(class_def.get("skills", []), officer.get("skills", []), deployment.get("skills", [])) + return unit + + +func get_runtime_terrain_defs(default_defs: Dictionary) -> Dictionary: + var result := default_defs.duplicate(true) + for key in terrain.keys(): + var source := _get_dict(terrain, key) + var fallback: Dictionary = result.get(key, {}) + var runtime := fallback.duplicate(true) + runtime["id"] = str(source.get("id", runtime.get("id", key))) + runtime["name"] = str(source.get("name", runtime.get("name", key))) + runtime["defense"] = int(source.get("defense", runtime.get("defense", 0))) + runtime["avoid"] = int(source.get("avoid", runtime.get("avoid", 0))) + runtime["move_cost"] = source.get("move_cost", runtime.get("move_cost", 1)) + runtime["color"] = _parse_color(source.get("color", runtime.get("color", Color.WHITE)), runtime.get("color", Color.WHITE)) + result[key] = runtime + return result + + +func get_skill(skill_id: String) -> Dictionary: + return _get_dict(skills, skill_id).duplicate(true) + + +func get_item(item_id: String) -> Dictionary: + return _get_dict(items, item_id).duplicate(true) + + +func get_item_ids() -> Array: + var result := [] + for item_id in items.keys(): + result.append(str(item_id)) + result.sort() + return result + + +func get_class_def(class_id: String) -> Dictionary: + return _get_dict(classes, class_id).duplicate(true) + + +func _load_json_object(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + push_error("Missing data file: %s" % path) + return {} + + var text := FileAccess.get_file_as_string(path) + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + push_error("Data file is not a JSON object: %s" % path) + return {} + return parsed + + +func _get_dict(source: Dictionary, key) -> Dictionary: + if key == null: + return {} + var value = source.get(str(key), {}) + if typeof(value) == TYPE_DICTIONARY: + return value + return {} + + +func _merged_equipment(base_equipment, override_equipment) -> Dictionary: + var result := {} + if typeof(base_equipment) == TYPE_DICTIONARY: + result = base_equipment.duplicate(true) + if typeof(override_equipment) == TYPE_DICTIONARY: + for slot in override_equipment.keys(): + result[slot] = override_equipment[slot] + return result + + +func _overlay_stats(target: Dictionary, source) -> void: + if typeof(source) != TYPE_DICTIONARY: + return + for key in source.keys(): + target[key] = int(source[key]) + + +func _add_stats(target: Dictionary, source) -> void: + if typeof(source) != TYPE_DICTIONARY: + return + for key in source.keys(): + target[key] = int(target.get(key, 0)) + int(source[key]) + + +func _add_equipment_bonuses(target: Dictionary, equipment: Dictionary) -> void: + for slot in equipment.keys(): + var item_id = equipment[slot] + if item_id == null: + continue + var item := _get_dict(items, item_id) + _add_stats(target, item.get("bonuses", {})) + + +func _merged_skill_list(class_skills, officer_skills, deployment_skills) -> Array: + var result := [] + _append_unique_skills(result, class_skills) + _append_unique_skills(result, officer_skills) + _append_unique_skills(result, deployment_skills) + return result + + +func _append_unique_skills(target: Array, source) -> void: + if typeof(source) != TYPE_ARRAY: + return + for skill_id in source: + var normalized := str(skill_id) + if normalized.is_empty() or target.has(normalized): + continue + target.append(normalized) + + +func _resolve_attack_range(class_def: Dictionary, equipment: Dictionary) -> Dictionary: + var attack_range := _range_pair_from_value(class_def.get("attack_range", 1)) + for slot in equipment.keys(): + var item_id = equipment[slot] + if item_id == null: + continue + var item := _get_dict(items, item_id) + if item.has("range"): + var item_range := _range_pair_from_value(item["range"]) + attack_range["min"] = min(int(attack_range["min"]), int(item_range["min"])) + attack_range["max"] = max(int(attack_range["max"]), int(item_range["max"])) + return attack_range + + +func _range_pair_from_value(value) -> Dictionary: + if typeof(value) == TYPE_ARRAY: + if value.size() >= 2: + return _normalized_range_pair(int(value[0]), int(value[1])) + if value.size() == 1: + return _normalized_range_pair(int(value[0]), int(value[0])) + return _normalized_range_pair(1, 1) + if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: + return _normalized_range_pair(1, 1) + var scalar := max(1, int(value)) + return _normalized_range_pair(scalar, scalar) + + +func _normalized_range_pair(min_range: int, max_range: int) -> Dictionary: + var safe_min := max(1, min_range) + var safe_max := max(safe_min, max_range) + return { + "min": safe_min, + "max": safe_max + } + + +func _parse_color(value, fallback: Color) -> Color: + if typeof(value) == TYPE_COLOR: + return value + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + var alpha := 1.0 + if value.size() >= 4: + alpha = float(value[3]) + return Color(float(value[0]), float(value[1]), float(value[2]), alpha) + + var text := str(value) + if Color.html_is_valid(text): + return Color.html(text) + return fallback diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd new file mode 100644 index 0000000..425f395 --- /dev/null +++ b/scripts/scenes/battle_scene.gd @@ -0,0 +1,2360 @@ +extends Node2D + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const CampaignStateScript := preload("res://scripts/core/campaign_state.gd") + +const CAMPAIGN_PATH := "res://data/campaign/campaign.json" +const TILE_SIZE := 64 +const BOARD_OFFSET := Vector2(48, 104) +const PLAYER_COLOR := Color(0.20, 0.42, 0.82) +const ENEMY_COLOR := Color(0.72, 0.18, 0.16) +const GRID_COLOR := Color(0.05, 0.06, 0.07, 0.45) +const SKILL_OVERLAY_COLOR := Color(0.56, 0.28, 0.95, 0.28) +const ITEM_OVERLAY_COLOR := Color(0.24, 0.74, 0.34, 0.30) +const FORMATION_OVERLAY_COLOR := Color(0.95, 0.76, 0.25, 0.34) +const OBJECTIVE_OVERLAY_COLOR := Color(1.0, 0.78, 0.18, 0.30) + +var state: BattleState = BattleStateScript.new() +var campaign_state: CampaignState = CampaignStateScript.new() +var hover_cell := Vector2i(-1, -1) +var move_cells: Array[Vector2i] = [] +var attack_cells: Array[Vector2i] = [] +var skill_cells: Array[Vector2i] = [] +var item_cells: Array[Vector2i] = [] +var selected_skill_id := "" +var selected_item_id := "" +var active_scenario_id := "" +var battle_started := false +var battle_result_applied := false +var battle_result_summary := {} +var post_battle_dialogue_started := false +var campaign_complete_screen := false + +var status_label: Label +var objective_label: Label +var campaign_status_label: Label +var selected_label: Label +var cell_info_label: Label +var forecast_label: Label +var inventory_label: Label +var briefing_panel: PanelContainer +var briefing_label: Label +var shop_button: Button +var shop_menu: VBoxContainer +var shop_list: VBoxContainer +var shop_status_label: Label +var armory_button: Button +var armory_menu: VBoxContainer +var armory_list: VBoxContainer +var armory_status_label: Label +var armory_unit_id := "" +var roster_button: Button +var roster_menu: VBoxContainer +var roster_list: VBoxContainer +var roster_status_label: Label +var formation_button: Button +var formation_menu: VBoxContainer +var formation_list: VBoxContainer +var formation_status_label: Label +var formation_unit_id := "" +var dialogue_panel: PanelContainer +var dialogue_speaker_label: Label +var dialogue_text_label: Label +var dialogue_continue_button: Button +var result_panel: PanelContainer +var result_label: Label +var result_choice_list: VBoxContainer +var result_restart_button: Button +var next_battle_button: Button +var end_turn_button: Button +var wait_button: Button +var tactic_button: Button +var tactic_menu: VBoxContainer +var tactic_list: VBoxContainer +var item_button: Button +var item_menu: VBoxContainer +var item_list: VBoxContainer +var equip_button: Button +var equip_menu: VBoxContainer +var equip_list: VBoxContainer +var restart_button: Button +var new_campaign_button: Button +var log_box: RichTextLabel +var dialogue_queue: Array = [] +var active_dialogue_lines: Array = [] +var active_dialogue_index := 0 + + +func _ready() -> void: + _create_hud() + state.changed.connect(_on_state_changed) + state.log_added.connect(_on_log_added) + state.dialogue_requested.connect(_on_dialogue_requested) + campaign_state.load_campaign(CAMPAIGN_PATH) + campaign_state.load_or_start(campaign_state.get_start_scenario_id()) + if campaign_state.has_pending_post_battle_choice(): + _load_pending_post_battle_choice() + elif campaign_state.is_campaign_complete(): + _show_campaign_complete() + else: + _load_current_battle() + _show_briefing() + _update_hud() + + +func _unhandled_input(event: InputEvent) -> void: + if _is_dialogue_visible(): + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + _advance_dialogue() + elif event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE: + _advance_dialogue() + return + + if event is InputEventMouseMotion: + var next_hover := _cell_from_screen(event.position) + if next_hover != hover_cell: + hover_cell = next_hover + _update_hud() + queue_redraw() + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + _handle_board_click(event.position) + elif event is InputEventKey and event.pressed and not event.echo: + _handle_key(event) + + +func _draw() -> void: + _draw_map() + _draw_overlays() + _draw_units() + + +func _create_hud() -> void: + var layer := CanvasLayer.new() + add_child(layer) + + var root := Control.new() + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + layer.add_child(root) + + var top_bar := PanelContainer.new() + top_bar.position = Vector2(24, 16) + top_bar.size = Vector2(1120, 64) + root.add_child(top_bar) + + var top_row := HBoxContainer.new() + top_row.add_theme_constant_override("separation", 12) + top_bar.add_child(top_row) + + status_label = Label.new() + status_label.custom_minimum_size = Vector2(180, 40) + top_row.add_child(status_label) + + objective_label = Label.new() + objective_label.custom_minimum_size = Vector2(300, 40) + top_row.add_child(objective_label) + + campaign_status_label = Label.new() + campaign_status_label.custom_minimum_size = Vector2(300, 40) + top_row.add_child(campaign_status_label) + + restart_button = Button.new() + restart_button.text = "Restart" + restart_button.pressed.connect(_on_restart_pressed) + top_row.add_child(restart_button) + + new_campaign_button = Button.new() + new_campaign_button.text = "New Campaign" + new_campaign_button.pressed.connect(_on_new_campaign_pressed) + top_row.add_child(new_campaign_button) + + var side_panel := PanelContainer.new() + side_panel.position = Vector2(760, 104) + side_panel.size = Vector2(480, 580) + root.add_child(side_panel) + + var side_column := VBoxContainer.new() + side_column.add_theme_constant_override("separation", 10) + side_panel.add_child(side_column) + + selected_label = Label.new() + selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + selected_label.custom_minimum_size = Vector2(420, 72) + side_column.add_child(selected_label) + + cell_info_label = Label.new() + cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + cell_info_label.custom_minimum_size = Vector2(420, 90) + side_column.add_child(cell_info_label) + + forecast_label = Label.new() + forecast_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + forecast_label.custom_minimum_size = Vector2(420, 84) + side_column.add_child(forecast_label) + + inventory_label = Label.new() + inventory_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + inventory_label.custom_minimum_size = Vector2(420, 40) + side_column.add_child(inventory_label) + + var button_row := HBoxContainer.new() + button_row.add_theme_constant_override("separation", 8) + side_column.add_child(button_row) + + wait_button = Button.new() + wait_button.text = "Wait" + wait_button.pressed.connect(_on_wait_pressed) + button_row.add_child(wait_button) + + tactic_button = Button.new() + tactic_button.text = "Tactic" + tactic_button.pressed.connect(_on_tactic_pressed) + button_row.add_child(tactic_button) + + item_button = Button.new() + item_button.text = "Item" + item_button.pressed.connect(_on_item_pressed) + button_row.add_child(item_button) + + equip_button = Button.new() + equip_button.text = "Equip" + equip_button.pressed.connect(_on_equip_pressed) + button_row.add_child(equip_button) + + end_turn_button = Button.new() + end_turn_button.text = "End Turn" + end_turn_button.pressed.connect(_on_end_turn_pressed) + button_row.add_child(end_turn_button) + + tactic_menu = VBoxContainer.new() + tactic_menu.visible = false + tactic_menu.add_theme_constant_override("separation", 6) + side_column.add_child(tactic_menu) + + var tactic_title := Label.new() + tactic_title.text = "Tactics" + tactic_menu.add_child(tactic_title) + + tactic_list = VBoxContainer.new() + tactic_list.add_theme_constant_override("separation", 4) + tactic_menu.add_child(tactic_list) + + item_menu = VBoxContainer.new() + item_menu.visible = false + item_menu.add_theme_constant_override("separation", 6) + side_column.add_child(item_menu) + + var item_title := Label.new() + item_title.text = "Items" + item_menu.add_child(item_title) + + item_list = VBoxContainer.new() + item_list.add_theme_constant_override("separation", 4) + item_menu.add_child(item_list) + + equip_menu = VBoxContainer.new() + equip_menu.visible = false + equip_menu.add_theme_constant_override("separation", 6) + side_column.add_child(equip_menu) + + var equip_title := Label.new() + equip_title.text = "Equipment" + equip_menu.add_child(equip_title) + + equip_list = VBoxContainer.new() + equip_list.add_theme_constant_override("separation", 4) + equip_menu.add_child(equip_list) + + log_box = RichTextLabel.new() + log_box.custom_minimum_size = Vector2(420, 170) + log_box.fit_content = false + side_column.add_child(log_box) + + briefing_panel = PanelContainer.new() + briefing_panel.visible = false + briefing_panel.position = Vector2(284, 88) + briefing_panel.size = Vector2(640, 500) + root.add_child(briefing_panel) + + var briefing_column := VBoxContainer.new() + briefing_column.add_theme_constant_override("separation", 10) + briefing_panel.add_child(briefing_column) + + briefing_label = Label.new() + briefing_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + briefing_label.custom_minimum_size = Vector2(600, 170) + briefing_column.add_child(briefing_label) + + var prep_button_row := HBoxContainer.new() + prep_button_row.add_theme_constant_override("separation", 8) + briefing_column.add_child(prep_button_row) + + shop_button = Button.new() + shop_button.text = "Shop" + shop_button.pressed.connect(_on_shop_pressed) + prep_button_row.add_child(shop_button) + + armory_button = Button.new() + armory_button.text = "Armory" + armory_button.pressed.connect(_on_armory_pressed) + prep_button_row.add_child(armory_button) + + roster_button = Button.new() + roster_button.text = "Roster" + roster_button.pressed.connect(_on_roster_pressed) + prep_button_row.add_child(roster_button) + + formation_button = Button.new() + formation_button.text = "Formation" + formation_button.pressed.connect(_on_formation_pressed) + prep_button_row.add_child(formation_button) + + shop_menu = VBoxContainer.new() + shop_menu.visible = false + shop_menu.add_theme_constant_override("separation", 6) + briefing_column.add_child(shop_menu) + + shop_status_label = Label.new() + shop_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + shop_status_label.custom_minimum_size = Vector2(600, 22) + shop_menu.add_child(shop_status_label) + + var shop_scroll := ScrollContainer.new() + shop_scroll.custom_minimum_size = Vector2(600, 190) + shop_menu.add_child(shop_scroll) + + shop_list = VBoxContainer.new() + shop_list.custom_minimum_size = Vector2(580, 0) + shop_list.add_theme_constant_override("separation", 4) + shop_scroll.add_child(shop_list) + + armory_menu = VBoxContainer.new() + armory_menu.visible = false + armory_menu.add_theme_constant_override("separation", 6) + briefing_column.add_child(armory_menu) + + armory_status_label = Label.new() + armory_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + armory_status_label.custom_minimum_size = Vector2(600, 22) + armory_menu.add_child(armory_status_label) + + var armory_scroll := ScrollContainer.new() + armory_scroll.custom_minimum_size = Vector2(600, 190) + armory_menu.add_child(armory_scroll) + + armory_list = VBoxContainer.new() + armory_list.custom_minimum_size = Vector2(580, 0) + armory_list.add_theme_constant_override("separation", 4) + armory_scroll.add_child(armory_list) + + roster_menu = VBoxContainer.new() + roster_menu.visible = false + roster_menu.add_theme_constant_override("separation", 6) + briefing_column.add_child(roster_menu) + + roster_status_label = Label.new() + roster_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + roster_status_label.custom_minimum_size = Vector2(600, 22) + roster_menu.add_child(roster_status_label) + + var roster_scroll := ScrollContainer.new() + roster_scroll.custom_minimum_size = Vector2(600, 190) + roster_menu.add_child(roster_scroll) + + roster_list = VBoxContainer.new() + roster_list.custom_minimum_size = Vector2(580, 0) + roster_list.add_theme_constant_override("separation", 4) + roster_scroll.add_child(roster_list) + + formation_menu = VBoxContainer.new() + formation_menu.visible = false + formation_menu.add_theme_constant_override("separation", 6) + briefing_column.add_child(formation_menu) + + formation_status_label = Label.new() + formation_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + formation_status_label.custom_minimum_size = Vector2(600, 22) + formation_menu.add_child(formation_status_label) + + var formation_scroll := ScrollContainer.new() + formation_scroll.custom_minimum_size = Vector2(600, 190) + formation_menu.add_child(formation_scroll) + + formation_list = VBoxContainer.new() + formation_list.custom_minimum_size = Vector2(580, 0) + formation_list.add_theme_constant_override("separation", 4) + formation_scroll.add_child(formation_list) + + var begin_button := Button.new() + begin_button.text = "Begin Battle" + begin_button.pressed.connect(_on_begin_battle_pressed) + briefing_column.add_child(begin_button) + + dialogue_panel = PanelContainer.new() + dialogue_panel.visible = false + dialogue_panel.position = Vector2(128, 474) + dialogue_panel.size = Vector2(760, 140) + root.add_child(dialogue_panel) + + var dialogue_column := VBoxContainer.new() + dialogue_column.add_theme_constant_override("separation", 8) + dialogue_panel.add_child(dialogue_column) + + dialogue_speaker_label = Label.new() + dialogue_speaker_label.custom_minimum_size = Vector2(720, 22) + dialogue_column.add_child(dialogue_speaker_label) + + dialogue_text_label = Label.new() + dialogue_text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + dialogue_text_label.custom_minimum_size = Vector2(720, 56) + dialogue_column.add_child(dialogue_text_label) + + dialogue_continue_button = Button.new() + dialogue_continue_button.text = "Next" + dialogue_continue_button.pressed.connect(_advance_dialogue) + dialogue_column.add_child(dialogue_continue_button) + + result_panel = PanelContainer.new() + result_panel.visible = false + result_panel.position = Vector2(300, 182) + result_panel.size = Vector2(500, 340) + root.add_child(result_panel) + + var result_column := VBoxContainer.new() + result_column.alignment = BoxContainer.ALIGNMENT_CENTER + result_panel.add_child(result_column) + + result_label = Label.new() + result_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + result_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + result_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + result_label.custom_minimum_size = Vector2(460, 176) + result_column.add_child(result_label) + + result_choice_list = VBoxContainer.new() + result_choice_list.add_theme_constant_override("separation", 6) + result_column.add_child(result_choice_list) + + result_restart_button = Button.new() + result_restart_button.text = "Restart Battle" + result_restart_button.pressed.connect(_on_restart_pressed) + result_column.add_child(result_restart_button) + + next_battle_button = Button.new() + next_battle_button.text = "Next Battle" + next_battle_button.pressed.connect(_on_next_battle_pressed) + result_column.add_child(next_battle_button) + + +func _handle_board_click(screen_position: Vector2) -> void: + if formation_menu != null and formation_menu.visible: + _handle_formation_board_click(screen_position) + return + if _is_input_locked(): + return + + var cell := _cell_from_screen(screen_position) + if tactic_menu != null and tactic_menu.visible: + _hide_tactic_menu() + _update_hud() + queue_redraw() + if item_menu != null and item_menu.visible: + _hide_item_menu() + _update_hud() + queue_redraw() + if equip_menu != null and equip_menu.visible: + _hide_equip_menu() + _update_hud() + queue_redraw() + if not state.is_inside(cell) or not state.can_player_act(): + return + + var clicked_unit := state.get_unit_at(cell) + var selected_unit := state.get_selected_unit() + + if selected_unit.is_empty(): + if _can_select(clicked_unit): + selected_skill_id = "" + selected_item_id = "" + state.select_unit(clicked_unit["id"]) + return + + if not selected_item_id.is_empty(): + if state.try_use_selected_item_on_cell(selected_item_id, cell): + selected_item_id = "" + return + if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit): + selected_item_id = "" + state.select_unit(clicked_unit["id"]) + return + + if not selected_skill_id.is_empty(): + if state.try_cast_selected_skill(selected_skill_id, cell): + selected_skill_id = "" + return + if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit): + selected_skill_id = "" + selected_item_id = "" + state.select_unit(clicked_unit["id"]) + return + + if not clicked_unit.is_empty() and clicked_unit.get("team", "") != selected_unit.get("team", ""): + if state.try_attack_selected(clicked_unit["id"]): + selected_skill_id = "" + selected_item_id = "" + return + + if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", ""): + if _can_select(clicked_unit): + selected_skill_id = "" + selected_item_id = "" + state.select_unit(clicked_unit["id"]) + return + + if state.try_move_selected(cell): + return + + if _can_select(clicked_unit): + selected_skill_id = "" + selected_item_id = "" + state.select_unit(clicked_unit["id"]) + + +func _handle_key(event: InputEventKey) -> void: + if event.keycode == KEY_ENTER and briefing_panel != null and briefing_panel.visible: + if _is_prep_menu_visible(): + return + _on_begin_battle_pressed() + elif event.keycode == KEY_ESCAPE: + if shop_menu != null and shop_menu.visible: + _hide_shop_menu() + elif armory_menu != null and armory_menu.visible: + _hide_armory_menu() + elif roster_menu != null and roster_menu.visible: + _hide_roster_menu() + elif formation_menu != null and formation_menu.visible: + _hide_formation_menu() + elif tactic_menu != null and tactic_menu.visible: + _hide_tactic_menu() + elif item_menu != null and item_menu.visible: + _hide_item_menu() + elif equip_menu != null and equip_menu.visible: + _hide_equip_menu() + elif selected_skill_id.is_empty() and selected_item_id.is_empty(): + state.clear_selection() + else: + selected_skill_id = "" + selected_item_id = "" + _refresh_ranges() + _update_hud() + queue_redraw() + elif event.keycode == KEY_SPACE and not _is_input_locked(): + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + state.end_player_turn() + elif event.keycode == KEY_R and not _is_prep_menu_visible(): + _on_restart_pressed() + elif event.keycode == KEY_T and not _is_input_locked(): + _on_tactic_pressed() + elif event.keycode == KEY_I and not _is_input_locked(): + _on_item_pressed() + elif event.keycode == KEY_E and not _is_input_locked(): + _on_equip_pressed() + + +func _can_select(unit: Dictionary) -> bool: + if unit.is_empty(): + return false + return ( + unit.get("team", "") == BattleState.TEAM_PLAYER + and bool(unit.get("controllable", true)) + and not unit.get("acted", false) + ) + + +func _draw_map() -> void: + for y in range(state.map_size.y): + for x in range(state.map_size.x): + var cell := Vector2i(x, y) + var rect := _rect_for_cell(cell) + draw_rect(rect, state.get_terrain_color(cell)) + draw_rect(rect, GRID_COLOR, false, 1.0) + + var board_rect := Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE) + draw_rect(board_rect, Color(0.02, 0.025, 0.03), false, 3.0) + + +func _draw_overlays() -> void: + for cell in state.get_objective_cells(): + var objective_rect := _rect_for_cell(cell) + draw_rect(objective_rect, OBJECTIVE_OVERLAY_COLOR) + draw_rect(objective_rect.grow(-5.0), Color(1.0, 0.88, 0.30, 0.82), false, 2.0) + + for cell in move_cells: + draw_rect(_rect_for_cell(cell), Color(0.16, 0.55, 0.95, 0.28)) + + for cell in attack_cells: + draw_rect(_rect_for_cell(cell), Color(0.95, 0.22, 0.18, 0.24)) + + for cell in skill_cells: + draw_rect(_rect_for_cell(cell), SKILL_OVERLAY_COLOR) + + for cell in item_cells: + draw_rect(_rect_for_cell(cell), ITEM_OVERLAY_COLOR) + + if formation_menu != null and formation_menu.visible: + for cell in state.get_formation_cells(): + draw_rect(_rect_for_cell(cell), FORMATION_OVERLAY_COLOR) + + if state.is_inside(hover_cell): + var hover_color := Color(1.0, 1.0, 1.0, 0.22) + var hover_unit := state.get_unit_at(hover_cell) + var selected := state.get_selected_unit() + if formation_menu != null and formation_menu.visible and state.is_formation_cell(hover_cell): + hover_color = Color(1.0, 0.86, 0.35, 0.62) + elif not selected_skill_id.is_empty() and skill_cells.has(hover_cell): + hover_color = Color(0.72, 0.46, 1.0, 0.54) + elif not selected_item_id.is_empty() and item_cells.has(hover_cell): + hover_color = Color(0.36, 0.95, 0.42, 0.52) + elif not hover_unit.is_empty() and not selected.is_empty() and hover_unit.get("team", "") != selected.get("team", ""): + hover_color = Color(1.0, 0.34, 0.24, 0.45) + draw_rect(_rect_for_cell(hover_cell), hover_color, false, 3.0) + + +func _draw_units() -> void: + var font := ThemeDB.fallback_font + for unit in state.units: + if not unit.get("alive", false) or not unit.get("deployed", true): + continue + + var rect := _rect_for_cell(unit["pos"]) + var center := rect.position + rect.size * 0.5 + var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR + var body_color := team_color.darkened(0.35) if unit.get("acted", false) else team_color + + draw_circle(center, 24, body_color) + draw_arc(center, 24, 0.0, TAU, 48, Color(0.03, 0.035, 0.04), 2.0) + + var label := _short_name(String(unit["name"])) + draw_string(font, rect.position + Vector2(2, 25), label, HORIZONTAL_ALIGNMENT_CENTER, TILE_SIZE - 4, 13, Color.WHITE) + + var hp_ratio := float(unit["hp"]) / float(unit["max_hp"]) + var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 12), Vector2(TILE_SIZE - 16, 6)) + var hp_front := Rect2(hp_back.position, Vector2(hp_back.size.x * hp_ratio, hp_back.size.y)) + draw_rect(hp_back, Color(0.08, 0.08, 0.09)) + draw_rect(hp_front, Color(0.28, 0.82, 0.36)) + + if unit.get("id", "") == state.selected_unit_id: + draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0) + + +func _cell_from_screen(screen_position: Vector2) -> Vector2i: + var local := screen_position - BOARD_OFFSET + if local.x < 0 or local.y < 0: + return Vector2i(-1, -1) + return Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE))) + + +func _rect_for_cell(cell: Vector2i) -> Rect2: + return Rect2(BOARD_OFFSET + Vector2(cell.x, cell.y) * TILE_SIZE, Vector2(TILE_SIZE, TILE_SIZE)) + + +func _short_name(unit_name: String) -> String: + if unit_name.length() <= 9: + return unit_name + return unit_name.substr(0, 8) + "." + + +func _refresh_ranges() -> void: + move_cells.clear() + attack_cells.clear() + skill_cells.clear() + item_cells.clear() + var selected := state.get_selected_unit() + if selected.is_empty(): + selected_skill_id = "" + selected_item_id = "" + return + move_cells = state.get_movement_range(selected["id"]) + if not selected_skill_id.is_empty(): + skill_cells = state.get_skill_cells(selected["id"], selected_skill_id) + if skill_cells.is_empty(): + selected_skill_id = "" + attack_cells = state.get_attack_cells(selected["id"]) + elif not selected_item_id.is_empty(): + item_cells = state.get_item_target_cells(selected["id"], selected_item_id) + if item_cells.is_empty(): + selected_item_id = "" + attack_cells = state.get_attack_cells(selected["id"]) + else: + attack_cells = state.get_attack_cells(selected["id"]) + + +func _update_hud() -> void: + status_label.text = state.get_status_text() + objective_label.text = String(state.objectives.get("victory", "Defeat all enemies.")) + campaign_status_label.text = campaign_state.get_progress_text() + _update_cell_info() + _update_forecast() + _update_result_panel() + if inventory_label != null: + inventory_label.text = _format_inventory_status_text() + + var selected := state.get_selected_unit() + if selected.is_empty(): + selected_label.text = "No unit selected." + wait_button.disabled = true + _update_tactic_button({}) + _hide_tactic_menu() + _update_item_button({}) + _hide_item_menu() + _update_equip_button({}) + _hide_equip_menu() + else: + var roster_mark := " *" if selected.get("loaded_from_roster", false) else "" + selected_label.text = "%s%s Lv.%d %s EXP %d\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d\nMOV %d RNG %d-%d" % [ + selected["name"], + roster_mark, + selected["level"], + selected["class"], + selected.get("exp", 0), + selected["hp"], + selected["max_hp"], + selected.get("mp", 0), + selected.get("max_mp", 0), + selected["atk"], + selected["def"], + selected.get("int", 0), + selected["move"], + selected.get("min_range", 1), + selected["range"] + ] + wait_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked() + _update_tactic_button(selected) + _update_item_button(selected) + _update_equip_button(selected) + if tactic_menu != null and tactic_menu.visible: + _rebuild_tactic_menu(selected) + if item_menu != null and item_menu.visible: + _rebuild_item_menu(selected) + if equip_menu != null and equip_menu.visible: + _rebuild_equip_menu(selected) + + end_turn_button.disabled = not state.can_player_act() or _is_input_locked() + restart_button.disabled = campaign_complete_screen + + +func _update_cell_info() -> void: + if not state.is_inside(hover_cell): + cell_info_label.text = "Tile: -" + return + + var summary := state.get_cell_summary(hover_cell) + var cell: Vector2i = summary["cell"] + var text := "Tile %d,%d %s\nMove cost %d DEF +%d AVO +%d%%" % [ + cell.x + 1, + cell.y + 1, + summary["terrain"], + summary["move_cost"], + summary["defense"], + summary.get("avoid", 0) + ] + if state.get_objective_cells().has(cell): + text += "\nObjective marker" + + var unit: Dictionary = summary["unit"] + if not unit.is_empty(): + var control_label := " Protected" if not bool(unit.get("controllable", true)) else "" + text += "\n%s%s %s Lv.%d\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d RNG %d-%d" % [ + unit["name"], + control_label, + str(unit["team"]).capitalize(), + unit["level"], + unit["hp"], + unit["max_hp"], + unit.get("mp", 0), + unit.get("max_mp", 0), + unit["atk"], + unit["def"], + unit.get("int", 0), + unit.get("min_range", 1), + unit["range"] + ] + + cell_info_label.text = text + + +func _update_forecast() -> void: + var selected := state.get_selected_unit() + if selected.is_empty() or not state.is_inside(hover_cell): + forecast_label.text = "Forecast: -" + return + + if not selected_skill_id.is_empty(): + _update_skill_forecast(selected) + return + if not selected_item_id.is_empty(): + _update_item_forecast(selected) + return + + var target := state.get_unit_at(hover_cell) + if target.is_empty() or target.get("team", "") == selected.get("team", ""): + forecast_label.text = "Forecast: -" + return + + var preview := state.get_damage_preview(selected["id"], target["id"]) + if preview.is_empty(): + forecast_label.text = "Forecast: -" + return + + var range_text := "ready" if preview["in_range"] else "out of range" + var defeat_text := " Defeat" if preview["would_defeat"] else "" + var counter_text := "" + if preview["counter_in_range"]: + var counter_defeat := " Defeat" if preview["counter_would_defeat"] else "" + counter_text = "\nCounter %d, Hit %d%%, your HP %d/%d.%s" % [ + preview["counter_damage"], + preview.get("counter_hit_chance", 0), + preview["attacker_hp_after"], + selected["max_hp"], + counter_defeat + ] + forecast_label.text = "Forecast: %s\n%d damage, Hit %d%%, target HP %d/%d.%s%s" % [ + range_text, + preview["damage"], + preview.get("hit_chance", 100), + preview["target_hp_after"], + target["max_hp"], + defeat_text, + counter_text + ] + + +func _update_skill_forecast(selected: Dictionary) -> void: + var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell) + if preview.is_empty(): + forecast_label.text = "Forecast: -" + return + + var target := state.get_unit_at(hover_cell) + var range_text := "ready" if preview["in_range"] else "out of range" + var mp_text := "" if preview["has_mp"] else " Not enough MP." + if target.is_empty() or not preview["valid_target"]: + forecast_label.text = "%s: %s, %d MP.%s\nNo valid target." % [ + preview["skill_name"], + range_text, + preview["mp_cost"], + mp_text + ] + return + + if preview["kind"] == "heal": + forecast_label.text = "%s: %s, %d MP.%s\nRestore %d HP, target HP %d/%d." % [ + preview["skill_name"], + range_text, + preview["mp_cost"], + mp_text, + preview["heal"], + preview["target_hp_after"], + target["max_hp"] + ] + else: + var defeat_text := " Defeat" if preview["would_defeat"] else "" + forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [ + preview["skill_name"], + range_text, + preview["mp_cost"], + mp_text, + preview["damage"], + preview["target_hp_after"], + target["max_hp"], + defeat_text + ] + + +func _update_item_forecast(selected: Dictionary) -> void: + var preview := state.get_item_preview(selected["id"], selected_item_id, hover_cell) + if preview.is_empty(): + forecast_label.text = "Forecast: -" + return + + var target := state.get_unit_at(hover_cell) + var range_text := "ready" if preview["in_range"] else "out of range" + var count_text := "x%d" % int(preview.get("count", 0)) + if target.is_empty() or not preview["valid_target"]: + forecast_label.text = "%s: %s, %s\nNo valid target." % [ + preview["item_name"], + range_text, + count_text + ] + return + + var hp_heal := int(preview.get("heal", 0)) + var mp_heal := int(preview.get("mp_heal", 0)) + if hp_heal <= 0 and mp_heal <= 0: + forecast_label.text = "%s: %s, %s\nNo recovery needed." % [ + preview["item_name"], + range_text, + count_text + ] + return + + var recovery_parts := [] + if hp_heal > 0: + recovery_parts.append("Restore %d HP, target HP %d/%d" % [ + hp_heal, + preview["target_hp_after"], + target["max_hp"] + ]) + if mp_heal > 0: + recovery_parts.append("Restore %d MP, target MP %d/%d" % [ + mp_heal, + preview["target_mp_after"], + target.get("max_mp", 0) + ]) + + forecast_label.text = "%s: %s, %s\n%s." % [ + preview["item_name"], + range_text, + count_text, + _join_strings(recovery_parts, "; ") + ] + + +func _update_result_panel() -> void: + if result_panel == null: + return + if campaign_complete_screen: + _clear_dialogue() + _clear_result_choices() + result_panel.visible = true + if result_restart_button != null: + result_restart_button.visible = false + if next_battle_button != null: + next_battle_button.visible = false + result_label.text = "%s Complete\nAll current scenarios cleared.\nGold %d\nStart a new campaign to replay." % [ + campaign_state.campaign_title, + campaign_state.gold + ] + return + if state.battle_status == BattleState.STATUS_VICTORY: + if _try_show_post_battle_dialogue(): + _clear_result_choices() + result_panel.visible = false + return + if _is_dialogue_visible(): + _clear_result_choices() + result_panel.visible = false + return + _apply_battle_result_once() + result_panel.visible = true + _rebuild_result_choices() + if result_restart_button != null: + result_restart_button.visible = true + if next_battle_button != null: + next_battle_button.visible = not str(battle_result_summary.get("next_scenario_id", "")).is_empty() and bool(battle_result_summary.get("choice_applied", true)) and bool(battle_result_summary.get("saved", false)) + result_label.text = "Victory\n%s\n%s" % [ + state.objectives.get("victory", ""), + _format_battle_result_summary() + ] + elif state.battle_status == BattleState.STATUS_DEFEAT: + _clear_dialogue() + _clear_result_choices() + result_panel.visible = true + if result_restart_button != null: + result_restart_button.visible = true + if next_battle_button != null: + next_battle_button.visible = false + result_label.text = "Defeat\n%s" % state.objectives.get("defeat", "") + else: + _clear_result_choices() + result_panel.visible = false + if result_restart_button != null: + result_restart_button.visible = true + if next_battle_button != null: + next_battle_button.visible = false + + +func _on_state_changed() -> void: + if state.get_selected_unit().is_empty(): + selected_skill_id = "" + selected_item_id = "" + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_log_added(message: String) -> void: + if log_box == null: + return + log_box.append_text(message + "\n") + log_box.scroll_to_line(max(0, log_box.get_line_count() - 1)) + + +func _on_dialogue_requested(lines: Array) -> void: + var normalized_lines := _normalized_dialogue_lines(lines) + if normalized_lines.is_empty(): + return + dialogue_queue.append(normalized_lines) + if _can_show_dialogue_now(): + _show_next_dialogue() + + +func _normalized_dialogue_lines(lines: Array) -> Array: + var result := [] + for line in lines: + if typeof(line) == TYPE_DICTIONARY: + var text := str(line.get("text", "")) + if text.is_empty(): + continue + result.append({ + "speaker": str(line.get("speaker", "")), + "text": text + }) + elif typeof(line) == TYPE_STRING: + var text := str(line) + if text.is_empty(): + continue + result.append({"speaker": "", "text": text}) + return result + + +func _can_show_dialogue_now() -> bool: + if not battle_started or campaign_complete_screen: + return false + if state.battle_status != BattleState.STATUS_ACTIVE: + return false + if briefing_panel != null and briefing_panel.visible: + return false + if result_panel != null and result_panel.visible: + return false + return not _is_dialogue_visible() + + +func _show_next_dialogue() -> void: + if dialogue_panel == null or dialogue_queue.is_empty(): + return + if _is_dialogue_visible(): + return + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _refresh_ranges() + active_dialogue_lines = dialogue_queue.pop_front() + active_dialogue_index = 0 + _render_dialogue_line() + + +func _render_dialogue_line() -> void: + if active_dialogue_index < 0 or active_dialogue_index >= active_dialogue_lines.size(): + _hide_dialogue_panel() + return + var line: Dictionary = active_dialogue_lines[active_dialogue_index] + var speaker := str(line.get("speaker", "")) + dialogue_speaker_label.text = speaker + dialogue_speaker_label.visible = not speaker.is_empty() + dialogue_text_label.text = str(line.get("text", "")) + dialogue_continue_button.text = "Close" if active_dialogue_index >= active_dialogue_lines.size() - 1 else "Next" + dialogue_panel.visible = true + _update_hud() + queue_redraw() + + +func _advance_dialogue() -> void: + if not _is_dialogue_visible(): + return + active_dialogue_index += 1 + if active_dialogue_index < active_dialogue_lines.size(): + _render_dialogue_line() + return + _hide_dialogue_panel() + if _can_show_dialogue_now(): + _show_next_dialogue() + else: + _update_hud() + queue_redraw() + + +func _hide_dialogue_panel() -> void: + if dialogue_panel != null: + dialogue_panel.visible = false + active_dialogue_lines.clear() + active_dialogue_index = 0 + + +func _clear_dialogue() -> void: + dialogue_queue.clear() + _hide_dialogue_panel() + + +func _is_dialogue_visible() -> bool: + return dialogue_panel != null and dialogue_panel.visible + + +func _on_wait_pressed() -> void: + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + state.try_wait_selected() + + +func _on_end_turn_pressed() -> void: + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + state.end_player_turn() + + +func _on_tactic_pressed() -> void: + var selected := state.get_selected_unit() + if selected.is_empty(): + return + if tactic_menu != null and tactic_menu.visible: + _hide_tactic_menu() + else: + selected_item_id = "" + _hide_item_menu() + _hide_equip_menu() + _show_tactic_menu(selected) + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_item_pressed() -> void: + var selected := state.get_selected_unit() + if selected.is_empty(): + return + if item_menu != null and item_menu.visible: + _hide_item_menu() + else: + selected_skill_id = "" + _hide_tactic_menu() + _hide_equip_menu() + _show_item_menu(selected) + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_equip_pressed() -> void: + var selected := state.get_selected_unit() + if selected.is_empty(): + return + if selected.get("moved", false) or selected.get("acted", false): + return + if equip_menu != null and equip_menu.visible: + _hide_equip_menu() + else: + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _show_equip_menu(selected) + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_restart_pressed() -> void: + if campaign_complete_screen: + return + if log_box != null: + log_box.clear() + battle_started = false + battle_result_applied = false + battle_result_summary.clear() + post_battle_dialogue_started = false + selected_skill_id = "" + selected_item_id = "" + armory_unit_id = "" + formation_unit_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _load_scenario(active_scenario_id) + _show_briefing() + + +func _load_current_battle() -> void: + _load_scenario(campaign_state.current_scenario_id) + + +func _load_pending_post_battle_choice() -> void: + _load_scenario(campaign_state.get_pending_post_battle_choice_scenario_id()) + battle_started = true + battle_result_applied = true + post_battle_dialogue_started = true + battle_result_summary = campaign_state.get_pending_post_battle_choice_summary() + state.battle_status = BattleState.STATUS_VICTORY + if briefing_panel != null: + briefing_panel.visible = false + if log_box != null: + log_box.append_text("Campaign choice pending.\n") + + +func _load_scenario(scenario_id: String) -> void: + campaign_complete_screen = false + active_scenario_id = scenario_id + var scenario_path := campaign_state.get_scenario_path(active_scenario_id) + if scenario_path.is_empty(): + push_error("Missing scenario path for %s." % active_scenario_id) + return + battle_started = false + battle_result_applied = false + battle_result_summary.clear() + post_battle_dialogue_started = false + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + _clear_dialogue() + state.load_battle( + scenario_path, + campaign_state.get_roster_overrides(), + campaign_state.get_inventory_snapshot(), + campaign_state.get_flags_snapshot(), + campaign_state.get_joined_officers_snapshot() + ) + + +func _show_briefing() -> void: + var briefing := state.get_briefing() + if briefing.is_empty(): + _start_battle_from_briefing() + return + + var body := "" + for line in briefing.get("lines", []): + if not body.is_empty(): + body += "\n" + body += str(line) + + briefing_label.text = "%s\n%s\n\n%s\n\nObjective: %s" % [ + briefing.get("title", state.battle_name), + briefing.get("location", ""), + body, + state.objectives.get("victory", "") + ] + briefing_panel.visible = true + battle_started = false + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + var prep_locked := _is_prebattle_prep_locked() + if shop_button != null: + shop_button.disabled = prep_locked or state.get_shop_item_ids().is_empty() + if armory_button != null: + armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty() + if roster_button != null: + roster_button.disabled = prep_locked or not state.has_deployment_roster() or state.get_player_units(true).is_empty() + if formation_button != null: + formation_button.disabled = prep_locked or state.get_formation_cells().is_empty() + + +func _on_begin_battle_pressed() -> void: + _start_battle_from_briefing() + + +func _start_battle_from_briefing() -> void: + battle_started = true + if briefing_panel != null: + briefing_panel.visible = false + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + state.run_battle_begin_events() + _show_next_dialogue() + _update_hud() + + +func _on_shop_pressed() -> void: + if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible: + return + if shop_menu != null and shop_menu.visible: + _hide_shop_menu() + else: + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + _rebuild_shop_menu() + shop_menu.visible = true + _update_hud() + + +func _hide_shop_menu() -> void: + if shop_menu == null: + return + shop_menu.visible = false + + +func _on_armory_pressed() -> void: + if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible: + return + if armory_menu != null and armory_menu.visible: + _hide_armory_menu() + else: + _hide_shop_menu() + _hide_roster_menu() + _hide_formation_menu() + _rebuild_armory_menu() + armory_menu.visible = true + _update_hud() + + +func _hide_armory_menu() -> void: + if armory_menu == null: + return + armory_menu.visible = false + + +func _on_roster_pressed() -> void: + if battle_started or _is_prebattle_prep_locked() or not state.has_deployment_roster() or briefing_panel == null or not briefing_panel.visible: + return + if roster_menu != null and roster_menu.visible: + _hide_roster_menu() + else: + _hide_shop_menu() + _hide_armory_menu() + _hide_formation_menu() + _rebuild_roster_menu() + roster_menu.visible = true + _update_hud() + + +func _hide_roster_menu() -> void: + if roster_menu == null: + return + roster_menu.visible = false + + +func _on_formation_pressed() -> void: + if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible: + return + if formation_menu != null and formation_menu.visible: + _hide_formation_menu() + else: + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _rebuild_formation_menu() + formation_menu.visible = true + _update_hud() + queue_redraw() + + +func _hide_formation_menu() -> void: + if formation_menu == null: + return + formation_menu.visible = false + + +func _is_prep_menu_visible() -> bool: + return ( + (shop_menu != null and shop_menu.visible) + or (armory_menu != null and armory_menu.visible) + or (roster_menu != null and roster_menu.visible) + or (formation_menu != null and formation_menu.visible) + ) + + +func _is_prebattle_prep_locked() -> bool: + return campaign_state.is_scenario_completed(active_scenario_id) + + +func _rebuild_roster_menu() -> void: + if roster_list == null: + return + _clear_roster_list() + var units := state.get_player_units(true) + var max_units := state.get_deployment_max_units() + var deployed_count := state.get_deployed_player_count() + if roster_status_label != null: + roster_status_label.text = "Sortie %d/%d" % [deployed_count, max_units] + + if units.is_empty(): + var empty_label := Label.new() + empty_label.text = "No officers" + roster_list.add_child(empty_label) + return + + for unit in units: + var unit_id := str(unit.get("id", "")) + var roster_unit_button := Button.new() + roster_unit_button.text = _format_roster_unit_button_text(unit) + var deployed := bool(unit.get("deployed", true)) + roster_unit_button.disabled = ( + bool(unit.get("required_deployment", false)) + or (not deployed and deployed_count >= max_units) + ) + roster_unit_button.pressed.connect(_on_roster_unit_pressed.bind(unit_id)) + roster_list.add_child(roster_unit_button) + + +func _clear_roster_list() -> void: + for child in roster_list.get_children(): + roster_list.remove_child(child) + child.queue_free() + + +func _format_roster_unit_button_text(unit: Dictionary) -> String: + var state_text := "Deploy" if bool(unit.get("deployed", true)) else "Reserve" + var required_text := " Required" if bool(unit.get("required_deployment", false)) else "" + var protected_text := " Protected" if not bool(unit.get("controllable", true)) else "" + return "%s%s Lv.%d %s %s%s" % [ + str(unit.get("name", "Officer")), + protected_text, + int(unit.get("level", 1)), + str(unit.get("class", "")), + state_text, + required_text + ] + + +func _on_roster_unit_pressed(unit_id: String) -> void: + var unit := state.get_unit(unit_id) + if unit.is_empty(): + return + var next_deployed := not bool(unit.get("deployed", true)) + if state.try_set_unit_deployed(unit_id, next_deployed): + if not state.get_unit(formation_unit_id).get("deployed", false): + formation_unit_id = "" + if not state.get_unit(armory_unit_id).get("deployed", false): + armory_unit_id = "" + _rebuild_roster_menu() + _update_hud() + queue_redraw() + + +func _rebuild_formation_menu() -> void: + if formation_list == null: + return + _clear_formation_list() + var units := state.get_controllable_player_units() + if formation_unit_id.is_empty() or _find_unit_in_list(units, formation_unit_id).is_empty(): + formation_unit_id = str(units[0].get("id", "")) if not units.is_empty() else "" + + if formation_status_label != null: + var selected := state.get_unit(formation_unit_id) + if selected.is_empty(): + formation_status_label.text = "Formation" + else: + formation_status_label.text = "Formation: %s at %s" % [ + str(selected.get("name", "Officer")), + _format_cell_label(selected.get("pos", Vector2i.ZERO)) + ] + + if units.is_empty(): + var empty_label := Label.new() + empty_label.text = "No deployed officers" + formation_list.add_child(empty_label) + return + + for unit in units: + var unit_id := str(unit.get("id", "")) + var unit_button := Button.new() + unit_button.text = _format_formation_unit_button_text(unit) + unit_button.disabled = unit_id == formation_unit_id + unit_button.pressed.connect(_on_formation_unit_pressed.bind(unit_id)) + formation_list.add_child(unit_button) + + +func _clear_formation_list() -> void: + for child in formation_list.get_children(): + formation_list.remove_child(child) + child.queue_free() + + +func _format_formation_unit_button_text(unit: Dictionary) -> String: + var marker := "> " if str(unit.get("id", "")) == formation_unit_id else "" + return "%s%s %s" % [ + marker, + str(unit.get("name", "Officer")), + _format_cell_label(unit.get("pos", Vector2i.ZERO)) + ] + + +func _format_cell_label(value) -> String: + if typeof(value) == TYPE_VECTOR2I: + return "%d,%d" % [value.x + 1, value.y + 1] + return "-" + + +func _on_formation_unit_pressed(unit_id: String) -> void: + formation_unit_id = unit_id + _rebuild_formation_menu() + _update_hud() + queue_redraw() + + +func _handle_formation_board_click(screen_position: Vector2) -> void: + if battle_started or _is_prebattle_prep_locked() or formation_unit_id.is_empty(): + return + var cell := _cell_from_screen(screen_position) + if not state.is_inside(cell): + return + if state.try_set_prebattle_formation(formation_unit_id, cell): + _rebuild_formation_menu() + _update_hud() + queue_redraw() + + +func _rebuild_armory_menu() -> void: + if armory_list == null: + return + _clear_armory_list() + var units := state.get_controllable_player_units() + if armory_unit_id.is_empty() or _find_unit_in_list(units, armory_unit_id).is_empty(): + armory_unit_id = str(units[0].get("id", "")) if not units.is_empty() else "" + + if armory_status_label != null: + armory_status_label.text = _format_inventory_status_text() + + if units.is_empty(): + var empty_label := Label.new() + empty_label.text = "No deployed officers" + armory_list.add_child(empty_label) + return + + var unit_title := Label.new() + unit_title.text = "Officers" + armory_list.add_child(unit_title) + + for unit in units: + var unit_id := str(unit.get("id", "")) + var unit_button := Button.new() + unit_button.text = _format_armory_unit_button_text(unit) + unit_button.disabled = unit_id == armory_unit_id + unit_button.pressed.connect(_on_armory_unit_pressed.bind(unit_id)) + armory_list.add_child(unit_button) + + var selected := state.get_unit(armory_unit_id) + if selected.is_empty(): + return + + var equipment_label := Label.new() + equipment_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + equipment_label.text = "Equipped: %s" % _format_unit_equipment(selected) + armory_list.add_child(equipment_label) + + var item_ids := state.get_equippable_item_ids(armory_unit_id) + if item_ids.is_empty(): + var empty_equipment_label := Label.new() + empty_equipment_label.text = "No compatible equipment in inventory" + armory_list.add_child(empty_equipment_label) + else: + var inventory := state.get_inventory_snapshot() + for item_id in item_ids: + var normalized_id := str(item_id) + var item := state.get_item_def(normalized_id) + var armory_equip_button := Button.new() + armory_equip_button.text = "%s x%d %s" % [ + _item_display_name(normalized_id), + int(inventory.get(normalized_id, 0)), + _format_equipment_bonus_text(item) + ] + armory_equip_button.pressed.connect(_on_armory_equip_pressed.bind(normalized_id)) + armory_list.add_child(armory_equip_button) + + +func _clear_armory_list() -> void: + for child in armory_list.get_children(): + armory_list.remove_child(child) + child.queue_free() + + +func _find_unit_in_list(units: Array, unit_id: String) -> Dictionary: + for unit in units: + if str(unit.get("id", "")) == unit_id: + return unit + return {} + + +func _format_armory_unit_button_text(unit: Dictionary) -> String: + var marker := "> " if str(unit.get("id", "")) == armory_unit_id else "" + return "%s%s Lv.%d %s" % [ + marker, + str(unit.get("name", "Officer")), + int(unit.get("level", 1)), + _format_unit_equipment(unit) + ] + + +func _on_armory_unit_pressed(unit_id: String) -> void: + armory_unit_id = unit_id + _rebuild_armory_menu() + _update_hud() + + +func _on_armory_equip_pressed(item_id: String) -> void: + if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty(): + return + if not state.try_equip_item(armory_unit_id, item_id): + _rebuild_armory_menu() + _update_hud() + return + if campaign_state.try_save_prebattle_loadout(state.get_player_roster_snapshot(), state.get_inventory_snapshot()): + _on_log_added("Saved pre-battle equipment.") + else: + _on_log_added("Could not save pre-battle equipment.") + _load_scenario(active_scenario_id) + _show_briefing() + return + _rebuild_armory_menu() + _update_hud() + queue_redraw() + + +func _rebuild_shop_menu() -> void: + if shop_list == null: + return + _clear_shop_list() + if shop_status_label != null: + shop_status_label.text = "Gold %d | %s" % [ + campaign_state.gold, + _format_inventory_status_text() + ] + + var item_ids := state.get_shop_item_ids() + if item_ids.is_empty(): + var empty_label := Label.new() + empty_label.text = "No shop stock" + shop_list.add_child(empty_label) + return + + var inventory := campaign_state.get_inventory_snapshot() + for item_id in item_ids: + var normalized_id := str(item_id) + var item := state.get_item_def(normalized_id) + var price := int(item.get("price", 0)) + var owned := int(inventory.get(normalized_id, 0)) + var buy_button := Button.new() + buy_button.text = _format_shop_item_button_text(normalized_id, item, price, owned) + buy_button.disabled = campaign_state.gold < price + buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id)) + shop_list.add_child(buy_button) + + +func _clear_shop_list() -> void: + for child in shop_list.get_children(): + shop_list.remove_child(child) + child.queue_free() + + +func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int) -> String: + var unavailable := "" + if campaign_state.gold < price: + unavailable = " - need %dG" % (price - campaign_state.gold) + return "%s %dG %s Owned x%d%s" % [ + _item_display_name(item_id), + price, + _format_shop_item_effect_text(item), + owned, + unavailable + ] + + +func _format_shop_item_effect_text(item: Dictionary) -> String: + var kind := str(item.get("kind", "item")) + if kind == "consumable": + return _format_item_effect_text(item) + if kind == "weapon" or kind == "armor" or kind == "accessory": + var parts := [] + var equipment_type := str(item.get("%s_type" % kind, "")) + if not equipment_type.is_empty(): + parts.append(equipment_type.capitalize()) + if item.has("range"): + var range_value: Array = item.get("range", []) + if range_value.size() >= 2: + parts.append("RNG %d-%d" % [int(range_value[0]), int(range_value[1])]) + parts.append(_format_equipment_bonus_text(item)) + return _join_strings(parts, ", ") + return kind.capitalize() + + +func _on_shop_item_pressed(item_id: String) -> void: + if battle_started or _is_prebattle_prep_locked(): + return + var item := state.get_item_def(item_id) + var price := int(item.get("price", 0)) + var item_name := _item_display_name(item_id) + if item.is_empty() or price <= 0: + return + if campaign_state.try_buy_item(item_id, price): + state.set_inventory_snapshot(campaign_state.get_inventory_snapshot()) + _on_log_added("Bought %s for %d gold." % [item_name, price]) + else: + _on_log_added("Could not buy %s." % item_name) + _rebuild_shop_menu() + _update_hud() + + +func _is_input_locked() -> bool: + return campaign_complete_screen or _is_dialogue_visible() or not battle_started or state.battle_status != BattleState.STATUS_ACTIVE + + +func _apply_battle_result_once() -> void: + if battle_result_applied: + return + battle_result_summary = campaign_state.apply_battle_result(state) + battle_result_applied = true + + +func _try_show_post_battle_dialogue() -> bool: + if post_battle_dialogue_started: + return false + var lines := _normalized_dialogue_lines(state.get_post_battle_dialogue()) + if lines.is_empty(): + post_battle_dialogue_started = true + return false + post_battle_dialogue_started = true + dialogue_queue.clear() + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _refresh_ranges() + active_dialogue_lines = lines + active_dialogue_index = 0 + _render_dialogue_line() + return true + + +func _rebuild_result_choices() -> void: + if result_choice_list == null: + return + _clear_result_choices() + if bool(battle_result_summary.get("choice_applied", true)): + return + var choices: Array = battle_result_summary.get("post_battle_choices", []) + for choice in choices: + if typeof(choice) != TYPE_DICTIONARY: + continue + var choice_button := Button.new() + choice_button.text = _format_post_battle_choice_text(choice) + choice_button.pressed.connect(_on_post_battle_choice_pressed.bind(choice)) + result_choice_list.add_child(choice_button) + + +func _clear_result_choices() -> void: + if result_choice_list == null: + return + for child in result_choice_list.get_children(): + result_choice_list.remove_child(child) + child.queue_free() + + +func _format_post_battle_choice_text(choice: Dictionary) -> String: + var label := str(choice.get("label", choice.get("text", choice.get("id", "Choice")))) + var description := str(choice.get("description", "")) + if description.is_empty(): + return label + return "%s - %s" % [label, description] + + +func _on_post_battle_choice_pressed(choice: Dictionary) -> void: + if battle_result_summary.is_empty() or bool(battle_result_summary.get("choice_applied", true)): + return + var scenario_id := str(battle_result_summary.get("scenario_id", state.battle_id)) + if campaign_state.try_apply_post_battle_choice(choice, scenario_id): + battle_result_summary["choice_applied"] = true + battle_result_summary["saved"] = true + battle_result_summary["choice_label"] = str(choice.get("label", choice.get("id", ""))) + _append_unique_result_values("joined_officers", choice.get("join_officers", [])) + _append_unique_result_values("left_officers", choice.get("leave_officers", [])) + _on_log_added("Campaign choice saved: %s." % battle_result_summary["choice_label"]) + else: + _on_log_added("Could not save campaign choice.") + _rebuild_result_choices() + _update_hud() + + +func _append_unique_result_values(key: String, values) -> void: + if typeof(values) != TYPE_ARRAY: + return + var current: Array = battle_result_summary.get(key, []) + for value in values: + var normalized := str(value) + if normalized.is_empty() or current.has(normalized): + continue + current.append(normalized) + battle_result_summary[key] = current + + +func _format_battle_result_summary() -> String: + if battle_result_summary.is_empty(): + return "" + if battle_result_summary.get("pending_choice", false): + var pending_next := str(battle_result_summary.get("next_scenario_title", "")) + var pending_text := "Campaign choice pending.\nCampaign saved." + if not pending_next.is_empty(): + pending_text += "\nNext: %s" % pending_next + return pending_text + if battle_result_summary.get("already_completed", false): + var already_saved := "Campaign saved." if battle_result_summary.get("saved", false) else "Campaign save failed." + var already_next := str(battle_result_summary.get("next_scenario_title", "")) + if already_next.is_empty(): + return "Rewards already claimed.\n%s" % already_saved + return "Rewards already claimed.\nNext: %s\n%s" % [already_next, already_saved] + + var items: Array = battle_result_summary.get("items", []) + var item_text := _format_reward_items(items) + var joined_text := "" + var joined_officers: Array = battle_result_summary.get("joined_officers", []) + if not joined_officers.is_empty(): + joined_text = "\nJoined: %s" % _join_strings(joined_officers, ", ") + var left_text := "" + var left_officers: Array = battle_result_summary.get("left_officers", []) + if not left_officers.is_empty(): + left_text = "\nLeft: %s" % _join_strings(left_officers, ", ") + var progression_text := _format_progression_events(battle_result_summary.get("progression_events", [])) + + var saved_text := "Campaign saved." if battle_result_summary.get("saved", false) else "Campaign save failed." + var next_text := "Campaign complete." + var next_title := str(battle_result_summary.get("next_scenario_title", "")) + if not next_title.is_empty(): + next_text = "Next: %s" % next_title + var choice_text := "" + if not bool(battle_result_summary.get("choice_applied", true)): + choice_text = "\nChoose a campaign response." + elif not str(battle_result_summary.get("choice_label", "")).is_empty(): + choice_text = "\nChoice: %s" % str(battle_result_summary.get("choice_label", "")) + return "Rewards: %d gold, %s%s%s%s\nTotal gold: %d\n%s\n%s%s" % [ + battle_result_summary.get("gold", 0), + item_text, + joined_text, + left_text, + progression_text, + campaign_state.gold, + saved_text, + next_text, + choice_text + ] + + +func _format_progression_events(events) -> String: + if typeof(events) != TYPE_ARRAY: + return "" + var parts := [] + var hidden_count := 0 + for event in events: + if typeof(event) != TYPE_DICTIONARY: + continue + if parts.size() >= 4: + hidden_count += 1 + continue + var event_type := str(event.get("type", "")) + var unit_name := str(event.get("name", "Unit")) + if event_type == "promotion": + parts.append("%s -> %s" % [unit_name, str(event.get("to_class", "Promoted"))]) + elif event_type == "level_up": + parts.append("%s Lv.%d" % [unit_name, int(event.get("to_level", 1))]) + if hidden_count > 0: + parts.append("+%d more" % hidden_count) + if parts.is_empty(): + return "" + return "\nProgress: %s" % _join_strings(parts, "; ") + + +func _format_inventory_status_text() -> String: + var inventory := state.get_inventory_snapshot() + if campaign_complete_screen or state.battle_status != BattleState.STATUS_ACTIVE: + inventory = campaign_state.get_inventory_snapshot() + return "Inventory: %s" % _format_inventory_by_category(inventory) + + +func _format_reward_items(items: Array) -> String: + var counts := {} + for item_id in items: + var key := str(item_id) + if key.is_empty(): + continue + counts[key] = int(counts.get(key, 0)) + 1 + return _format_inventory_items(counts) + + +func _format_inventory_items(inventory: Dictionary) -> String: + var item_ids := [] + for item_id in inventory.keys(): + var key := str(item_id) + if key.is_empty() or int(inventory.get(key, 0)) <= 0: + continue + item_ids.append(key) + item_ids.sort() + if item_ids.is_empty(): + return "none" + + var parts := [] + for item_id in item_ids: + var key := str(item_id) + parts.append("%s x%d" % [_item_display_name(key), int(inventory.get(key, 0))]) + return _join_strings(parts, ", ") + + +func _format_inventory_by_category(inventory: Dictionary) -> String: + var consumables := {} + var equipment := {} + var other := {} + for item_id in inventory.keys(): + var key := str(item_id) + var count := int(inventory.get(key, 0)) + if key.is_empty() or count <= 0: + continue + var item := state.get_item_def(key) + var kind := str(item.get("kind", "")) + if kind == "consumable": + consumables[key] = count + elif kind == "weapon" or kind == "armor" or kind == "accessory": + equipment[key] = count + else: + other[key] = count + + var sections := [] + if not consumables.is_empty(): + sections.append("Consumables: %s" % _format_inventory_items(consumables)) + if not equipment.is_empty(): + sections.append("Equipment: %s" % _format_inventory_items(equipment)) + if not other.is_empty(): + sections.append("Other: %s" % _format_inventory_items(other)) + if sections.is_empty(): + return "none" + return _join_strings(sections, " | ") + + +func _item_display_name(item_id: String) -> String: + var item := state.get_item_def(item_id) + if item.is_empty(): + return item_id + return str(item.get("name", item_id)) + + +func _join_strings(values: Array, delimiter: String) -> String: + var text := "" + for value in values: + if not text.is_empty(): + text += delimiter + text += str(value) + return text + + +func _on_next_battle_pressed() -> void: + var next_scenario_id := str(battle_result_summary.get("next_scenario_id", "")) + if next_scenario_id.is_empty(): + return + if log_box != null: + log_box.clear() + _load_scenario(next_scenario_id) + _show_briefing() + _update_hud() + + +func _show_campaign_complete() -> void: + campaign_complete_screen = true + battle_started = false + battle_result_applied = true + battle_result_summary.clear() + post_battle_dialogue_started = true + move_cells.clear() + attack_cells.clear() + skill_cells.clear() + item_cells.clear() + selected_skill_id = "" + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + _clear_dialogue() + active_scenario_id = campaign_state.current_scenario_id + if briefing_panel != null: + briefing_panel.visible = false + if log_box != null: + log_box.append_text("Campaign complete.\n") + + +func _on_new_campaign_pressed() -> void: + if log_box != null: + log_box.clear() + if not campaign_state.reset_save(campaign_state.get_start_scenario_id()): + result_panel.visible = true + result_label.text = "Unable to reset campaign save.\nClose the game and check file permissions." + if result_restart_button != null: + result_restart_button.visible = false + if next_battle_button != null: + next_battle_button.visible = false + return + campaign_complete_screen = false + battle_started = false + battle_result_applied = false + battle_result_summary.clear() + post_battle_dialogue_started = false + selected_skill_id = "" + selected_item_id = "" + formation_unit_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_roster_menu() + _load_current_battle() + _show_briefing() + _update_hud() + + +func _update_tactic_button(selected: Dictionary) -> void: + if tactic_button == null: + return + if selected.is_empty(): + tactic_button.text = "Tactic" + tactic_button.disabled = true + return + + var skill_ids := state.get_skill_ids(selected["id"]) + if skill_ids.is_empty(): + tactic_button.text = "Tactic" + tactic_button.disabled = true + return + + if not selected_skill_id.is_empty(): + var skill := state.get_skill_def(selected_skill_id) + tactic_button.text = "Tactic: %s" % str(skill.get("name", selected_skill_id)) + elif tactic_menu != null and tactic_menu.visible: + tactic_button.text = "Close Tactic" + else: + tactic_button.text = "Tactic" + tactic_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked() + + +func _show_tactic_menu(selected: Dictionary) -> void: + if tactic_menu == null: + return + _hide_item_menu() + _hide_equip_menu() + _rebuild_tactic_menu(selected) + tactic_menu.visible = true + + +func _hide_tactic_menu() -> void: + if tactic_menu == null: + return + tactic_menu.visible = false + + +func _rebuild_tactic_menu(selected: Dictionary) -> void: + if tactic_list == null: + return + _clear_tactic_list() + var skill_ids := state.get_skill_ids(selected["id"]) + if skill_ids.is_empty(): + var empty_label := Label.new() + empty_label.text = "No tactics" + tactic_list.add_child(empty_label) + return + + for skill_id in skill_ids: + var skill := state.get_skill_def(str(skill_id)) + var skill_button := Button.new() + skill_button.text = _format_tactic_button_text(str(skill_id), skill, selected) + skill_button.disabled = selected.get("acted", false) or _is_input_locked() or int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0)) + skill_button.pressed.connect(_on_tactic_skill_pressed.bind(str(skill_id))) + tactic_list.add_child(skill_button) + + var cancel_button := Button.new() + cancel_button.text = "Cancel" + cancel_button.pressed.connect(_on_tactic_cancel_pressed) + tactic_list.add_child(cancel_button) + + +func _clear_tactic_list() -> void: + for child in tactic_list.get_children(): + tactic_list.remove_child(child) + child.queue_free() + + +func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: Dictionary) -> String: + var marker := "> " if selected_skill_id == skill_id else "" + var skill_name := str(skill.get("name", skill_id)) + var mp_cost := int(skill.get("mp_cost", 0)) + var kind := str(skill.get("kind", "skill")).capitalize() + var range_text := _format_tactic_range_text(skill) + var power_text := int(skill.get("power", 0)) + var disabled_text := " - MP" if int(selected.get("mp", 0)) < mp_cost else "" + return "%s%s %dMP %s R%s P%d%s" % [marker, skill_name, mp_cost, kind, range_text, power_text, disabled_text] + + +func _format_tactic_range_text(skill: Dictionary) -> String: + var value = skill.get("range", 1) + if typeof(value) == TYPE_ARRAY: + if value.size() >= 2: + return "%d-%d" % [int(value[0]), int(value[1])] + if value.size() == 1: + return "%d" % int(value[0]) + return "1" + if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT: + return "%d" % int(value) + return "1" + + +func _on_tactic_skill_pressed(skill_id: String) -> void: + selected_skill_id = skill_id + selected_item_id = "" + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_tactic_cancel_pressed() -> void: + selected_skill_id = "" + _hide_tactic_menu() + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _update_item_button(selected: Dictionary) -> void: + if item_button == null: + return + if selected.is_empty(): + item_button.text = "Item" + item_button.disabled = true + return + + var item_ids := state.get_usable_item_ids() + if item_ids.is_empty(): + item_button.text = "Item" + item_button.disabled = true + return + + if not selected_item_id.is_empty(): + var item := state.get_item_def(selected_item_id) + item_button.text = "Item: %s" % str(item.get("name", selected_item_id)) + elif item_menu != null and item_menu.visible: + item_button.text = "Close Item" + else: + item_button.text = "Item" + item_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked() + + +func _show_item_menu(selected: Dictionary) -> void: + if item_menu == null: + return + _hide_tactic_menu() + _hide_equip_menu() + _rebuild_item_menu(selected) + item_menu.visible = true + + +func _hide_item_menu() -> void: + if item_menu == null: + return + item_menu.visible = false + + +func _rebuild_item_menu(selected: Dictionary) -> void: + if item_list == null: + return + _clear_item_list() + var item_ids := state.get_usable_item_ids() + if item_ids.is_empty(): + var empty_label := Label.new() + empty_label.text = "No items" + item_list.add_child(empty_label) + return + + var inventory := state.get_inventory_snapshot() + for item_id in item_ids: + var normalized_id := str(item_id) + var item := state.get_item_def(normalized_id) + var count := int(inventory.get(normalized_id, 0)) + var use_button := Button.new() + use_button.text = _format_item_button_text(normalized_id, item, count) + use_button.disabled = selected.get("acted", false) or _is_input_locked() or count <= 0 + use_button.pressed.connect(_on_item_selected_pressed.bind(normalized_id)) + item_list.add_child(use_button) + + var cancel_button := Button.new() + cancel_button.text = "Cancel" + cancel_button.pressed.connect(_on_item_cancel_pressed) + item_list.add_child(cancel_button) + + +func _clear_item_list() -> void: + for child in item_list.get_children(): + item_list.remove_child(child) + child.queue_free() + + +func _format_item_button_text(item_id: String, item: Dictionary, count: int) -> String: + var marker := "> " if selected_item_id == item_id else "" + var item_name := str(item.get("name", item_id)) + return "%s%s x%d %s" % [marker, item_name, count, _format_item_effect_text(item)] + + +func _format_item_effect_text(item: Dictionary) -> String: + var parts := [] + for effect in item.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) == "heal_hp": + parts.append("Heal %d" % int(effect.get("amount", 0))) + elif str(effect.get("type", "")) == "heal_mp": + parts.append("MP %d" % int(effect.get("amount", 0))) + if parts.is_empty(): + return str(item.get("kind", "item")).capitalize() + return _join_strings(parts, ", ") + + +func _on_item_selected_pressed(item_id: String) -> void: + selected_item_id = item_id + selected_skill_id = "" + _hide_item_menu() + _hide_tactic_menu() + _hide_equip_menu() + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_item_cancel_pressed() -> void: + selected_item_id = "" + _hide_item_menu() + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _update_equip_button(selected: Dictionary) -> void: + if equip_button == null: + return + if selected.is_empty(): + equip_button.text = "Equip" + equip_button.disabled = true + return + + if equip_menu != null and equip_menu.visible: + equip_button.text = "Close Equip" + else: + equip_button.text = "Equip" + equip_button.disabled = selected.get("moved", false) or selected.get("acted", false) or not state.can_player_act() or _is_input_locked() + + +func _show_equip_menu(selected: Dictionary) -> void: + if equip_menu == null: + return + _hide_tactic_menu() + _hide_item_menu() + _rebuild_equip_menu(selected) + equip_menu.visible = true + + +func _hide_equip_menu() -> void: + if equip_menu == null: + return + equip_menu.visible = false + + +func _rebuild_equip_menu(selected: Dictionary) -> void: + if equip_list == null: + return + _clear_equip_list() + var equipment_label := Label.new() + equipment_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + equipment_label.text = "Equipped: %s" % _format_unit_equipment(selected) + equip_list.add_child(equipment_label) + + var item_ids := state.get_equippable_item_ids(selected["id"]) + if item_ids.is_empty(): + var empty_label := Label.new() + empty_label.text = "No compatible equipment" + equip_list.add_child(empty_label) + else: + var inventory := state.get_inventory_snapshot() + for item_id in item_ids: + var normalized_id := str(item_id) + var item := state.get_item_def(normalized_id) + var equip_item_button := Button.new() + equip_item_button.text = "%s x%d %s" % [ + _item_display_name(normalized_id), + int(inventory.get(normalized_id, 0)), + _format_equipment_bonus_text(item) + ] + equip_item_button.disabled = _is_input_locked() + equip_item_button.pressed.connect(_on_equip_item_pressed.bind(normalized_id)) + equip_list.add_child(equip_item_button) + + var cancel_button := Button.new() + cancel_button.text = "Cancel" + cancel_button.pressed.connect(_on_equip_cancel_pressed) + equip_list.add_child(cancel_button) + + +func _clear_equip_list() -> void: + for child in equip_list.get_children(): + equip_list.remove_child(child) + child.queue_free() + + +func _format_unit_equipment(unit: Dictionary) -> String: + var equipment := state.get_equipment_snapshot(unit["id"]) + var parts := [] + for slot in ["weapon", "armor", "accessory"]: + var item_id := str(equipment.get(slot, "")) + if item_id.is_empty(): + parts.append("%s -" % slot.capitalize()) + else: + parts.append("%s %s" % [slot.capitalize(), _item_display_name(item_id)]) + return _join_strings(parts, ", ") + + +func _format_equipment_bonus_text(item: Dictionary) -> String: + var parts := [] + var bonuses: Dictionary = item.get("bonuses", {}) + for stat in ["hp", "mp", "atk", "def", "int", "agi"]: + var amount := int(bonuses.get(stat, 0)) + if amount == 0: + continue + var sign := "+" if amount > 0 else "" + parts.append("%s %s%d" % [stat.to_upper(), sign, amount]) + if parts.is_empty(): + return str(item.get("kind", "equipment")).capitalize() + return _join_strings(parts, ", ") + + +func _on_equip_item_pressed(item_id: String) -> void: + var selected := state.get_selected_unit() + if selected.is_empty(): + return + if state.try_equip_item(selected["id"], item_id): + _rebuild_equip_menu(state.get_selected_unit()) + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_equip_cancel_pressed() -> void: + _hide_equip_menu() + _update_hud() + queue_redraw() diff --git a/tools/check_godot46_readiness.ps1 b/tools/check_godot46_readiness.ps1 new file mode 100644 index 0000000..5bd0f45 --- /dev/null +++ b/tools/check_godot46_readiness.ps1 @@ -0,0 +1,153 @@ +param( + [switch]$RunGodotImport +) + +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$Failures = New-Object System.Collections.Generic.List[string] +$Warnings = New-Object System.Collections.Generic.List[string] + +function Add-Failure([string]$Message) { + $Failures.Add($Message) | Out-Null + Write-Host "[FAIL] $Message" +} + +function Add-Warning([string]$Message) { + $Warnings.Add($Message) | Out-Null + Write-Host "[WARN] $Message" +} + +function Add-Pass([string]$Message) { + Write-Host "[ OK ] $Message" +} + +function Find-Godot { + if (-not [string]::IsNullOrWhiteSpace($env:GODOT_BIN) -and (Test-Path $env:GODOT_BIN)) { + return (Resolve-Path $env:GODOT_BIN).Path + } + + $command = Get-Command godot -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $commonCandidates = @( + (Join-Path $env:USERPROFILE "Downloads\Godot_v4.6-stable_win64.exe"), + (Join-Path $env:USERPROFILE "Desktop\Godot_v4.6-stable_win64.exe"), + (Join-Path $env:LOCALAPPDATA "Programs\Godot\Godot_v4.6-stable_win64.exe"), + "C:\Program Files\Godot\Godot_v4.6-stable_win64.exe" + ) + + foreach ($candidate in $commonCandidates) { + if (Test-Path $candidate) { + return (Resolve-Path $candidate).Path + } + } + + return "" +} + +Push-Location $Root +try { + $validateScript = Join-Path $PSScriptRoot "validate_data.ps1" + if (Test-Path $validateScript) { + try { + & $validateScript | Out-Host + Add-Pass "data validation script passed" + } catch { + Add-Failure "data validation failed: $($_.Exception.Message)" + } + } else { + Add-Failure "missing tools\validate_data.ps1" + } + + $jsonFiles = Get-ChildItem -Path (Join-Path $Root "data") -Filter "*.json" -Recurse + foreach ($file in $jsonFiles) { + try { + Get-Content $file.FullName -Raw | ConvertFrom-Json | Out-Null + } catch { + Add-Failure "invalid JSON: $($file.FullName) - $($_.Exception.Message)" + } + } + if ($Failures.Count -eq 0) { + Add-Pass "all data JSON files parsed" + } + + $projectPath = Join-Path $Root "project.godot" + if (Test-Path $projectPath) { + $projectText = Get-Content $projectPath -Raw + if ($projectText -match 'config/features=PackedStringArray\("([^"]+)"\)') { + $featureVersion = $Matches[1] + if ($featureVersion -ne "4.6") { + Add-Warning "project feature tag is $featureVersion; expect Godot 4.6 editor migration to update it" + } else { + Add-Pass "project feature tag is 4.6" + } + } else { + Add-Warning "project feature tag was not found" + } + + if ($projectText -match 'renderer/rendering_method="gl_compatibility"') { + Add-Pass "project keeps the compatibility renderer explicit" + } else { + Add-Warning "rendering method is not explicitly gl_compatibility" + } + } else { + Add-Failure "missing project.godot" + } + + $riskPatterns = [ordered]@{ + "Godot 3 yield syntax" = '\byield\s*\(' + "Godot 3 File.new API" = '\bFile\.new\s*\(' + "Godot 3 Directory.new API" = '\bDirectory\.new\s*\(' + "Godot 3 Pool*Array type" = '\bPool[A-Za-z]+Array\b' + "Godot 3 KinematicBody node" = '\bKinematicBody(2D|3D)?\b' + "legacy TileMap set_cellv call" = '\bset_cellv\s*\(' + } + + $gdFiles = Get-ChildItem -Path (Join-Path $Root "scripts") -Filter "*.gd" -Recurse + foreach ($entry in $riskPatterns.GetEnumerator()) { + $matches = @() + foreach ($file in $gdFiles) { + $fileMatches = Select-String -Path $file.FullName -Pattern $entry.Value -AllMatches + if ($null -ne $fileMatches) { + $matches += $fileMatches + } + } + if ($matches.Count -gt 0) { + Add-Warning "$($entry.Key) found in $($matches.Count) location(s)" + } + } + Add-Pass "static Godot 3 API risk scan completed" + + $godotBin = Find-Godot + if ([string]::IsNullOrWhiteSpace($godotBin)) { + Add-Warning "Godot executable not found. Set GODOT_BIN or add godot to PATH for engine checks." + } else { + try { + $version = & $godotBin --version + Add-Pass "Godot executable found: $godotBin ($version)" + if ($version -notmatch '^4\.6') { + Add-Warning "detected Godot version is not 4.6: $version" + } + if ($RunGodotImport) { + & $godotBin --headless --path $Root --quit + Add-Pass "Godot headless project open completed" + } + } catch { + Add-Failure "Godot command failed: $($_.Exception.Message)" + } + } +} finally { + Pop-Location +} + +Write-Host "" +Write-Host "Summary: $($Failures.Count) failure(s), $($Warnings.Count) warning(s)" + +if ($Failures.Count -gt 0) { + exit 1 +} + +exit 0 diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 new file mode 100644 index 0000000..18cd2c0 --- /dev/null +++ b/tools/validate_data.ps1 @@ -0,0 +1,1334 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot + +function Read-Json($RelativePath) { + $path = Join-Path $Root $RelativePath + if (-not (Test-Path $path)) { + throw "Missing file: $RelativePath" + } + return Get-Content $path -Raw | ConvertFrom-Json +} + +function Has-Prop($Object, [string]$Name) { + if ($null -eq $Object) { + return $false + } + return $null -ne ($Object.PSObject.Properties | Where-Object { $_.Name -eq $Name }) +} + +function Get-Prop($Object, [string]$Name, $Default = $null) { + if (Has-Prop $Object $Name) { + return $Object.$Name + } + return $Default +} + +function Get-Names($Object) { + return @($Object.PSObject.Properties.Name) +} + +function Fail($Message) { + throw $Message +} + +function Check-Raw-Skills-Arrays() { + $relativePaths = @( + "data\defs\classes.json", + "data\defs\officers.json" + ) + $scenarioDir = Join-Path $Root "data\scenarios" + foreach ($file in Get-ChildItem -Path $scenarioDir -Filter "*.json") { + $relativePaths += "data\scenarios\$($file.Name)" + } + foreach ($relativePath in $relativePaths) { + $path = Join-Path $Root $relativePath + $text = Get-Content $path -Raw + if ($text -match '"skills"\s*:\s*"') { + Fail "$relativePath has skills as a string; use an array." + } + } +} + +function Get-Flag-Value-Kind($Value) { + if ($Value -is [bool]) { + return "bool" + } + if ($Value -is [string]) { + return "string" + } + if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) { + return "number" + } + return "other" +} + +function Terrain-At($Rows, [int]$X, [int]$Y) { + return [string]$Rows[$Y].Substring($X, 1) +} + +$campaign = Read-Json "data\campaign\campaign.json" +$classes = Read-Json "data\defs\classes.json" +$items = Read-Json "data\defs\items.json" +$officers = Read-Json "data\defs\officers.json" +$skills = Read-Json "data\defs\skills.json" +$terrain = Read-Json "data\defs\terrain.json" + +$classIds = Get-Names $classes +$itemIds = Get-Names $items +$officerIds = Get-Names $officers +$skillIds = Get-Names $skills +$terrainKeys = Get-Names $terrain + +$validConditionTypes = @( + "all_units_defeated", + "all_enemies_defeated", + "all_players_defeated", + "any_unit_defeated", + "any_officer_defeated", + "unit_reaches_tile", + "turn_limit", + "turn_reached", + "all", + "any" +) +$validTriggers = @("battle_start", "battle_begin", "turn_start", "unit_reaches_tile") +$validActions = @("log", "dialogue", "set_objective", "spawn_deployment", "spawn_deployments") +$validEffects = @("heal_hp", "heal_mp") +$validItemKinds = @("weapon", "armor", "accessory", "consumable") +$validBonusStats = @("hp", "mp", "atk", "def", "int", "agi") +$validSkillKinds = @("damage", "heal") +$validSkillTargets = @("enemy", "ally", "self", "any") +$validSkillStats = @("atk", "def", "int", "agi") +$knownFlagIds = New-Object System.Collections.Generic.HashSet[string] +$knownFlagValueKinds = @{} +$joinedOfficerIds = New-Object System.Collections.Generic.HashSet[string] + +foreach ($officerIdValue in (Get-Prop $campaign "initial_joined_officers" @())) { + $officerId = [string]$officerIdValue + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Campaign initial_joined_officers has an empty officer id." + } + if (-not $officerIds.Contains($officerId)) { + Fail "Campaign initial_joined_officers references unknown officer: $officerId" + } + if (-not $joinedOfficerIds.Add($officerId)) { + Fail "Campaign initial_joined_officers has duplicate officer: $officerId" + } +} + +function Check-Condition($Condition, [string]$ScenarioId, $EventIds, [int]$Width = -1, [int]$Height = -1, $Rows = $null, $KnownUnitIds = $null) { + if ($Condition -is [System.Array]) { + foreach ($entry in $Condition) { + Check-Condition $entry $ScenarioId $EventIds $Width $Height $Rows $KnownUnitIds + } + return + } + if (-not (Has-Prop $Condition "type")) { + Fail "Scenario $ScenarioId has a malformed condition." + } + + $type = [string]$Condition.type + if (-not $validConditionTypes.Contains($type)) { + Fail "Scenario $ScenarioId has unknown condition type: $type" + } + if ((Has-Prop $Condition "after_event") -and (-not $EventIds.Contains([string]$Condition.after_event))) { + Fail "Scenario $ScenarioId condition references missing event: $($Condition.after_event)" + } + if (($type -eq "all" -or $type -eq "any") -and (Has-Prop $Condition "conditions")) { + Check-Condition $Condition.conditions $ScenarioId $EventIds $Width $Height $Rows $KnownUnitIds + } + if ($type -eq "any_unit_defeated") { + $unitIdsValue = @() + if (Has-Prop $Condition "unit_ids") { + $unitIdsValue = @($Condition.unit_ids) + } + if ($unitIdsValue.Count -le 0) { + Fail "Scenario $ScenarioId any_unit_defeated needs unit_ids." + } + foreach ($unitIdValue in $unitIdsValue) { + $unitId = [string]$unitIdValue + if ([string]::IsNullOrWhiteSpace($unitId)) { + Fail "Scenario $ScenarioId any_unit_defeated has empty unit id." + } + if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) { + Fail "Scenario $ScenarioId any_unit_defeated references unknown unit: $unitId" + } + } + } + if ($type -eq "unit_reaches_tile") { + if (-not (Has-Prop $Condition "pos")) { + Fail "Scenario $ScenarioId unit_reaches_tile condition is missing pos." + } + $cell = Resolve-Cell $Condition.pos "Scenario $ScenarioId unit_reaches_tile condition" + $x = [int]$cell.X + $y = [int]$cell.Y + if ($Width -ge 0 -and $Height -ge 0 -and ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height)) { + Fail "Scenario $ScenarioId unit_reaches_tile condition is outside map at [$x,$y]." + } + if ($null -ne $Rows) { + $terrainKey = Terrain-At $Rows $x $y + if ([int]$terrain.$terrainKey.move_cost.foot -ge 99 -and [int]$terrain.$terrainKey.move_cost.mounted -ge 99 -and [int]$terrain.$terrainKey.move_cost.archer -ge 99) { + Fail "Scenario $ScenarioId unit_reaches_tile condition targets impassable terrain at [$x,$y]." + } + } + $unitIdsValue = $null + if (Has-Prop $Condition "unit_ids") { + $unitIdsValue = $Condition.unit_ids + } + if ($null -ne $unitIdsValue -and -not ($unitIdsValue -is [System.Array])) { + Fail "Scenario $ScenarioId unit_reaches_tile unit_ids must be an array." + } + if ($null -ne $unitIdsValue) { + foreach ($unitIdValue in @($unitIdsValue)) { + $unitId = [string]$unitIdValue + if ([string]::IsNullOrWhiteSpace($unitId)) { + Fail "Scenario $ScenarioId unit_reaches_tile has empty unit id." + } + if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) { + Fail "Scenario $ScenarioId unit_reaches_tile references unknown unit: $unitId" + } + } + } + $officerIdsValue = $null + if (Has-Prop $Condition "officer_ids") { + $officerIdsValue = $Condition.officer_ids + if ($null -ne $officerIdsValue -and -not ($officerIdsValue -is [System.Array])) { + Fail "Scenario $ScenarioId unit_reaches_tile officer_ids must be an array." + } + foreach ($officerIdValue in @($officerIdsValue)) { + $officerId = [string]$officerIdValue + if ([string]::IsNullOrWhiteSpace($officerId) -or (-not $officerIds.Contains($officerId))) { + Fail "Scenario $ScenarioId unit_reaches_tile references unknown officer: $officerId" + } + } + } + if (($null -eq $unitIdsValue -or @($unitIdsValue).Count -le 0) -and ((-not (Has-Prop $Condition "officer_ids")) -or @($officerIdsValue).Count -le 0)) { + Fail "Scenario $ScenarioId unit_reaches_tile needs unit_ids or officer_ids." + } + $team = [string](Get-Prop $Condition "team" "") + if (-not [string]::IsNullOrWhiteSpace($team) -and $team -ne "player" -and $team -ne "enemy") { + Fail "Scenario $ScenarioId unit_reaches_tile has unknown team: $team" + } + } +} + +function Check-Unit-Reaches-Event-When($When, [string]$ScenarioId, [int]$Width, [int]$Height, $Rows, $KnownUnitIds) { + if (-not (Has-Prop $When "pos")) { + Fail "Scenario $ScenarioId unit_reaches_tile event is missing pos." + } + $cell = Resolve-Cell $When.pos "Scenario $ScenarioId unit_reaches_tile event" + $x = [int]$cell.X + $y = [int]$cell.Y + if ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height) { + Fail "Scenario $ScenarioId unit_reaches_tile event is outside map at [$x,$y]." + } + $terrainKey = Terrain-At $Rows $x $y + if ([int]$terrain.$terrainKey.move_cost.foot -ge 99 -and [int]$terrain.$terrainKey.move_cost.mounted -ge 99 -and [int]$terrain.$terrainKey.move_cost.archer -ge 99) { + Fail "Scenario $ScenarioId unit_reaches_tile event targets impassable terrain at [$x,$y]." + } + + $unitIdsValue = $null + if (Has-Prop $When "unit_ids") { + $unitIdsValue = $When.unit_ids + if ($null -ne $unitIdsValue -and -not ($unitIdsValue -is [System.Array])) { + Fail "Scenario $ScenarioId unit_reaches_tile event unit_ids must be an array." + } + foreach ($unitIdValue in @($unitIdsValue)) { + $unitId = [string]$unitIdValue + if ([string]::IsNullOrWhiteSpace($unitId)) { + Fail "Scenario $ScenarioId unit_reaches_tile event has empty unit id." + } + if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) { + Fail "Scenario $ScenarioId unit_reaches_tile event references unknown unit: $unitId" + } + } + } + + $officerIdsValue = $null + if (Has-Prop $When "officer_ids") { + $officerIdsValue = $When.officer_ids + if ($null -ne $officerIdsValue -and -not ($officerIdsValue -is [System.Array])) { + Fail "Scenario $ScenarioId unit_reaches_tile event officer_ids must be an array." + } + foreach ($officerIdValue in @($officerIdsValue)) { + $officerId = [string]$officerIdValue + if ([string]::IsNullOrWhiteSpace($officerId) -or (-not $officerIds.Contains($officerId))) { + Fail "Scenario $ScenarioId unit_reaches_tile event references unknown officer: $officerId" + } + } + } + + $team = [string](Get-Prop $When "team" "") + if (-not [string]::IsNullOrWhiteSpace($team) -and $team -ne "player" -and $team -ne "enemy") { + Fail "Scenario $ScenarioId unit_reaches_tile event has unknown team: $team" + } + $unitIdCount = 0 + if ($null -ne $unitIdsValue) { + $unitIdCount = @($unitIdsValue).Count + } + $officerIdCount = 0 + if ($null -ne $officerIdsValue) { + $officerIdCount = @($officerIdsValue).Count + } + if ([string]::IsNullOrWhiteSpace($team) -and $unitIdCount -le 0 -and $officerIdCount -le 0) { + Fail "Scenario $ScenarioId unit_reaches_tile event needs team, unit_ids, or officer_ids." + } +} + +function Resolve-Class-Id($Deployment, [string]$ScenarioId) { + $classId = [string](Get-Prop $Deployment "class_id" "") + if (-not [string]::IsNullOrWhiteSpace($classId)) { + return $classId + } + + $officerId = [string](Get-Prop $Deployment "officer_id" "") + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Scenario $ScenarioId deployment $($Deployment.unit_id) has no class_id or officer_id." + } + if (-not $officerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId deployment $($Deployment.unit_id) references unknown officer: $officerId" + } + return [string]$officers.$officerId.class_id +} + +function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$Height, $Rows, $SeenIds) { + if (-not (Has-Prop $Deployment "unit_id")) { + Fail "Scenario $ScenarioId has a deployment without unit_id." + } + + $unitId = [string]$Deployment.unit_id + if ($SeenIds.Contains($unitId)) { + Fail "Scenario $ScenarioId has duplicate deployment id: $unitId" + } + $SeenIds.Add($unitId) | Out-Null + + $team = [string](Get-Prop $Deployment "team" "enemy") + $officerId = [string](Get-Prop $Deployment "officer_id" "") + if (-not [string]::IsNullOrWhiteSpace($officerId) -and (-not $officerIds.Contains($officerId))) { + Fail "Scenario $ScenarioId deployment $unitId references unknown officer: $officerId" + } + if (Has-Prop $Deployment "requires_joined") { + if (-not ((Get-Prop $Deployment "requires_joined" $false) -is [bool])) { + Fail "Scenario $ScenarioId deployment $unitId requires_joined must be boolean." + } + if ([bool](Get-Prop $Deployment "requires_joined" $false)) { + if ($team -ne "player") { + Fail "Scenario $ScenarioId deployment $unitId requires_joined is only valid for player deployments." + } + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Scenario $ScenarioId deployment $unitId requires_joined needs officer_id." + } + } + } + if ((Has-Prop $Deployment "controllable") -and (-not ((Get-Prop $Deployment "controllable" $true) -is [bool]))) { + Fail "Scenario $ScenarioId deployment $unitId controllable must be boolean." + } + if ((Has-Prop $Deployment "persist_progression") -and (-not ((Get-Prop $Deployment "persist_progression" $true) -is [bool]))) { + Fail "Scenario $ScenarioId deployment $unitId persist_progression must be boolean." + } + if (Has-Prop $Deployment "ai_target_priority") { + $priorityValue = Get-Prop $Deployment "ai_target_priority" 0 + if (-not ($priorityValue -is [int] -or $priorityValue -is [long])) { + Fail "Scenario $ScenarioId deployment $unitId ai_target_priority must be an integer." + } + $priority = [int]$priorityValue + if ($priority -lt 0 -or $priority -gt 20) { + Fail "Scenario $ScenarioId deployment $unitId ai_target_priority must be between 0 and 20." + } + } + + $classId = Resolve-Class-Id $Deployment $ScenarioId + if (-not $classIds.Contains($classId)) { + Fail "Scenario $ScenarioId deployment $unitId references unknown class: $classId" + } + Check-Skill-Refs (Get-Prop $Deployment "skills" @()) "Scenario $ScenarioId deployment $unitId" + + $pos = Get-Prop $Deployment "pos" @() + if ($pos.Count -lt 2) { + Fail "Scenario $ScenarioId deployment $unitId has malformed pos." + } + $x = [int]$pos[0] + $y = [int]$pos[1] + if ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height) { + Fail "Scenario $ScenarioId deployment $unitId is outside map at [$x,$y]." + } + + $terrainKey = Terrain-At $Rows $x $y + $classDef = $classes.$classId + $moveType = [string](Get-Prop $Deployment "move_type" (Get-Prop $classDef "move_type" "foot")) + $moveCost = $terrain.$terrainKey.move_cost.$moveType + if ($null -eq $moveCost) { + $moveCost = $terrain.$terrainKey.move_cost.foot + } + if ([int]$moveCost -ge 99) { + Fail "Scenario $ScenarioId deployment $unitId starts on impassable terrain." + } +} + +function Add-Deployment-Unit-Id($Deployment, $Result) { + $unitId = [string](Get-Prop $Deployment "unit_id" (Get-Prop $Deployment "id" "")) + if (-not [string]::IsNullOrWhiteSpace($unitId)) { + $Result.Add($unitId) | Out-Null + } +} + +function Collect-Scenario-Unit-Ids($ScenarioData) { + $result = New-Object System.Collections.Generic.HashSet[string] + foreach ($deployment in (Get-Prop $ScenarioData "deployments" @())) { + Add-Deployment-Unit-Id $deployment $result + } + foreach ($event in (Get-Prop $ScenarioData "events" @())) { + foreach ($action in (Get-Prop $event "actions" @())) { + $actionType = [string](Get-Prop $action "type" "") + if ($actionType -eq "spawn_deployment") { + Add-Deployment-Unit-Id $action.deployment $result + } + if ($actionType -eq "spawn_deployments") { + foreach ($deployment in (Get-Prop $action "deployments" @())) { + Add-Deployment-Unit-Id $deployment $result + } + } + } + } + return $result +} + +function Find-Item-Def([string]$ItemId) { + $prop = $items.PSObject.Properties | Where-Object { $_.Name -eq $ItemId } | Select-Object -First 1 + if ($null -eq $prop) { + return $null + } + return $prop.Value +} + +function Check-Class-Equipment-Slots() { + $weaponTypes = @() + $armorTypes = @() + $accessoryTypes = @() + foreach ($itemProp in $items.PSObject.Properties) { + $item = $itemProp.Value + $kind = [string](Get-Prop $item "kind" "") + if ($kind -eq "weapon") { + $weaponTypes += [string](Get-Prop $item "weapon_type" "") + } elseif ($kind -eq "armor") { + $armorTypes += [string](Get-Prop $item "armor_type" "") + } elseif ($kind -eq "accessory") { + $accessoryTypes += [string](Get-Prop $item "accessory_type" "accessory") + } + } + + foreach ($classProp in $classes.PSObject.Properties) { + $classId = $classProp.Name + $slots = Get-Prop $classProp.Value "equipment_slots" $null + if ($null -eq $slots) { + continue + } + foreach ($weaponType in (Get-Prop $slots "weapon" @())) { + if (-not $weaponTypes.Contains([string]$weaponType)) { + Fail "Class $classId allows unknown weapon type: $weaponType" + } + } + foreach ($armorType in (Get-Prop $slots "armor" @())) { + if (-not $armorTypes.Contains([string]$armorType)) { + Fail "Class $classId allows unknown armor type: $armorType" + } + } + foreach ($accessoryType in (Get-Prop $slots "accessory" @())) { + if (-not $accessoryTypes.Contains([string]$accessoryType)) { + Fail "Class $classId allows unknown accessory type: $accessoryType" + } + } + } +} + +function Check-Class-Promotions() { + foreach ($classProp in $classes.PSObject.Properties) { + $classId = [string]$classProp.Name + $promotion = Get-Prop $classProp.Value "promotion" $null + if ($null -eq $promotion) { + continue + } + if ($promotion -is [string] -or $promotion -is [System.Array]) { + Fail "Class $classId promotion must be an object." + } + $level = [int](Get-Prop $promotion "level" 0) + if ($level -le 1) { + Fail "Class $classId promotion has invalid level." + } + $targetClassId = [string](Get-Prop $promotion "to" "") + if ([string]::IsNullOrWhiteSpace($targetClassId) -or (-not $classIds.Contains($targetClassId))) { + Fail "Class $classId promotes to unknown class: $targetClassId" + } + if ($targetClassId -eq $classId) { + Fail "Class $classId cannot promote to itself." + } + $targetClass = Get-Prop $classes $targetClassId $null + $currentTier = [int](Get-Prop $classProp.Value "tier" 1) + $targetTier = [int](Get-Prop $targetClass "tier" $currentTier) + if ($targetTier -le $currentTier) { + Fail "Class $classId promotion target must have higher tier: $targetClassId" + } + + $sourceSlots = Get-Prop $classProp.Value "equipment_slots" $null + $targetSlots = Get-Prop $targetClass "equipment_slots" $null + foreach ($slot in @("weapon", "armor", "accessory")) { + $sourceAllowed = @() + if ($null -ne $sourceSlots) { + $sourceAllowed = @(Get-Prop $sourceSlots $slot @()) + } + $targetAllowed = @() + if ($null -ne $targetSlots) { + $targetAllowed = @(Get-Prop $targetSlots $slot @()) + } + foreach ($allowed in $sourceAllowed) { + if (-not $targetAllowed.Contains([string]$allowed)) { + Fail "Class $classId promotion target $targetClassId must keep $slot type: $allowed" + } + } + } + } +} + +function Check-Officer-Equipment([string]$OfficerId, $Officer) { + $classId = [string](Get-Prop $Officer "class_id" "") + if ([string]::IsNullOrWhiteSpace($classId) -or (-not $classIds.Contains($classId))) { + Fail "Officer $OfficerId references unknown class: $classId" + } + + $classDef = $classes.$classId + $slots = Get-Prop $classDef "equipment_slots" $null + $equipment = Get-Prop $Officer "equipment" $null + if ($null -eq $equipment) { + return + } + + foreach ($slot in $equipment.PSObject.Properties) { + $slotName = [string]$slot.Name + $itemId = [string]$slot.Value + if ([string]::IsNullOrWhiteSpace($itemId)) { + continue + } + + $item = Find-Item-Def $itemId + if ($null -eq $item) { + Fail "Officer $OfficerId references unknown equipment item: $itemId" + } + + $kind = [string](Get-Prop $item "kind" "") + if ($slotName -eq "weapon") { + if ($kind -ne "weapon") { + Fail "Officer $OfficerId has non-weapon item $itemId in weapon slot." + } + $weaponType = [string](Get-Prop $item "weapon_type" "") + if ($null -ne $slots -and (-not @((Get-Prop $slots "weapon" @())).Contains($weaponType))) { + Fail "Officer $OfficerId cannot equip weapon type $weaponType." + } + } elseif ($slotName -eq "armor") { + if ($kind -ne "armor") { + Fail "Officer $OfficerId has non-armor item $itemId in armor slot." + } + $armorType = [string](Get-Prop $item "armor_type" "") + if ($null -ne $slots -and (-not @((Get-Prop $slots "armor" @())).Contains($armorType))) { + Fail "Officer $OfficerId cannot equip armor type $armorType." + } + } elseif ($slotName -eq "accessory") { + if ($kind -ne "accessory") { + Fail "Officer $OfficerId has non-accessory item $itemId in accessory slot." + } + $accessoryType = [string](Get-Prop $item "accessory_type" "accessory") + if ($null -ne $slots -and (-not @((Get-Prop $slots "accessory" @())).Contains($accessoryType))) { + Fail "Officer $OfficerId cannot equip accessory type $accessoryType." + } + } + } +} + +function Check-Item-Effects() { + foreach ($itemProp in $items.PSObject.Properties) { + $itemId = $itemProp.Name + $item = $itemProp.Value + $kind = [string](Get-Prop $item "kind" "") + if (-not $validItemKinds.Contains($kind)) { + Fail "Item $itemId has unknown kind: $kind" + } + if ([int](Get-Prop $item "price" 0) -lt 0) { + Fail "Item $itemId has negative price." + } + if ($kind -eq "weapon") { + if (-not (Has-Prop $item "weapon_type")) { + Fail "Weapon item $itemId is missing weapon_type." + } + Check-Skill-Range (Get-Prop $item "range" 1) "Item $itemId" + } + if ($kind -eq "armor" -and (-not (Has-Prop $item "armor_type"))) { + Fail "Armor item $itemId is missing armor_type." + } + if ($kind -eq "accessory") { + $accessoryType = [string](Get-Prop $item "accessory_type" "") + if ([string]::IsNullOrWhiteSpace($accessoryType)) { + Fail "Accessory item $itemId is missing accessory_type." + } + } + if ($kind -eq "consumable") { + if ([int](Get-Prop $item "uses" 0) -le 0) { + Fail "Consumable item $itemId must have positive uses." + } + $target = [string](Get-Prop $item "target" "ally") + if (-not $validSkillTargets.Contains($target)) { + Fail "Consumable item $itemId has unknown target: $target" + } + Check-Skill-Range (Get-Prop $item "range" @(0, 1)) "Item $itemId" + } + $bonuses = Get-Prop $item "bonuses" $null + if ($null -ne $bonuses) { + foreach ($bonus in $bonuses.PSObject.Properties) { + if (-not $validBonusStats.Contains([string]$bonus.Name)) { + Fail "Item $itemId has unsupported bonus stat: $($bonus.Name)" + } + } + } + foreach ($effect in (Get-Prop $item "effects" @())) { + if (-not (Has-Prop $effect "type")) { + Fail "Item $itemId has malformed effect." + } + if (-not $validEffects.Contains([string]$effect.type)) { + Fail "Item $itemId has unknown effect: $($effect.type)" + } + if ([int](Get-Prop $effect "amount" 0) -le 0) { + Fail "Item $itemId effect $($effect.type) must have positive amount." + } + } + } +} + +function Check-Terrain-Definitions() { + foreach ($terrainProp in $terrain.PSObject.Properties) { + $terrainKey = [string]$terrainProp.Name + $terrainDef = $terrainProp.Value + $defense = [int](Get-Prop $terrainDef "defense" 0) + if ($defense -lt 0) { + Fail "Terrain $terrainKey has negative defense." + } + $avoid = [int](Get-Prop $terrainDef "avoid" 0) + if ($avoid -lt 0 -or $avoid -gt 100) { + Fail "Terrain $terrainKey avoid must be between 0 and 100." + } + } +} + +function Check-Skill-Range($Value, [string]$Context) { + if ($Value -is [System.Array]) { + if ($Value.Count -lt 1 -or $Value.Count -gt 2) { + Fail "$Context has malformed range." + } + $minRange = [int]$Value[0] + $maxRange = [int]$Value[$Value.Count - 1] + if ($minRange -lt 0 -or $maxRange -lt $minRange) { + Fail "$Context has invalid range." + } + return + } + $range = [int]$Value + if ($range -lt 0) { + Fail "$Context has invalid range." + } +} + +function Check-Skill-Definitions() { + foreach ($skillProp in $skills.PSObject.Properties) { + $skillId = $skillProp.Name + $skill = $skillProp.Value + $kind = [string](Get-Prop $skill "kind" "") + if (-not $validSkillKinds.Contains($kind)) { + Fail "Skill $skillId has unknown kind: $kind" + } + $target = [string](Get-Prop $skill "target" "") + if (-not $validSkillTargets.Contains($target)) { + Fail "Skill $skillId has unknown target: $target" + } + Check-Skill-Range (Get-Prop $skill "range" 1) "Skill $skillId" + if ([int](Get-Prop $skill "mp_cost" 0) -lt 0) { + Fail "Skill $skillId has negative mp_cost." + } + if ([int](Get-Prop $skill "power" 0) -lt 0) { + Fail "Skill $skillId has negative power." + } + $stat = [string](Get-Prop $skill "stat" "int") + if (-not $validSkillStats.Contains($stat)) { + Fail "Skill $skillId references unsupported stat: $stat" + } + } +} + +function Check-Skill-Refs($Value, [string]$Context) { + if ($null -eq $Value) { + return + } + if ($Value -is [pscustomobject]) { + Fail "$Context skills must be an array." + } + foreach ($skillId in @($Value)) { + if (-not $skillIds.Contains([string]$skillId)) { + Fail "$Context references unknown skill: $skillId" + } + } +} + +function Resolve-Shop-Item-Id($Entry, [string]$Context) { + if ($Entry -is [string]) { + return [string]$Entry + } + if (Has-Prop $Entry "id") { + return [string]$Entry.id + } + Fail "$Context has a malformed shop item entry." +} + +function Check-Shop($Shop, [string]$ScenarioId) { + if ($null -eq $Shop) { + return + } + if (($Shop -is [System.Array]) -or ($Shop -is [string])) { + Fail "Scenario $ScenarioId shop must be an object." + } + if (-not (Has-Prop $Shop "items")) { + Fail "Scenario $ScenarioId shop is missing items." + } + + $shopItemsValue = $Shop.items + if (-not ($shopItemsValue -is [System.Array])) { + Fail "Scenario $ScenarioId shop items must be an array." + } + $shopItems = @($shopItemsValue) + + $seenShopItems = New-Object System.Collections.Generic.HashSet[string] + foreach ($entry in $shopItems) { + $itemId = Resolve-Shop-Item-Id $entry "Scenario $ScenarioId shop" + if ([string]::IsNullOrWhiteSpace($itemId)) { + Fail "Scenario $ScenarioId shop has an empty item id." + } + if (-not $seenShopItems.Add($itemId)) { + Fail "Scenario $ScenarioId shop has duplicate item: $itemId" + } + if (-not $itemIds.Contains($itemId)) { + Fail "Scenario $ScenarioId shop references unknown item: $itemId" + } + $item = Find-Item-Def $itemId + if ([int](Get-Prop $item "price" 0) -le 0) { + Fail "Scenario $ScenarioId shop item $itemId has no positive price." + } + } + + $conditionalItems = Get-Prop $Shop "conditional_items" @() + foreach ($block in @($conditionalItems)) { + if ($block -is [string] -or $block -is [System.Array]) { + Fail "Scenario $ScenarioId shop has malformed conditional_items entry." + } + $blockFlags = Get-Prop $block "campaign_flags" $null + if ($null -eq $blockFlags -or @($blockFlags.PSObject.Properties).Count -le 0) { + Fail "Scenario $ScenarioId shop conditional_items entry must have campaign_flags." + } + Check-Required-Flags $blockFlags $ScenarioId "shop conditional_items" + $blockItems = Get-Prop $block "items" $null + if ($null -eq $blockItems) { + Fail "Scenario $ScenarioId shop conditional_items entry must have item array." + } + $seenConditionalItems = New-Object System.Collections.Generic.HashSet[string] + foreach ($baseItem in $shopItems) { + $seenConditionalItems.Add((Resolve-Shop-Item-Id $baseItem "Scenario $ScenarioId shop")) | Out-Null + } + foreach ($entry in @($blockItems)) { + $itemId = Resolve-Shop-Item-Id $entry "Scenario $ScenarioId shop conditional_items" + if ([string]::IsNullOrWhiteSpace($itemId)) { + Fail "Scenario $ScenarioId shop conditional_items has an empty item id." + } + if (-not $seenConditionalItems.Add($itemId)) { + Fail "Scenario $ScenarioId shop conditional_items duplicates item already in that branch: $itemId" + } + if (-not $itemIds.Contains($itemId)) { + Fail "Scenario $ScenarioId shop conditional_items references unknown item: $itemId" + } + $item = Find-Item-Def $itemId + if ([int](Get-Prop $item "price" 0) -le 0) { + Fail "Scenario $ScenarioId shop conditional item $itemId has no positive price." + } + } + } +} + +function Resolve-Cell($Cell, [string]$Context) { + if (-not ($Cell -is [System.Array]) -or $Cell.Count -lt 2) { + Fail "$Context has a malformed cell." + } + return @{ X = [int]$Cell[0]; Y = [int]$Cell[1] } +} + +function Cell-Key([int]$X, [int]$Y) { + return "$X,$Y" +} + +function Check-Formation($Formation, [string]$ScenarioId, [int]$Width, [int]$Height, $Rows, $Deployments) { + if ($null -eq $Formation) { + return + } + if (($Formation -is [System.Array]) -or ($Formation -is [string])) { + Fail "Scenario $ScenarioId formation must be an object." + } + if (-not (Has-Prop $Formation "cells")) { + Fail "Scenario $ScenarioId formation is missing cells." + } + $cellsValue = $Formation.cells + if (-not ($cellsValue -is [System.Array])) { + Fail "Scenario $ScenarioId formation cells must be an array." + } + + $formationKeys = New-Object System.Collections.Generic.HashSet[string] + foreach ($cellEntry in @($cellsValue)) { + $cell = Resolve-Cell $cellEntry "Scenario $ScenarioId formation" + $x = [int]$cell.X + $y = [int]$cell.Y + if ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height) { + Fail "Scenario $ScenarioId formation cell is outside map at [$x,$y]." + } + if (-not $formationKeys.Add((Cell-Key $x $y))) { + Fail "Scenario $ScenarioId formation has duplicate cell: [$x,$y]" + } + $terrainKey = Terrain-At $Rows $x $y + if ([int]$terrain.$terrainKey.move_cost.foot -ge 99 -and [int]$terrain.$terrainKey.move_cost.mounted -ge 99 -and [int]$terrain.$terrainKey.move_cost.archer -ge 99) { + Fail "Scenario $ScenarioId formation cell [$x,$y] is impassable for normal units." + } + } + + $playerCount = 0 + foreach ($deployment in $Deployments) { + $team = [string](Get-Prop $deployment "team" "enemy") + $pos = Get-Prop $deployment "pos" @() + if ($pos.Count -lt 2) { + continue + } + $x = [int]$pos[0] + $y = [int]$pos[1] + $key = Cell-Key $x $y + if ($team -eq "player") { + $playerCount += 1 + if (-not $formationKeys.Contains($key)) { + Fail "Scenario $ScenarioId player deployment $($deployment.unit_id) starts outside formation cells." + } + } elseif ($formationKeys.Contains($key)) { + Fail "Scenario $ScenarioId formation overlaps enemy deployment at [$x,$y]." + } + } + if ($formationKeys.Count -lt $playerCount) { + Fail "Scenario $ScenarioId formation has fewer cells than player deployments." + } +} + +function Check-Roster($Roster, [string]$ScenarioId, $Deployments) { + if ($null -eq $Roster) { + return + } + if (($Roster -is [System.Array]) -or ($Roster -is [string])) { + Fail "Scenario $ScenarioId roster must be an object." + } + + $playerOfficerIds = New-Object System.Collections.Generic.HashSet[string] + $playerUnitIds = New-Object System.Collections.Generic.HashSet[string] + $officerToUnitIds = @{} + $playerCount = 0 + foreach ($deployment in $Deployments) { + if ([string](Get-Prop $deployment "team" "enemy") -ne "player") { + continue + } + $playerCount += 1 + $unitId = [string](Get-Prop $deployment "unit_id" (Get-Prop $deployment "id" "")) + if (-not [string]::IsNullOrWhiteSpace($unitId)) { + $playerUnitIds.Add($unitId) | Out-Null + } + $officerId = [string](Get-Prop $deployment "officer_id" "") + if (-not [string]::IsNullOrWhiteSpace($officerId)) { + $playerOfficerIds.Add($officerId) | Out-Null + if (-not $officerToUnitIds.ContainsKey($officerId)) { + $officerToUnitIds[$officerId] = @() + } + $officerToUnitIds[$officerId] += $unitId + } + } + if ($playerCount -le 0) { + Fail "Scenario $ScenarioId roster has no player deployments." + } + + $maxUnits = [int](Get-Prop $Roster "max_units" $playerCount) + if ($maxUnits -le 0 -or $maxUnits -gt $playerCount) { + Fail "Scenario $ScenarioId roster max_units must be between 1 and player deployment count." + } + + $requiredValue = @() + if (Has-Prop $Roster "required_officers") { + $requiredValue = $Roster.required_officers + } + if (-not ($requiredValue -is [System.Array])) { + Fail "Scenario $ScenarioId roster required_officers must be an array." + } + $required = New-Object System.Collections.Generic.HashSet[string] + $requiredDeploymentUnitIds = New-Object System.Collections.Generic.HashSet[string] + foreach ($officerIdValue in @($requiredValue)) { + $officerId = [string]$officerIdValue + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Scenario $ScenarioId roster has an empty required officer." + } + if (-not $required.Add($officerId)) { + Fail "Scenario $ScenarioId roster has duplicate required officer: $officerId" + } + if (-not $playerOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId roster requires officer not in player deployments: $officerId" + } + foreach ($unitId in @($officerToUnitIds[$officerId])) { + if (-not [string]::IsNullOrWhiteSpace([string]$unitId)) { + $requiredDeploymentUnitIds.Add([string]$unitId) | Out-Null + } + } + } + + $requiredUnitsValue = @() + if (Has-Prop $Roster "required_units") { + $requiredUnitsValue = $Roster.required_units + } + if (-not ($requiredUnitsValue -is [System.Array])) { + Fail "Scenario $ScenarioId roster required_units must be an array." + } + $requiredUnits = New-Object System.Collections.Generic.HashSet[string] + foreach ($unitIdValue in @($requiredUnitsValue)) { + $unitId = [string]$unitIdValue + if ([string]::IsNullOrWhiteSpace($unitId)) { + Fail "Scenario $ScenarioId roster has an empty required unit." + } + if (-not $requiredUnits.Add($unitId)) { + Fail "Scenario $ScenarioId roster has duplicate required unit: $unitId" + } + if (-not $playerUnitIds.Contains($unitId)) { + Fail "Scenario $ScenarioId roster requires unit not in player deployments: $unitId" + } + $requiredDeploymentUnitIds.Add($unitId) | Out-Null + } + if ($requiredDeploymentUnitIds.Count -gt $maxUnits) { + Fail "Scenario $ScenarioId roster requires more units than max_units." + } +} + +function Check-Joined-Deployment-Requirements($Deployments, [string]$ScenarioId) { + foreach ($deployment in $Deployments) { + if ([string](Get-Prop $deployment "team" "enemy") -ne "player") { + continue + } + if (-not [bool](Get-Prop $deployment "requires_joined" $false)) { + continue + } + $officerId = [string](Get-Prop $deployment "officer_id" "") + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Scenario $ScenarioId deployment $($deployment.unit_id) requires joined officer but has no officer_id." + } + if (-not $joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId deployment $($deployment.unit_id) requires officer before they join: $officerId" + } + } +} + +function Check-Required-Officers-Joined($Roster, $Conditions, [string]$ScenarioId) { + if ($null -ne $Roster -and (Has-Prop $Roster "required_officers")) { + foreach ($officerIdValue in @($Roster.required_officers)) { + $officerId = [string]$officerIdValue + if (-not $joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId roster requires officer before they join: $officerId" + } + } + } + + $conditionRequired = New-Object System.Collections.Generic.HashSet[string] + if ($null -ne $Conditions -and (Has-Prop $Conditions "defeat")) { + Collect-Required-Player-Officers $Conditions.defeat $conditionRequired + } + foreach ($officerId in $conditionRequired) { + if (-not $joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId defeat condition references officer before they join: $officerId" + } + } +} + +function Collect-Required-Player-Officers($Condition, $Result) { + if ($Condition -is [System.Array]) { + foreach ($entry in $Condition) { + Collect-Required-Player-Officers $entry $Result + } + return + } + if (-not (Has-Prop $Condition "type")) { + return + } + $type = [string]$Condition.type + if ($type -eq "any_officer_defeated" -and [string](Get-Prop $Condition "team" "") -eq "player") { + foreach ($officerId in (Get-Prop $Condition "officer_ids" @())) { + $Result.Add([string]$officerId) | Out-Null + } + } + if (($type -eq "all" -or $type -eq "any") -and (Has-Prop $Condition "conditions")) { + Collect-Required-Player-Officers $Condition.conditions $Result + } +} + +function Check-Roster-Condition-Consistency($Roster, $Conditions, [string]$ScenarioId) { + $conditionRequired = New-Object System.Collections.Generic.HashSet[string] + if ($null -ne $Conditions -and (Has-Prop $Conditions "defeat")) { + Collect-Required-Player-Officers $Conditions.defeat $conditionRequired + } + if ($conditionRequired.Count -le 0) { + return + } + + $rosterRequired = New-Object System.Collections.Generic.HashSet[string] + if ($null -ne $Roster -and (Has-Prop $Roster "required_officers")) { + foreach ($officerIdValue in @($Roster.required_officers)) { + $rosterRequired.Add([string]$officerIdValue) | Out-Null + } + } + foreach ($officerId in $conditionRequired) { + if (-not $rosterRequired.Contains($officerId)) { + Fail "Scenario $ScenarioId defeat condition officer must be required in roster: $officerId" + } + } +} + +function Check-Officer-Transition-List($Value, [string]$ScenarioId, [string]$Context) { + $result = New-Object System.Collections.Generic.HashSet[string] + if ($null -eq $Value) { + return ,$result + } + foreach ($officerIdValue in @($Value)) { + $officerId = [string]$officerIdValue + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Scenario $ScenarioId $Context has empty officer id." + } + if (-not $officerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId $Context references unknown officer: $officerId" + } + if (-not $result.Add($officerId)) { + Fail "Scenario $ScenarioId $Context has duplicate officer: $officerId" + } + } + return ,$result +} + +function Check-Post-Battle-Choices($Choices, [string]$ScenarioId) { + if ($null -eq $Choices) { + return + } + if (-not ($Choices -is [System.Array])) { + Fail "Scenario $ScenarioId post_battle_choices must be an array." + } + + $seenChoices = New-Object System.Collections.Generic.HashSet[string] + foreach ($choice in @($Choices)) { + if ($choice -is [string] -or $choice -is [System.Array]) { + Fail "Scenario $ScenarioId has malformed post-battle choice." + } + $choiceId = [string](Get-Prop $choice "id" "") + if ([string]::IsNullOrWhiteSpace($choiceId)) { + Fail "Scenario $ScenarioId post-battle choice has empty id." + } + if (-not ($choiceId -match "^[a-z0-9_]+$")) { + Fail "Scenario $ScenarioId post-battle choice has unstable id: $choiceId" + } + if (-not $seenChoices.Add($choiceId)) { + Fail "Scenario $ScenarioId has duplicate post-battle choice: $choiceId" + } + if ([string]::IsNullOrWhiteSpace([string](Get-Prop $choice "label" ""))) { + Fail "Scenario $ScenarioId post-battle choice $choiceId has empty label." + } + if ([int](Get-Prop $choice "gold" 0) -lt 0) { + Fail "Scenario $ScenarioId post-battle choice $choiceId has negative gold." + } + $choiceItems = @() + if (Has-Prop $choice "items") { + $choiceItems = @($choice.items) + } + foreach ($itemId in @($choiceItems)) { + if (-not $itemIds.Contains([string]$itemId)) { + Fail "Scenario $ScenarioId post-battle choice $choiceId grants unknown item: $itemId" + } + } + $choiceJoins = Check-Officer-Transition-List (Get-Prop $choice "join_officers" $null) $ScenarioId "post-battle choice $choiceId join_officers" + $choiceLeaves = Check-Officer-Transition-List (Get-Prop $choice "leave_officers" $null) $ScenarioId "post-battle choice $choiceId leave_officers" + foreach ($officerId in $choiceJoins) { + if ($choiceLeaves.Contains($officerId)) { + Fail "Scenario $ScenarioId post-battle choice $choiceId both joins and leaves officer: $officerId" + } + if ($joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId post-battle choice $choiceId joins already joined officer: $officerId" + } + } + foreach ($officerId in $choiceLeaves) { + if (-not $joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId post-battle choice $choiceId leaves officer before they join: $officerId" + } + } + $setFlags = Get-Prop $choice "set_flags" $null + if ($null -eq $setFlags) { + Fail "Scenario $ScenarioId post-battle choice $choiceId has no set_flags." + } + if ($setFlags -is [string] -or $setFlags -is [System.Array]) { + Fail "Scenario $ScenarioId post-battle choice $choiceId set_flags must be an object." + } + foreach ($flag in $setFlags.PSObject.Properties) { + if ([string]::IsNullOrWhiteSpace([string]$flag.Name)) { + Fail "Scenario $ScenarioId post-battle choice $choiceId has empty flag name." + } + if (-not ([string]$flag.Name -match "^[a-z0-9_]+$")) { + Fail "Scenario $ScenarioId post-battle choice $choiceId has unstable flag name: $($flag.Name)" + } + if ($null -eq $flag.Value -or $flag.Value -is [System.Array] -or $flag.Value -is [pscustomobject]) { + Fail "Scenario $ScenarioId post-battle choice $choiceId flag $($flag.Name) must be scalar." + } + $flagName = [string]$flag.Name + $flagKind = Get-Flag-Value-Kind $flag.Value + if ($knownFlagValueKinds.ContainsKey($flagName) -and $knownFlagValueKinds[$flagName] -ne $flagKind) { + Fail "Scenario $ScenarioId post-battle choice $choiceId changes flag type: $flagName" + } + $knownFlagIds.Add($flagName) | Out-Null + $knownFlagValueKinds[$flagName] = $flagKind + } + } +} + +function Check-Required-Flags($RequiredFlags, [string]$ScenarioId, [string]$Context) { + if ($null -eq $RequiredFlags) { + return + } + if ($RequiredFlags -is [string] -or $RequiredFlags -is [System.Array]) { + Fail "Scenario $ScenarioId $Context campaign_flags must be an object." + } + foreach ($flag in $RequiredFlags.PSObject.Properties) { + $flagName = [string]$flag.Name + if ([string]::IsNullOrWhiteSpace($flagName)) { + Fail "Scenario $ScenarioId $Context has empty required flag name." + } + if (-not ($flagName -match "^[a-z0-9_]+$")) { + Fail "Scenario $ScenarioId $Context has unstable required flag name: $flagName" + } + if (-not $knownFlagIds.Contains($flagName)) { + Fail "Scenario $ScenarioId $Context requires unknown campaign flag: $flagName" + } + if ($null -eq $flag.Value -or $flag.Value -is [System.Array] -or $flag.Value -is [pscustomobject]) { + Fail "Scenario $ScenarioId $Context required flag $flagName must be scalar." + } + $flagKind = Get-Flag-Value-Kind $flag.Value + if ($knownFlagValueKinds.ContainsKey($flagName) -and $knownFlagValueKinds[$flagName] -ne $flagKind) { + Fail "Scenario $ScenarioId $Context required flag $flagName has type $flagKind but was defined as $($knownFlagValueKinds[$flagName])." + } + } +} + +function Check-Briefing($Briefing, [string]$ScenarioId) { + if ($null -eq $Briefing) { + return + } + if ($Briefing -is [string] -or $Briefing -is [System.Array]) { + Fail "Scenario $ScenarioId briefing must be an object." + } + $lines = Get-Prop $Briefing "lines" @() + foreach ($line in @($lines)) { + if ([string]::IsNullOrWhiteSpace([string]$line)) { + Fail "Scenario $ScenarioId briefing has an empty line." + } + } + + $conditionalLines = Get-Prop $Briefing "conditional_lines" @() + foreach ($block in @($conditionalLines)) { + if ($block -is [string] -or $block -is [System.Array]) { + Fail "Scenario $ScenarioId briefing has malformed conditional_lines entry." + } + $blockFlags = Get-Prop $block "campaign_flags" $null + if ($null -eq $blockFlags -or @($blockFlags.PSObject.Properties).Count -le 0) { + Fail "Scenario $ScenarioId briefing conditional_lines entry must have campaign_flags." + } + Check-Required-Flags $blockFlags $ScenarioId "briefing conditional_lines" + $blockLines = Get-Prop $block "lines" $null + if ($null -eq $blockLines) { + Fail "Scenario $ScenarioId briefing conditional_lines entry must have line array." + } + foreach ($line in @($blockLines)) { + if ([string]::IsNullOrWhiteSpace([string]$line)) { + Fail "Scenario $ScenarioId briefing conditional_lines has an empty line." + } + } + } +} + +function Check-Dialogue-Line($Line, [string]$Context) { + if ($Line -is [string]) { + if ([string]::IsNullOrWhiteSpace([string]$Line)) { + Fail "$Context has an empty dialogue line." + } + return + } + if ($Line -is [System.Array] -or -not (Has-Prop $Line "text")) { + Fail "$Context has malformed dialogue line." + } + if ([string]::IsNullOrWhiteSpace([string](Get-Prop $Line "text" ""))) { + Fail "$Context has an empty dialogue text." + } +} + +function Check-Post-Battle-Dialogue($Dialogue, [string]$ScenarioId) { + if ($null -eq $Dialogue) { + return + } + $lines = @($Dialogue) + if ($lines.Count -le 0) { + Fail "Scenario $ScenarioId post_battle_dialogue must not be empty." + } + foreach ($line in $lines) { + Check-Dialogue-Line $line "Scenario $ScenarioId post_battle_dialogue" + } +} + +function Check-Join-Officer-Rewards($Rewards, [string]$ScenarioId) { + if ($null -eq $Rewards) { + return + } + $joinOfficers = Check-Officer-Transition-List (Get-Prop $Rewards "join_officers" $null) $ScenarioId "rewards join_officers" + $leaveOfficers = Check-Officer-Transition-List (Get-Prop $Rewards "leave_officers" $null) $ScenarioId "rewards leave_officers" + foreach ($officerId in $joinOfficers) { + if ($leaveOfficers.Contains($officerId)) { + Fail "Scenario $ScenarioId rewards both joins and leaves officer: $officerId" + } + if ($joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId rewards joins already joined officer: $officerId" + } + } + foreach ($officerId in $leaveOfficers) { + if (-not $joinedOfficerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId rewards leaves officer before they join: $officerId" + } + } + foreach ($officerId in $joinOfficers) { + $joinedOfficerIds.Add($officerId) | Out-Null + } + foreach ($officerId in $leaveOfficers) { + $joinedOfficerIds.Remove($officerId) | Out-Null + } +} + +Check-Raw-Skills-Arrays +Check-Item-Effects +Check-Terrain-Definitions +Check-Skill-Definitions +Check-Class-Equipment-Slots +Check-Class-Promotions + +foreach ($classProp in $classes.PSObject.Properties) { + Check-Skill-Refs (Get-Prop $classProp.Value "skills" @()) "Class $($classProp.Name)" +} + +foreach ($officerProp in $officers.PSObject.Properties) { + Check-Skill-Refs (Get-Prop $officerProp.Value "skills" @()) "Officer $($officerProp.Name)" + Check-Officer-Equipment $officerProp.Name $officerProp.Value +} + +foreach ($scenario in $campaign.scenarios) { + $scenarioPath = ([string]$scenario.path).Replace("res://", "") + $scenarioData = Read-Json $scenarioPath + $scenarioId = [string]$scenarioData.id + if ($scenarioId -ne [string]$scenario.id) { + Fail "Campaign id $($scenario.id) does not match scenario file id $scenarioId." + } + + $map = $scenarioData.map + $width = [int]$map.width + $height = [int]$map.height + $rows = @($map.terrain) + if ($rows.Count -ne $height) { + Fail "Scenario $scenarioId map height mismatch." + } + foreach ($row in $rows) { + if ([string]$row.Length -ne [string]$width) { + Fail "Scenario $scenarioId map row width mismatch: $row" + } + foreach ($char in $row.ToCharArray()) { + if (-not $terrainKeys.Contains([string]$char)) { + Fail "Scenario $scenarioId uses unknown terrain key: $char" + } + } + } + + $eventIds = New-Object System.Collections.Generic.HashSet[string] + foreach ($event in (Get-Prop $scenarioData "events" @())) { + if (Has-Prop $event "id") { + $eventIds.Add([string]$event.id) | Out-Null + } + } + $knownUnitIds = Collect-Scenario-Unit-Ids $scenarioData + + if (Has-Prop $scenarioData "conditions") { + if (Has-Prop $scenarioData.conditions "victory") { + Check-Condition $scenarioData.conditions.victory $scenarioId $eventIds $width $height $rows $knownUnitIds + } + if (Has-Prop $scenarioData.conditions "defeat") { + Check-Condition $scenarioData.conditions.defeat $scenarioId $eventIds $width $height $rows $knownUnitIds + } + } + + $seenIds = New-Object System.Collections.Generic.HashSet[string] + foreach ($deployment in $scenarioData.deployments) { + Check-Deployment $deployment $scenarioId $width $height $rows $seenIds + } + + Check-Briefing (Get-Prop $scenarioData "briefing" $null) $scenarioId + Check-Shop (Get-Prop $scenarioData "shop" $null) $scenarioId + Check-Joined-Deployment-Requirements $scenarioData.deployments $scenarioId + Check-Roster (Get-Prop $scenarioData "roster" $null) $scenarioId $scenarioData.deployments + Check-Required-Officers-Joined (Get-Prop $scenarioData "roster" $null) (Get-Prop $scenarioData "conditions" $null) $scenarioId + Check-Roster-Condition-Consistency (Get-Prop $scenarioData "roster" $null) (Get-Prop $scenarioData "conditions" $null) $scenarioId + Check-Formation (Get-Prop $scenarioData "formation" $null) $scenarioId $width $height $rows $scenarioData.deployments + + foreach ($event in (Get-Prop $scenarioData "events" @())) { + $eventId = [string](Get-Prop $event "id" "event") + $when = Get-Prop $event "when" $null + if ($null -eq $when -or $when -is [string] -or $when -is [System.Array]) { + Fail "Scenario $scenarioId event $eventId has malformed when." + } + Check-Required-Flags (Get-Prop $when "campaign_flags" $null) $scenarioId "event $eventId" + $trigger = [string]$event.when.type + if (-not $validTriggers.Contains($trigger)) { + Fail "Scenario $scenarioId has unknown trigger: $trigger" + } + if ($trigger -eq "unit_reaches_tile") { + Check-Unit-Reaches-Event-When $when $scenarioId $width $height $rows $knownUnitIds + } + foreach ($action in (Get-Prop $event "actions" @())) { + $actionType = [string]$action.type + if (-not $validActions.Contains($actionType)) { + Fail "Scenario $scenarioId has unknown event action: $actionType" + } + if ($actionType -eq "spawn_deployment") { + Check-Deployment $action.deployment $scenarioId $width $height $rows $seenIds + } + if ($actionType -eq "spawn_deployments") { + foreach ($deployment in $action.deployments) { + Check-Deployment $deployment $scenarioId $width $height $rows $seenIds + } + } + } + } + + foreach ($itemId in (Get-Prop (Get-Prop $scenarioData "rewards" @{}) "items" @())) { + if (-not $itemIds.Contains([string]$itemId)) { + Fail "Scenario $scenarioId rewards unknown item: $itemId" + } + } + + Check-Join-Officer-Rewards (Get-Prop $scenarioData "rewards" $null) $scenarioId + Check-Post-Battle-Dialogue (Get-Prop $scenarioData "post_battle_dialogue" $null) $scenarioId + Check-Post-Battle-Choices (Get-Prop $scenarioData "post_battle_choices" $null) $scenarioId +} + +"data validation ok"