From 4f5e949d84fb9fc3a0915c51e0ab9383f89156fb Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 14:14:51 +0900 Subject: [PATCH] Add unit defeat event reactions --- README.md | 2 +- data/scenarios/010_white_horse_relief.json | 14 +++ data/scenarios/011_yan_ford_pursuit.json | 16 ++++ docs/ARCHITECTURE.md | 4 +- docs/DATA_MODEL.md | 15 +++- docs/GODOT_4_6_MIGRATION.md | 1 + docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 17 ++++ tools/smoke_event_unit_defeated.gd | 100 +++++++++++++++++++++ tools/validate_data.ps1 | 54 ++++++++++- 10 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 tools/smoke_event_unit_defeated.gd diff --git a/README.md b/README.md index a6fc005..32eb4c8 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - 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 split rewards, roster changes, saved growth, campaign status, and pending choices into readable sections. -- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, `after_event` prerequisites, objective updates with HUD notices, inventory pickups, reinforcements, and scripted unit withdrawals. +- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, unit-defeat reactions, `after_event` prerequisites, objective updates with HUD notices, inventory pickups, reinforcements, and scripted unit withdrawals. - Scenario briefing before battle with a campaign battle header and objective summary. - Victory rewards and consumed inventory saved into a campaign state. - Scenario-defined victory and defeat conditions. diff --git a/data/scenarios/010_white_horse_relief.json b/data/scenarios/010_white_horse_relief.json index a5e48eb..e39a5c3 100644 --- a/data/scenarios/010_white_horse_relief.json +++ b/data/scenarios/010_white_horse_relief.json @@ -280,6 +280,20 @@ } ] }, + { + "id": "yan_liang_vanguard_defeated", + "once": true, + "when": { "type": "unit_defeated", "unit_ids": ["yan_liang_vanguard"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Guo Jia", "text": "Yan Liang's banner is down. His riders will feel that before Yuan Shao can answer." }, + { "speaker": "Cao Cao", "text": "Press the isolated vanguard and make White Horse breathe again." } + ] + } + ] + }, { "id": "xu_province_reserve", "once": true, diff --git a/data/scenarios/011_yan_ford_pursuit.json b/data/scenarios/011_yan_ford_pursuit.json index 1b12178..43ceec2 100644 --- a/data/scenarios/011_yan_ford_pursuit.json +++ b/data/scenarios/011_yan_ford_pursuit.json @@ -283,6 +283,22 @@ } ] }, + { + "id": "wen_chou_vanguard_defeated", + "once": true, + "when": { "type": "unit_defeated", "unit_ids": ["wen_chou_vanguard"] }, + "actions": [ + { "type": "log", "text": "Wen Chou's countercharge collapses at Yan Ford." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Wen Chou", "text": "Yan Liang... I rode the same road too quickly." }, + { "speaker": "Guo Jia", "text": "The ford has taken both wings from Yuan Shao." }, + { "speaker": "Cao Cao", "text": "Then close the crossing. Guandu is where his numbers must answer us." } + ] + } + ] + }, { "id": "white_horse_fortified", "once": true, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 013c1b9..be50d5b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,7 +43,7 @@ 8. Player ends turn. 9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock 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, can wait for prerequisite events, and may update objectives with HUD notices, grant inventory pickups, deploy reinforcements, or withdraw units from the battlefield. +11. Turn-start, movement-triggered, and unit-defeat scenario events run as phases change, units enter marked cells, or units fall, can wait for prerequisite events, and may update objectives with HUD notices, grant inventory pickups, deploy reinforcements, or withdraw units from the battlefield. 12. On first-time 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 splits rewards, roster changes, saved player growth, campaign status, and pending choices into sections. @@ -57,7 +57,7 @@ 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. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts 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 and pickup events 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, item pickups, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up 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. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. +Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts 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 and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item pickups, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up 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. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures for the selected unit or hovered unit, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, and item pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 30552d6..912d32d 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -465,7 +465,7 @@ Conditions may include `after_event` to stay inactive until a one-shot event has `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. Each event needs a stable lowercase `id` that is unique within the scenario; condition and event `after_event` gates can only reference those explicit ids. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. Event `when` blocks may include `after_event` to wait until an earlier one-shot scenario beat has fired, which is useful for dialogue or follow-up pressure that only makes sense after a destination, ambush, gate, or fire event. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `spawn_deployment`, `spawn_deployments`, `withdraw_unit`, and `withdraw_units`. `set_objective` updates visible objective text, emits concrete log lines for changed victory/defeat text, and raises an objective-update signal so the battle scene can refresh the HUD and show a short notice. `withdraw_unit` accepts `unit_id` or `id`, while `withdraw_units` accepts a `unit_ids` array; living deployed targets leave the battlefield without being marked defeated, so they are removed from map occupancy and living-unit counts while protected-unit defeat checks still distinguish withdrawal from death. Event `dialogue` actions use the same dialogue line format, portrait default rules, and optional `side` placement as `post_battle_dialogue`. Event item grants add to the battle inventory immediately; they persist to the campaign save only when the battle is won. +Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, movement-triggered `unit_reaches_tile`, and defeat-triggered `unit_defeated` triggers. Each event needs a stable lowercase `id` that is unique within the scenario; condition and event `after_event` gates can only reference those explicit ids. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. `unit_defeated` events run after a deployed unit is marked defeated and before final victory/defeat checks; use `team`, `unit_ids`, or `officer_ids` to limit which fallen unit can trigger the event. Event `when` blocks may include `after_event` to wait until an earlier one-shot scenario beat has fired, which is useful for dialogue or follow-up pressure that only makes sense after a destination, ambush, gate, fire event, or named-unit defeat. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `spawn_deployment`, `spawn_deployments`, `withdraw_unit`, and `withdraw_units`. `set_objective` updates visible objective text, emits concrete log lines for changed victory/defeat text, and raises an objective-update signal so the battle scene can refresh the HUD and show a short notice. `withdraw_unit` accepts `unit_id` or `id`, while `withdraw_units` accepts a `unit_ids` array; living deployed targets leave the battlefield without being marked defeated, so they are removed from map occupancy and living-unit counts while protected-unit defeat checks still distinguish withdrawal from death. Event `dialogue` actions use the same dialogue line format, portrait default rules, and optional `side` placement as `post_battle_dialogue`. Event item grants add to the battle inventory immediately; they persist to the campaign save only when the battle is won. ```json { @@ -478,6 +478,17 @@ Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, and move } ``` +```json +{ + "id": "wen_chou_vanguard_defeated", + "once": true, + "when": { "type": "unit_defeated", "unit_ids": ["wen_chou_vanguard"] }, + "actions": [ + { "type": "log", "text": "Wen Chou's countercharge collapses at Yan Ford." } + ] +} +``` + ```json { "id": "turn_2_warning", @@ -575,4 +586,4 @@ Before a battle starts, `BattleScene` can buy priced items through `CampaignStat When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. -Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names and cure statuses, officer and deployment portraits, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event item grants, event withdrawal unit ids, 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. +Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names and cure statuses, officer and deployment portraits, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item grants, event withdrawal unit ids, 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 index 8c2522c..c5b0b9d 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -50,6 +50,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Hovering an occupied tile shows the unit's class, movement type, AGI, movement, range, equipped weapon/armor/accessory, and a HUD portrait thumbnail when no unit is selected, with empty gear slots shown as `-`. - The battle HUD and briefing objective panels split objective, progress, and risk details, including pending unlocks, destination reach status, defeated enemy counts, named protected-unit or commander safety, and turns remaining. - Scenario event `when.after_event` prerequisites keep follow-up events from firing before their one-shot setup event. +- `unit_defeated` scenario events fire named-unit reactions before final battle status checks, including Yan Liang and Wen Chou defeat dialogue. - `set_objective` scenario events show concrete objective-update logs, play a UI confirmation sound, refresh the HUD immediately, and briefly show the objective notice panel. - `withdraw_unit` and `withdraw_units` scenario events remove living targets from the battlefield without marking them defeated. - Movement-triggered supply cache events grant battle inventory items with pickup feedback, and the items persist only after victory. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8900998..e16b803 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,7 +42,7 @@ ## Milestone 3: Scenario Layer - Pre-battle briefing exists with a campaign chapter header, battle header, objective summary, and condition tracker. Full dialogue scenes are still planned. -- Scenario events exist for battle start, battle begin, turn start, movement-triggered map tiles, event prerequisites, and objective-change notices. +- Scenario events exist for battle start, battle begin, turn start, movement-triggered map tiles, unit defeat reactions, event prerequisites, and objective-change notices. - Mid-battle map/action events have a first action system with movement ambushes and scripted unit withdrawals. - Opening battle dialogue exists through event actions. - Post-battle dialogue exists before the victory result panel. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 137b71f..f2b5171 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -2957,6 +2957,7 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal target["alive"] = false _emit_log("%s is defeated." % target["name"]) _emit_combat_feedback(target, "DOWN", "defeat") + _handle_unit_defeated(target) return {"hit": true, "defeated": true} return {"hit": true, "defeated": false} @@ -2976,10 +2977,17 @@ func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictio target["alive"] = false _emit_log("%s is defeated." % target["name"]) _emit_combat_feedback(target, "DOWN", "defeat") + _handle_unit_defeated(target) return true return false +func _handle_unit_defeated(unit: Dictionary) -> void: + if unit.is_empty(): + return + _run_events("unit_defeated", str(unit.get("team", "")), turn_number, unit) + + 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) @@ -3585,6 +3593,8 @@ func _run_events(trigger_type: String, team := "", turn := -1, trigger_unit: Dic continue if trigger_type == "unit_reaches_tile" and not _unit_reaches_event_tile(trigger_unit, when): continue + if trigger_type == "unit_defeated" and not _unit_defeated_event_matches(trigger_unit, when): + continue if not _event_gate_open(when): continue if not _campaign_flags_match(when.get("campaign_flags", {})): @@ -3606,6 +3616,12 @@ func _unit_reaches_event_tile(unit: Dictionary, when: Dictionary) -> bool: return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) +func _unit_defeated_event_matches(unit: Dictionary, when: Dictionary) -> bool: + if unit.is_empty() or not unit.get("deployed", true) or unit.get("alive", true): + return false + return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) + + func _event_gate_open(when: Dictionary) -> bool: var after_event := str(when.get("after_event", "")) if after_event.is_empty(): @@ -3848,6 +3864,7 @@ func _apply_phase_status_effects_for_team(team: String) -> void: unit["alive"] = false _emit_log("%s is defeated by %s." % [unit["name"], status.replace("_", " ")]) _emit_combat_feedback(unit, "DOWN", "defeat") + _handle_unit_defeated(unit) break diff --git a/tools/smoke_event_unit_defeated.gd b/tools/smoke_event_unit_defeated.gd new file mode 100644 index 0000000..a17ab13 --- /dev/null +++ b/tools/smoke_event_unit_defeated.gd @@ -0,0 +1,100 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") + + +func _init() -> void: + var failures: Array[String] = [] + _check_defeat_event_log(failures) + _check_defeat_event_can_delay_victory(failures) + + if failures.is_empty(): + print("event unit_defeated smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_defeat_event_log(failures: Array[String]) -> void: + var state = _loaded_state(failures, "defeat event log") + if state == null: + return + var logs := [] + state.log_added.connect(func(message: String) -> void: + logs.append(message) + ) + state.fired_event_ids.clear() + state.battle_events.clear() + state.battle_events.append({ + "id": "zhang_mancheng_falls", + "once": true, + "when": {"type": "unit_defeated", "unit_ids": ["yellow_turban_1"]}, + "actions": [ + {"type": "log", "text": "Zhang Mancheng's banner falls."} + ] + }) + + _defeat_unit_with_skill(state, "yellow_turban_1") + if not logs.has("Zhang Mancheng's banner falls."): + failures.append("unit_defeated event log did not fire") + if not state.fired_event_ids.has("zhang_mancheng_falls"): + failures.append("unit_defeated event id was not recorded") + + +func _check_defeat_event_can_delay_victory(failures: Array[String]) -> void: + var state = _loaded_state(failures, "defeat event victory delay") + if state == null: + return + for enemy in state.get_living_units("enemy"): + if str(enemy.get("id", "")) != "yellow_turban_1": + enemy["alive"] = false + state.fired_event_ids.clear() + state.battle_events.clear() + state.battle_events.append({ + "id": "last_rebel_reserve_arrives", + "once": true, + "when": {"type": "unit_defeated", "unit_ids": ["yellow_turban_1"]}, + "actions": [ + { + "type": "spawn_deployment", + "deployment": { + "unit_id": "last_rebel_reserve", + "name": "Last Rebel Reserve", + "class_id": "bandit", + "team": "enemy", + "level": 1, + "pos": [5, 5], + "base": {"hp": 24, "atk": 9, "def": 4} + } + } + ] + }) + + _defeat_unit_with_skill(state, "yellow_turban_1") + var reserve: Dictionary = state.get_unit("last_rebel_reserve") + if reserve.is_empty() or not bool(reserve.get("alive", false)) or not bool(reserve.get("deployed", true)): + failures.append("unit_defeated reinforcement did not arrive alive and deployed") + if str(state.battle_status) != "active": + failures.append("unit_defeated reinforcement should keep battle active, got %s" % str(state.battle_status)) + + +func _loaded_state(failures: Array[String], label: String): + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("%s could not load smoke scenario" % label) + return null + return state + + +func _defeat_unit_with_skill(state, unit_id: String) -> void: + var caster: Dictionary = state.get_unit("cao_cao") + var target: Dictionary = state.get_unit(unit_id) + target["hp"] = 1 + state._resolve_skill_damage(caster, target, { + "name": "Smoke Bolt", + "power": 99, + "stat": "int" + }) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 8836b80..1987e23 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -92,7 +92,7 @@ $validConditionTypes = @( "all", "any" ) -$validTriggers = @("battle_start", "battle_begin", "turn_start", "unit_reaches_tile") +$validTriggers = @("battle_start", "battle_begin", "turn_start", "unit_reaches_tile", "unit_defeated") $validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "spawn_deployment", "spawn_deployments", "withdraw_unit", "withdraw_units") $validEffects = @("heal_hp", "heal_mp", "cure_status") $validItemKinds = @("weapon", "armor", "accessory", "consumable") @@ -398,6 +398,55 @@ function Check-Unit-Reaches-Event-When($When, [string]$ScenarioId, [int]$Width, } } +function Check-Unit-Defeated-Event-When($When, [string]$ScenarioId, $KnownUnitIds) { + $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_defeated event unit_ids must be an array." + } + foreach ($unitIdValue in @($unitIdsValue)) { + $unitId = [string]$unitIdValue + if ([string]::IsNullOrWhiteSpace($unitId)) { + Fail "Scenario $ScenarioId unit_defeated event has empty unit id." + } + if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) { + Fail "Scenario $ScenarioId unit_defeated 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_defeated 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_defeated 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_defeated 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_defeated 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)) { @@ -1763,6 +1812,9 @@ foreach ($scenario in $campaign.scenarios) { if ($trigger -eq "unit_reaches_tile") { Check-Unit-Reaches-Event-When $when $scenarioId $width $height $rows $knownUnitIds } + if ($trigger -eq "unit_defeated") { + Check-Unit-Defeated-Event-When $when $scenarioId $knownUnitIds + } foreach ($action in (Get-Prop $event "actions" @())) { $actionType = [string]$action.type if (-not $validActions.Contains($actionType)) {