diff --git a/README.md b/README.md index 32eb4c8..734e0b2 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - 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. -- Destination-style victory conditions with objective map markers. +- Destination-style victory conditions with single-tile or multi-cell objective map markers. - Protected-unit defeat conditions for escort battles. - Turn-limit defeat conditions with turn-limit HUD display. - Live objective panels in the active HUD and briefing split objective, progress, and risk details, including gated unlocks, destination markers, defeated enemy counts, named protected-unit safety, and turns remaining. diff --git a/data/scenarios/008_wan_castle_escape.json b/data/scenarios/008_wan_castle_escape.json index e340586..b2e19ef 100644 --- a/data/scenarios/008_wan_castle_escape.json +++ b/data/scenarios/008_wan_castle_escape.json @@ -10,7 +10,7 @@ "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], - "pos": [1, 4] + "cells": [[1, 4], [0, 4]] }, "defeat": [ { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, diff --git a/data/scenarios/026_changban_pursuit.json b/data/scenarios/026_changban_pursuit.json index d9f0ff1..e95c4c7 100644 --- a/data/scenarios/026_changban_pursuit.json +++ b/data/scenarios/026_changban_pursuit.json @@ -284,7 +284,7 @@ { "id": "changban_bridge_forced", "once": true, - "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [10, 4] }, + "when": { "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "cells": [[10, 4], [11, 4]] }, "actions": [ { "type": "set_objective", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index be50d5b..5f08722 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. 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. +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, and can be expressed as one `pos` or a multi-cell `cells` list. 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 912d32d..1180988 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -459,13 +459,13 @@ Conditions may include `after_event` to stay inactive until a one-shot event has `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. +`unit_reaches_tile` checks living deployed units against either one zero-based `pos` coordinate or a `cells` array such as `[[10, 4], [11, 4]]`. 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, including every entry in `cells`. `post_battle_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker`, non-empty `text`, optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`, and optional `side` values of `left` or `right` for portrait placement. Portrait paths must stay inside the project, point to existing readable image files, and keep at least a 512x512 source resolution. If `side` is omitted, it defaults to `left`. If `portrait` is omitted, `BattleState` uses a matching officer definition's default portrait path when the line's `speaker` matches the officer `name`. If no usable portrait is loaded, the battle scene falls back to a speaker-initial panel. `post_battle_choices` are shown on the victory result panel after first-time scenario rewards are applied. Each choice needs a unique stable lowercase `id`, a non-empty `label`, and a `set_flags` object whose keys are stable lowercase flag ids. Choices may also grant positive `gold`, known `items`, `join_officers`, and `leave_officers`. When rewards are saved but the player has not selected a choice yet, `CampaignState.pending_post_battle_choice_scenario_id` records that scenario id so reloading the game returns to the victory choice panel instead of skipping the branch. The selected choice is saved to `CampaignState.flags`, `CampaignState.applied_post_battle_choices`, and campaign membership only when the player presses its result-panel button; completed-scenario replays do not show choices again. Save-load migration for older saves uses flags as a conservative choice signal, so avoid designing multiple choices in one scenario that can all match the same saved flag state. -Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, 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. +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 or any zero-based entry in `cells`; 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 { @@ -526,13 +526,13 @@ Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, movement } ``` -Movement-triggered ambushes use the same event action shape: +Movement-triggered ambushes use the same event action shape. Use `cells` instead of `pos` when a gate, bridge, road, or marker should allow more than one exact tile. ```json { "id": "midroad_ambush", "once": true, - "when": { "type": "unit_reaches_tile", "team": "player", "pos": [7, 4] }, + "when": { "type": "unit_reaches_tile", "team": "player", "cells": [[7, 4], [8, 4]] }, "actions": [ { "type": "dialogue", "lines": [{ "speaker": "Scout", "text": "Ambush!" }] }, { @@ -586,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 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. +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 `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/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index c5b0b9d..0518bf0 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -48,7 +48,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable, and equipment options show stat/range change hints. - Named equipment rewards such as Yitian Sword and Dragon Spear validate, appear with compact rarity tags in reward/inventory/equipment text, and remain ordinary stackable inventory stock until unique item instances are implemented. - 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. +- The battle HUD and briefing objective panels split objective, progress, and risk details, including pending unlocks, single-tile or multi-cell 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e16b803..f8c1fa6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,7 +19,7 @@ - Enemy units take AI turns with movement, damage-aware physical 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. +- Destination victory conditions and single-tile or multi-cell objective map markers exist. - Turn-limit conditions and HUD turn-limit display exist. - Active HUD and briefing objective panels split objective, progress, and risk details, including gated unlocks, destinations, defeated enemies, protected-unit safety, and turns remaining. - Victory result summary splits rewards, roster changes, saved growth, campaign status, and pending choices into readable sections. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index f2b5171..4e00bd4 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -3240,11 +3240,11 @@ func _append_target_officers_progress_line( func _append_destination_progress_line(result: Array[String], condition: Dictionary, pending := false) -> void: - var target := _condition_cell(condition.get("pos", [])) - if not is_inside(target): + var targets := _condition_cells(condition) + if targets.is_empty(): return var status := "reached" if _unit_reaches_tile(condition) else "not reached" - _append_progress_line(result, "Destination %s: %s" % [_format_cell(target), status], pending) + _append_progress_line(result, "Destination %s: %s" % [_format_cells(targets), status], pending) func _append_turn_limit_progress_line(result: Array[String]) -> void: @@ -3272,11 +3272,11 @@ func _append_after_event_progress_line(result: Array[String], event_id: String) func _append_event_destination_progress_line(result: Array[String], when: Dictionary) -> void: - var target := _condition_cell(when.get("pos", [])) - if not is_inside(target): + var targets := _condition_cells(when) + if targets.is_empty(): return var status := "reached" if _unit_reaches_tile(when) else "not reached" - _append_progress_line(result, "Reach %s: %s" % [_format_cell(target), status]) + _append_progress_line(result, "Reach %s: %s" % [_format_cells(targets), status]) func _append_turn_condition_progress_line(result: Array[String], condition: Dictionary, mode: String, pending := false) -> void: @@ -3435,8 +3435,8 @@ func _is_turn_reached(condition: Dictionary) -> bool: func _unit_reaches_tile(condition: Dictionary) -> bool: - var target := _condition_cell(condition.get("pos", [])) - if not is_inside(target): + var targets := _condition_cells(condition) + if targets.is_empty(): return false var team := str(condition.get("team", "")) var unit_ids = condition.get("unit_ids", []) @@ -3450,7 +3450,7 @@ func _unit_reaches_tile(condition: Dictionary) -> bool: continue if not _unit_matches_condition_ids(unit, unit_ids, officer_ids): continue - if unit.get("pos", Vector2i(-9999, -9999)) == target: + if targets.has(unit.get("pos", Vector2i(-9999, -9999))): return true return false @@ -3481,6 +3481,20 @@ func _condition_cell(pos_value) -> Vector2i: return Vector2i(int(pos_value[0]), int(pos_value[1])) +func _condition_cells(source: Dictionary) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + if source.has("cells") and typeof(source["cells"]) == TYPE_ARRAY: + for pos_value in source["cells"]: + var cell := _condition_cell(pos_value) + if is_inside(cell) and not result.has(cell): + result.append(cell) + elif source.has("pos"): + var cell := _condition_cell(source.get("pos", [])) + if is_inside(cell): + result.append(cell) + return result + + func _condition_gate_open(condition: Dictionary) -> bool: var after_event := str(condition.get("after_event", "")) if after_event.is_empty(): @@ -3514,9 +3528,9 @@ func _collect_objective_cells(condition_group, result: Array[Vector2i]) -> void: 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) + for cell in _condition_cells(condition_group): + if not result.has(cell): + result.append(cell) func _find_turn_limit(condition_group) -> int: @@ -3608,10 +3622,10 @@ func _run_events(trigger_type: String, team := "", turn := -1, trigger_unit: Dic 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): + var targets := _condition_cells(when) + if targets.is_empty(): return false - if unit.get("pos", Vector2i(-9999, -9999)) != target: + if not targets.has(unit.get("pos", Vector2i(-9999, -9999))): return false return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) @@ -3900,6 +3914,17 @@ func _format_cell(cell: Vector2i) -> String: return "(%d, %d)" % [cell.x + 1, cell.y + 1] +func _format_cells(cells: Array[Vector2i]) -> String: + if cells.is_empty(): + return "" + if cells.size() == 1: + return _format_cell(cells[0]) + var labels := [] + for cell in cells: + labels.append(_format_cell(cell)) + return _join_strings(labels, ", ") + + func _join_strings(values: Array, delimiter: String) -> String: var text := "" for value in values: diff --git a/tools/smoke_event_cells.gd b/tools/smoke_event_cells.gd new file mode 100644 index 0000000..73968bb --- /dev/null +++ b/tools/smoke_event_cells.gd @@ -0,0 +1,76 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") + + +func _init() -> void: + var failures: Array[String] = [] + _check_condition_cells(failures) + _check_event_cells(failures) + + if failures.is_empty(): + print("event cells smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_condition_cells(failures: Array[String]) -> void: + var state = _loaded_state(failures, "condition cells") + if state == null: + return + state.battle_conditions["victory"] = { + "type": "unit_reaches_tile", + "team": "player", + "unit_ids": ["cao_cao"], + "cells": [[2, 3], [2, 4]] + } + var objective_cells: Array[Vector2i] = state.get_objective_cells() + if not objective_cells.has(Vector2i(2, 3)) or not objective_cells.has(Vector2i(2, 4)): + failures.append("objective cells missing multi-cell destination: %s" % str(objective_cells)) + var progress: String = state.get_objective_progress_text(false) + if not progress.contains("(3, 4), (3, 5): not reached"): + failures.append("multi-cell progress text mismatch before reach: %s" % progress) + var cao_cao: Dictionary = state.get_unit("cao_cao") + cao_cao["pos"] = Vector2i(2, 4) + progress = state.get_objective_progress_text(false) + if not progress.contains("(3, 4), (3, 5): reached"): + failures.append("multi-cell progress text mismatch after reach: %s" % progress) + if not state._is_condition_met(state.battle_conditions["victory"]): + failures.append("multi-cell unit_reaches_tile condition should be met") + + +func _check_event_cells(failures: Array[String]) -> void: + var state = _loaded_state(failures, "event cells") + if state == null: + return + var logs: Array[String] = [] + 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": "multi_cell_cache", + "once": true, + "when": {"type": "unit_reaches_tile", "team": "player", "cells": [[3, 2], [3, 3]]}, + "actions": [ + {"type": "log", "text": "Multi-cell trigger fired."} + ] + }) + var cao_cao: Dictionary = state.get_unit("cao_cao") + cao_cao["pos"] = Vector2i(3, 3) + state._run_events("unit_reaches_tile", "player", 1, cao_cao) + if not logs.has("Multi-cell trigger fired."): + failures.append("multi-cell event did not fire from second cell") + + +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 diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 1987e23..541f2d8 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -239,6 +239,50 @@ if (Has-Prop $campaign "chapters") { } } +function Check-Unit-Reaches-Cells($Source, [string]$Context, [int]$Width, [int]$Height, $Rows) { + $hasPos = Has-Prop $Source "pos" + $hasCells = Has-Prop $Source "cells" + if ($hasPos -and $hasCells) { + Fail "$Context must use either pos or cells, not both." + } + if (-not $hasPos -and -not $hasCells) { + Fail "$Context is missing pos or cells." + } + + $cellEntries = @() + if ($hasPos) { + $cellEntries += ,$Source.pos + } else { + if (-not ($Source.cells -is [System.Array])) { + Fail "$Context cells must be an array." + } + $cellEntries = @($Source.cells) + if ($cellEntries.Count -le 0) { + Fail "$Context cells must not be empty." + } + } + + $seenCells = New-Object System.Collections.Generic.HashSet[string] + foreach ($entry in $cellEntries) { + $cell = Resolve-Cell $entry $Context + $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 "$Context 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 "$Context targets impassable terrain at [$x,$y]." + } + } + $key = "$x,$y" + if (-not $seenCells.Add($key)) { + Fail "$Context has duplicate cell [$x,$y]." + } + } +} + 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) { @@ -279,21 +323,7 @@ function Check-Condition($Condition, [string]$ScenarioId, $EventIds, [int]$Width } } 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]." - } - } + Check-Unit-Reaches-Cells $Condition "Scenario $ScenarioId unit_reaches_tile condition" $Width $Height $Rows $unitIdsValue = $null if (Has-Prop $Condition "unit_ids") { $unitIdsValue = $Condition.unit_ids @@ -336,19 +366,7 @@ function Check-Condition($Condition, [string]$ScenarioId, $EventIds, [int]$Width } 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]." - } + Check-Unit-Reaches-Cells $When "Scenario $ScenarioId unit_reaches_tile event" $Width $Height $Rows $unitIdsValue = $null if (Has-Prop $When "unit_ids") {