From 909d497df02c03b889de625135e9cf8d42251dd6 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 15:47:04 +0900 Subject: [PATCH] Let panacea cleanse stat debuffs --- README.md | 2 +- data/defs/items.json | 3 +- docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 9 +- docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 57 +++++++++++ scripts/scenes/battle_scene.gd | 2 + tools/smoke_item_debuff_cleanse.gd | 159 +++++++++++++++++++++++++++++ tools/validate_data.ps1 | 2 +- 9 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 tools/smoke_item_debuff_cleanse.gd diff --git a/README.md b/README.md index 55b5ca8..86d1cd7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - 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, area damage/healing/status options, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu, previews, and target overlays. -- Campaign inventory consumables with an item menu, HP/MP recovery, poison/seal/snare/disarm cure previews, and target overlay. +- Campaign inventory consumables with an item menu, HP/MP recovery, poison/seal/snare/disarm and debuff cure previews, and target overlay. - Weapon, armor, and accessory rewards can be stored, displayed, compared, equipped, and unequipped from the battle HUD; some weapons deal bonus physical damage against matching move types. - First named equipment rewards exist in the northern campaign arc and show a `[Named]` or `Named` tag in reward, inventory, shop, Armory, and equipment menus. - Scenario-specific pre-battle shop purchases, optional finite shop stock, and 50% sell-back save immediately and sync into the upcoming battle inventory. diff --git a/data/defs/items.json b/data/defs/items.json index e7665e1..70ad613 100644 --- a/data/defs/items.json +++ b/data/defs/items.json @@ -172,7 +172,8 @@ { "type": "cure_status", "status": "poison" }, { "type": "cure_status", "status": "seal" }, { "type": "cure_status", "status": "snare" }, - { "type": "cure_status", "status": "disarm" } + { "type": "cure_status", "status": "disarm" }, + { "type": "cleanse_debuffs" } ], "price": 140 }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89d08f6..db31002 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. 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, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. 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 and gold pickups, AI target-priority shifts, 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, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, 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, event gold pickups, 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, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. 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, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. 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 and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, 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, event gold pickups, 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, AI target-focus changes, and item/gold 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 3d21beb..1c004bf 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -321,7 +321,7 @@ Items start as static bonuses and consumable effects. Equipment leveling should Weapon `range` also uses `[min, max]`. When class and equipment both provide range, the runtime unit keeps the lowest minimum and highest maximum. Weapons can optionally define `effective_vs_move_types` plus `effective_bonus_damage` to add a small physical damage bonus during combat against targets with a matching runtime `move_type`; this is shown in attack forecasts, combat logs, shop stock, and equipment menus, but it does not mutate saved unit stats. The first examples are spear vs mounted, bow vs water, and axe vs foot. 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. The first named rewards are `yitian_sword`, `dragon_spear`, `wind_chaser_bow`, and `black_tortoise_robe`; their `rarity` tag is presentation-only and does not enforce uniqueness. -Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, `heal_mp` restores MP, and `cure_status` removes active battle-only effects with a matching `status` from a valid target; `Bean`, `Wine`, `Antidote`, and `Panacea` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu. +Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, `heal_mp` restores MP, `cure_status` removes active battle-only effects with a matching `status` from a valid target, and `cleanse_debuffs` removes only negative `stat_bonus` effects while leaving positive buffs intact; `Bean`, `Wine`, `Antidote`, and `Panacea` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu. ```json { @@ -335,7 +335,8 @@ Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, { "type": "cure_status", "status": "poison" }, { "type": "cure_status", "status": "seal" }, { "type": "cure_status", "status": "snare" }, - { "type": "cure_status", "status": "disarm" } + { "type": "cure_status", "status": "disarm" }, + { "type": "cleanse_debuffs" } ], "price": 140 } @@ -596,8 +597,8 @@ During a battle, item consumption, event item pickups, equipment swaps, and uneq Physical attacks grant normal attack/counter EXP only on hit after the attack/counter exchange resolves. A missed physical attack or counter grants a small miss EXP value, while defeat bonuses still require the attack to hit and defeat the target. -Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold, inventory, and finite-stock purchase counts in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Roster and Formation choices are battle setup state, so the manual checkpoint stores campaign progression and saved loadout state but not unsaved sortie or starting-cell edits. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. During battle, item previews can show HP/MP recovery and status cures, and no-effect item use is rejected without consuming stock. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. +Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold, inventory, and finite-stock purchase counts in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Roster and Formation choices are battle setup state, so the manual checkpoint stores campaign progression and saved loadout state but not unsaved sortie or starting-cell edits. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. During battle, item previews can show HP/MP recovery, status cures, and debuff cleanses, and no-effect item use is rejected without consuming stock. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. -Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill 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/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` 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, cure statuses, debuff cleanse effects, 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/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` 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/ROADMAP.md b/docs/ROADMAP.md index aee4673..af905be 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,7 +35,7 @@ - Equipment rewards can be stored, displayed, compared, equipped, unequipped, and swapped from the battle HUD. - Terrain movement, defense, and avoid modifiers affect combat. - Skills, tactics, and MP. Multiple damage/heal tactics, first area damage/healing/status tactics, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu with range/effect hints, and enemy tactic AI exist. -- Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, Antidote poison cure, Panacea poison/seal/snare/disarm cure, item menu, preview, and target overlay exist. +- Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, Antidote poison cure, Panacea poison/seal/snare/disarm and stat debuff cleanse, item menu, preview, and target overlay exist. - Counterattacks exist. Battle-only support, debuff, damage-over-time, and first action-lock status effects exist; richer ailment/status families are still planned. - Battle EXP, level-ups, class promotion, and campaign roster persistence exist. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 3a3dbfe..7fc88ca 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -836,6 +836,7 @@ func get_item_preview(user_id: String, item_id: String, target_cell: Vector2i) - var hp_heal_amount := _item_hp_heal_amount(target, item) var mp_heal_amount := _item_mp_heal_amount(target, item) var cured_statuses := _item_curable_status_names(target, item) + cured_statuses.append_array(_item_cleansable_debuff_names(target, item)) preview["heal"] = hp_heal_amount preview["mp_heal"] = mp_heal_amount preview["cure_statuses"] = cured_statuses @@ -2400,6 +2401,12 @@ func _apply_item_effects(user: Dictionary, target: Dictionary, item: Dictionary) applied = true _emit_log("%s is cured of %s." % [target["name"], _join_strings(cured_statuses, ", ")]) _emit_combat_feedback(target, "CURE", "support") + elif effect_type == "cleanse_debuffs": + var cleansed_debuffs := _apply_item_debuff_cleanse(target) + if not cleansed_debuffs.is_empty(): + applied = true + _emit_log("%s is cleansed of %s." % [target["name"], _join_strings(cleansed_debuffs, ", ")]) + _emit_combat_feedback(target, "CLEANSE", "support") if not applied: _emit_log("%s has no effect on %s." % [item.get("name", "Item"), target["name"]]) return applied @@ -2442,6 +2449,22 @@ func _apply_item_status_cure(target: Dictionary, status: String) -> Array: return cured_statuses +func _apply_item_debuff_cleanse(target: Dictionary) -> Array: + var cleansed_debuffs := [] + if typeof(target.get("status_effects", [])) != TYPE_ARRAY: + return cleansed_debuffs + var kept_effects := [] + for effect in _active_status_effects(target): + if _is_negative_stat_bonus_effect(effect): + var display_name := _stat_debuff_display_name(effect) + if not cleansed_debuffs.has(display_name): + cleansed_debuffs.append(display_name) + continue + kept_effects.append(effect) + target["status_effects"] = kept_effects + return cleansed_debuffs + + func _item_hp_heal_amount(target: Dictionary, item: Dictionary) -> int: var total := 0 for effect in item.get("effects", []): @@ -2486,6 +2509,40 @@ func _item_curable_status_names(target: Dictionary, item: Dictionary) -> Array: return result +func _item_cleansable_debuff_names(target: Dictionary, item: Dictionary) -> Array: + var result := [] + if target.is_empty() or not _item_has_effect_type(item, "cleanse_debuffs"): + return result + for active_effect in _active_status_effects(target): + if not _is_negative_stat_bonus_effect(active_effect): + continue + var display_name := _stat_debuff_display_name(active_effect) + if not result.has(display_name): + result.append(display_name) + return result + + +func _item_has_effect_type(item: Dictionary, effect_type: String) -> bool: + for effect in item.get("effects", []): + if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == effect_type: + return true + return false + + +func _is_negative_stat_bonus_effect(effect: Dictionary) -> bool: + if str(effect.get("type", "")) != "stat_bonus": + return false + var stat := str(effect.get("stat", "")) + return _is_supported_status_stat(stat) and int(effect.get("amount", 0)) < 0 + + +func _stat_debuff_display_name(effect: Dictionary) -> String: + var stat := str(effect.get("stat", "stat")) + if not _is_supported_status_stat(stat): + return "Debuff" + return "%s down" % stat.to_upper() + + func _equipment_slot_for_item(item: Dictionary) -> String: var kind := str(item.get("kind", "")) if kind == "weapon" or kind == "armor" or kind == "accessory": diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index b8e80cb..916f58a 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -4122,6 +4122,8 @@ func _format_item_effect_text(item: Dictionary) -> String: parts.append("MP %d" % int(effect.get("amount", 0))) elif str(effect.get("type", "")) == "cure_status": parts.append("Cure %s" % str(effect.get("status", "status")).replace("_", " ").capitalize()) + elif str(effect.get("type", "")) == "cleanse_debuffs": + parts.append("Cleanse debuffs") if parts.is_empty(): return str(item.get("kind", "item")).capitalize() return _join_strings(parts, ", ") diff --git a/tools/smoke_item_debuff_cleanse.gd b/tools/smoke_item_debuff_cleanse.gd new file mode 100644 index 0000000..dea7f73 --- /dev/null +++ b/tools/smoke_item_debuff_cleanse.gd @@ -0,0 +1,159 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + + +func _init() -> void: + var failures: Array[String] = [] + _check_panacea_cleanses_debuffs(failures) + _check_no_effect_panacea_is_not_consumed(failures) + _check_panacea_menu_text(failures) + + if failures.is_empty(): + print("item debuff cleanse smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_panacea_cleanses_debuffs(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle") + return + + var cao_cao: Dictionary = state.get_unit("cao_cao") + if cao_cao.is_empty(): + failures.append("missing Cao Cao") + return + cao_cao["status_effects"] = _mixed_status_effects() + state.battle_inventory["panacea"] = 1 + + var preview: Dictionary = state.get_item_preview("cao_cao", "panacea", cao_cao["pos"]) + var cure_names: Array = preview.get("cure_statuses", []) + for expected in ["Poison", "Seal", "ATK down", "MOVE down"]: + if not cure_names.has(expected): + failures.append("Panacea preview should include %s in %s" % [expected, str(cure_names)]) + + var logs: Array[String] = [] + var feedback: Array[String] = [] + state.log_added.connect(func(message: String) -> void: + logs.append(message) + ) + state.combat_feedback_requested.connect(func(unit_id: String, text: String, kind: String) -> void: + feedback.append("%s|%s|%s" % [unit_id, text, kind]) + ) + + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao") + return + if not state.try_use_selected_item_on_cell("panacea", cao_cao["pos"]): + failures.append("Panacea should apply to Cao Cao with poison, seal, and debuffs") + return + + if int(state.get_inventory_snapshot().get("panacea", 0)) != 0: + failures.append("Panacea should be consumed after cleansing") + var summary := state.get_status_effect_summary("cao_cao") + if not summary.contains("DEF +4"): + failures.append("Panacea should keep positive buffs: %s" % summary) + for removed_text in ["ATK -3", "MOVE -1", "Poison", "Seal"]: + if summary.contains(removed_text): + failures.append("Panacea should remove %s from summary: %s" % [removed_text, summary]) + _check_log_contains(failures, logs, "Cao Cao is cured of Poison.") + _check_log_contains(failures, logs, "Cao Cao is cured of Seal.") + _check_log_contains(failures, logs, "Cao Cao is cleansed of ATK down, MOVE down.") + if not feedback.has("cao_cao|CLEANSE|support"): + failures.append("Panacea cleanse feedback missing: %s" % str(feedback)) + + +func _check_no_effect_panacea_is_not_consumed(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for no-effect check") + return + + var cao_cao: Dictionary = state.get_unit("cao_cao") + cao_cao["status_effects"] = [{ + "source_skill": "guard_order", + "name": "Guard Order", + "type": "stat_bonus", + "stat": "def", + "amount": 4, + "remaining_phases": 2 + }] + state.battle_inventory["panacea"] = 1 + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for no-effect check") + return + if state.try_use_selected_item_on_cell("panacea", cao_cao["pos"]): + failures.append("Panacea should not apply when only positive buffs are active") + if int(state.get_inventory_snapshot().get("panacea", 0)) != 1: + failures.append("No-effect Panacea should not be consumed") + + +func _check_panacea_menu_text(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load battle scene state for menu text") + scene.free() + return + var text := scene._format_item_effect_text(scene.state.get_item_def("panacea")) + if not text.contains("Cleanse debuffs"): + failures.append("Panacea menu text should mention debuff cleanse: %s" % text) + scene.free() + + +func _mixed_status_effects() -> Array: + return [ + { + "source_skill": "disrupt_order", + "name": "Disrupt Order", + "type": "stat_bonus", + "stat": "atk", + "amount": -3, + "remaining_phases": 2 + }, + { + "source_skill": "hamper_order", + "name": "Hamper Order", + "type": "stat_bonus", + "stat": "move", + "amount": -1, + "remaining_phases": 2 + }, + { + "source_skill": "guard_order", + "name": "Guard Order", + "type": "stat_bonus", + "stat": "def", + "amount": 4, + "remaining_phases": 2 + }, + { + "source_skill": "poison_mist", + "name": "Poison Mist", + "type": "damage_over_time", + "status": "poison", + "amount": 4, + "remaining_phases": 2 + }, + { + "source_skill": "seal_tactic", + "name": "Seal Tactic", + "type": "action_lock", + "status": "seal", + "action": "skill", + "remaining_phases": 2 + } + ] + + +func _check_log_contains(failures: Array[String], logs: Array, expected: String) -> void: + for log_entry in logs: + if str(log_entry) == expected: + return + failures.append("expected log `%s` in `%s`" % [expected, str(logs)]) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index fbcd371..ac07593 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -94,7 +94,7 @@ $validConditionTypes = @( ) $validTriggers = @("battle_start", "battle_begin", "turn_start", "unit_reaches_tile", "unit_defeated") $validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "grant_gold", "spawn_deployment", "spawn_deployments", "withdraw_unit", "withdraw_units", "set_ai_target_priority") -$validEffects = @("heal_hp", "heal_mp", "cure_status") +$validEffects = @("heal_hp", "heal_mp", "cure_status", "cleanse_debuffs") $validItemKinds = @("weapon", "armor", "accessory", "consumable") $validItemRarities = @("common", "named") $validBonusStats = @("hp", "mp", "atk", "def", "int", "agi")