class_name AbilitySystem extends Node ## AbilitySystem - Central coordinator for ability processing ## Loads processed abilities and handles trigger matching and effect resolution signal ability_triggered(source: CardInstance, ability: Dictionary) signal effect_resolved(effect: Dictionary, targets: Array) signal targeting_required(effect: Dictionary, valid_targets: Array, callback: Callable) signal targeting_completed(effect: Dictionary, selected_targets: Array) signal choice_modal_required(effect: Dictionary, modes: Array, callback: Callable) signal optional_effect_prompt(player_index: int, effect: Dictionary, description: String, callback: Callable) const ABILITIES_PATH = "res://data/abilities_processed.json" # Loaded ability data var _abilities: Dictionary = {} # card_id -> Array of parsed abilities var _version: String = "" var _stats: Dictionary = {} # Sub-systems var trigger_matcher: TriggerMatcher var effect_resolver: EffectResolver var target_selector: TargetSelector var field_effect_manager: FieldEffectManager var condition_checker: ConditionChecker # UI Reference var choice_modal: ChoiceModal = null # Effect resolution stack var _pending_effects: Array = [] var _is_resolving: bool = false var _waiting_for_choice: bool = false var _waiting_for_optional: bool = false # Connected game state var _game_state = null # GameState reference func _ready() -> void: _load_abilities() _init_subsystems() func _init_subsystems() -> void: trigger_matcher = TriggerMatcher.new() effect_resolver = EffectResolver.new() target_selector = TargetSelector.new() field_effect_manager = FieldEffectManager.new() condition_checker = ConditionChecker.new() # Wire ConditionChecker to subsystems that need it effect_resolver.condition_checker = condition_checker trigger_matcher.condition_checker = condition_checker # Connect effect resolver signals effect_resolver.effect_completed.connect(_on_effect_completed) effect_resolver.choice_required.connect(_on_choice_required) func _load_abilities() -> void: if not FileAccess.file_exists(ABILITIES_PATH): push_warning("AbilitySystem: No processed abilities found at " + ABILITIES_PATH) push_warning("Run: python tools/ability_processor.py") return var file = FileAccess.open(ABILITIES_PATH, FileAccess.READ) if not file: push_error("AbilitySystem: Failed to open " + ABILITIES_PATH) return var json = JSON.new() var error = json.parse(file.get_as_text()) file.close() if error != OK: push_error("AbilitySystem: Failed to parse abilities JSON: " + json.get_error_message()) return var data = json.get_data() _version = data.get("version", "unknown") _stats = data.get("statistics", {}) _abilities = data.get("abilities", {}) print("AbilitySystem: Loaded v%s - %d cards, %d abilities (%d high confidence)" % [ _version, _stats.get("total_cards", 0), _stats.get("total_abilities", 0), _stats.get("parsed_high", 0) ]) ## Connect to a game state to listen for events func connect_to_game(game_state) -> void: if _game_state: _disconnect_from_game() _game_state = game_state # Connect to game events that can trigger abilities game_state.card_played.connect(_on_card_played) game_state.summon_cast.connect(_on_summon_cast) game_state.attack_declared.connect(_on_attack_declared) game_state.block_declared.connect(_on_block_declared) game_state.forward_broken.connect(_on_forward_broken) game_state.damage_dealt.connect(_on_damage_dealt) game_state.card_moved.connect(_on_card_moved) game_state.combat_resolved.connect(_on_combat_resolved) # Turn manager signals if game_state.turn_manager: game_state.turn_manager.phase_changed.connect(_on_phase_changed) game_state.turn_manager.turn_started.connect(_on_turn_started) game_state.turn_manager.turn_ended.connect(_on_turn_ended) print("AbilitySystem: Connected to GameState") func _disconnect_from_game() -> void: if not _game_state: return # Disconnect all signals if _game_state.card_played.is_connected(_on_card_played): _game_state.card_played.disconnect(_on_card_played) if _game_state.summon_cast.is_connected(_on_summon_cast): _game_state.summon_cast.disconnect(_on_summon_cast) if _game_state.attack_declared.is_connected(_on_attack_declared): _game_state.attack_declared.disconnect(_on_attack_declared) if _game_state.block_declared.is_connected(_on_block_declared): _game_state.block_declared.disconnect(_on_block_declared) if _game_state.forward_broken.is_connected(_on_forward_broken): _game_state.forward_broken.disconnect(_on_forward_broken) if _game_state.damage_dealt.is_connected(_on_damage_dealt): _game_state.damage_dealt.disconnect(_on_damage_dealt) if _game_state.card_moved.is_connected(_on_card_moved): _game_state.card_moved.disconnect(_on_card_moved) _game_state = null ## Get parsed abilities for a card func get_abilities(card_id: String) -> Array: return _abilities.get(card_id, []) ## Check if a card has parsed abilities func has_abilities(card_id: String) -> bool: return _abilities.has(card_id) and _abilities[card_id].size() > 0 ## Get a specific parsed ability func get_ability(card_id: String, ability_index: int) -> Dictionary: var abilities = get_abilities(card_id) if ability_index >= 0 and ability_index < abilities.size(): return abilities[ability_index] return {} ## Process a game event and trigger matching abilities func process_event(event_type: String, event_data: Dictionary) -> void: if not _game_state: return var triggered = trigger_matcher.find_triggered_abilities( event_type, event_data, _game_state, _abilities ) for trigger_info in triggered: _queue_ability(trigger_info) ## Queue an ability for resolution func _queue_ability(trigger_info: Dictionary) -> void: var source = trigger_info.source as CardInstance var ability = trigger_info.ability as Dictionary var parsed = ability.get("parsed", {}) if not parsed or not parsed.has("effects"): return # Check if ability has a cost that needs to be paid var cost = parsed.get("cost", {}) if not cost.is_empty() and source and _game_state: var player = _game_state.get_player(source.controller_index) if player: # Validate cost var validation = _validate_ability_cost(cost, source, player) if not validation.valid: # Cannot pay cost - emit signal and skip ability ability_cost_failed.emit(source, ability, validation.reason) push_warning("AbilitySystem: Cannot pay cost for ability - %s" % validation.reason) return # Pay the cost _pay_ability_cost(cost, source, player) ability_triggered.emit(source, ability) # Add effects to pending stack (LIFO for proper resolution order) var effects = parsed.get("effects", []) for i in range(effects.size() - 1, -1, -1): _pending_effects.push_front({ "effect": effects[i], "source": source, "controller": source.controller_index, "ability": ability, "event_data": trigger_info.get("event_data", {}) }) # Start resolving if not already if not _is_resolving: _resolve_next_effect() ## Resolve the next pending effect func _resolve_next_effect() -> void: if _pending_effects.is_empty(): _is_resolving = false return _is_resolving = true var pending = _pending_effects[0] var effect = pending.effect var source = pending.source # Check if effect is optional and we haven't prompted yet if effect.get("optional", false) and not pending.get("optional_prompted", false): _waiting_for_optional = true pending["optional_prompted"] = true # Mark as prompted to avoid re-prompting # Determine which player should decide var player_index = source.controller_index if source else 0 # Build description from effect var description = _build_effect_description(effect) # Emit signal for UI to handle optional_effect_prompt.emit(player_index, effect, description, _on_optional_effect_choice) return # Wait for callback # Check if effect needs targeting if _effect_needs_targeting(effect): var valid_targets = target_selector.get_valid_targets( effect.get("target", {}), source, _game_state ) if valid_targets.is_empty(): # No valid targets, skip effect _pending_effects.pop_front() _resolve_next_effect() return # Request target selection from player targeting_required.emit(effect, valid_targets, _on_targets_selected) # Wait for targeting_completed signal else: # Resolve immediately _execute_effect(pending) ## Execute an effect with its targets func _execute_effect(pending: Dictionary) -> void: var effect = pending.effect var source = pending.source var targets = pending.get("targets", []) effect_resolver.resolve(effect, source, targets, _game_state) ## Called when effect resolution completes func _on_effect_completed(effect: Dictionary, targets: Array) -> void: effect_resolved.emit(effect, targets) if not _pending_effects.is_empty(): _pending_effects.pop_front() _resolve_next_effect() ## Called when player selects targets func _on_targets_selected(targets: Array) -> void: if _pending_effects.is_empty(): return _pending_effects[0]["targets"] = targets targeting_completed.emit(_pending_effects[0].effect, targets) _execute_effect(_pending_effects[0]) ## Check if effect requires player targeting func _effect_needs_targeting(effect: Dictionary) -> bool: if not effect.has("target"): return false var target = effect.target return target.get("type") == "CHOOSE" ## Called when player responds to optional effect prompt func _on_optional_effect_choice(accepted: bool) -> void: _waiting_for_optional = false if _pending_effects.is_empty(): return if accepted: # Player chose to execute the optional effect # Continue with normal resolution (targeting or execution) var pending = _pending_effects[0] var effect = pending.effect if _effect_needs_targeting(effect): var source = pending.source var valid_targets = target_selector.get_valid_targets( effect.get("target", {}), source, _game_state ) if valid_targets.is_empty(): _pending_effects.pop_front() _resolve_next_effect() return targeting_required.emit(effect, valid_targets, _on_targets_selected) else: _execute_effect(pending) else: # Player declined the optional effect - skip it _pending_effects.pop_front() _resolve_next_effect() ## Build a human-readable description of an effect for prompts func _build_effect_description(effect: Dictionary) -> String: var effect_type = str(effect.get("type", "")).to_upper() var amount = effect.get("amount", 0) match effect_type: "DRAW": var count = effect.get("amount", 1) return "Draw %d card%s" % [count, "s" if count > 1 else ""] "DAMAGE": return "Deal %d damage" % amount "POWER_MOD": var sign = "+" if amount >= 0 else "" return "Give %s%d power" % [sign, amount] "DULL": return "Dull a Forward" "ACTIVATE": return "Activate a card" "BREAK": return "Break a card" "RETURN": return "Return a card to hand" "SEARCH": return "Search your deck" "DISCARD": var count = effect.get("amount", 1) return "Discard %d card%s" % [count, "s" if count > 1 else ""] _: # Use the original_text if available if effect.has("original_text"): return effect.original_text return "Use this effect" ## Called when EffectResolver encounters a CHOOSE_MODE effect func _on_choice_required(effect: Dictionary, modes: Array) -> void: if _pending_effects.is_empty(): return var pending = _pending_effects[0] var source = pending.get("source") as CardInstance # Check for enhanced condition (e.g., "If you have 5+ Ifrit, select 3 instead") var select_count = effect.get("select_count", 1) var select_up_to = effect.get("select_up_to", false) var enhanced = effect.get("enhanced_condition", {}) if not enhanced.is_empty() and _check_enhanced_condition(enhanced, source): select_count = enhanced.get("select_count", select_count) select_up_to = enhanced.get("select_up_to", select_up_to) # If we have a ChoiceModal, use it if choice_modal: _waiting_for_choice = true _handle_modal_choice_async(effect, modes, select_count, select_up_to, source) else: # No UI available - auto-select first N modes push_warning("AbilitySystem: No ChoiceModal available, auto-selecting first mode(s)") var auto_selected: Array = [] for i in range(min(select_count, modes.size())): auto_selected.append(i) _on_modes_selected(effect, modes, auto_selected, source) ## Handle modal choice asynchronously func _handle_modal_choice_async( effect: Dictionary, modes: Array, select_count: int, select_up_to: bool, source: CardInstance ) -> void: var selected = await choice_modal.show_choices( "", # Title is generated by ChoiceModal modes, select_count, select_up_to, false # Not cancellable for mandatory abilities ) _waiting_for_choice = false _on_modes_selected(effect, modes, selected, source) ## Cached regex for enhanced condition parsing var _enhanced_count_regex: RegEx = null ## Check if enhanced condition is met func _check_enhanced_condition(condition: Dictionary, source: CardInstance) -> bool: var description = condition.get("description", "").to_lower() # Parse "if you have X or more [Card Name] in your Break Zone" if "break zone" in description: # Initialize regex once (lazy) if _enhanced_count_regex == null: _enhanced_count_regex = RegEx.new() _enhanced_count_regex.compile("(\\d+) or more") var match_result = _enhanced_count_regex.search(description) if match_result: var required_count = int(match_result.get_string(1)) # Check break zone for matching cards if _game_state and source: var player = _game_state.get_player(source.controller_index) if player: # Count matching cards in break zone var break_zone_count = 0 for card in player.break_zone.get_cards(): # Simple name matching (description contains card name pattern) if card.card_data and card.card_data.name.to_lower() in description: break_zone_count += 1 return break_zone_count >= required_count return false return false # ============================================================================= # COST VALIDATION AND PAYMENT # ============================================================================= ## Signal emitted when cost cannot be paid signal ability_cost_failed(source: CardInstance, ability: Dictionary, reason: String) ## Validate that a player can pay the cost for an ability ## Returns true if cost can be paid, false otherwise func _validate_ability_cost( cost: Dictionary, source: CardInstance, player ) -> Dictionary: var result = {"valid": true, "reason": ""} if cost.is_empty(): return result # Check CP cost var cp_cost = cost.get("cp", 0) var element = cost.get("element", "") if cp_cost > 0: if element and element != "" and element.to_upper() != "ANY": # Specific element required var element_enum = Enums.element_from_string(element) if player.cp_pool.get_cp(element_enum) < cp_cost: result.valid = false result.reason = "Not enough %s CP (need %d, have %d)" % [ element, cp_cost, player.cp_pool.get_cp(element_enum) ] return result else: # Any element if player.cp_pool.get_total_cp() < cp_cost: result.valid = false result.reason = "Not enough CP (need %d, have %d)" % [ cp_cost, player.cp_pool.get_total_cp() ] return result # Check discard cost var discard = cost.get("discard", 0) if discard > 0: if player.hand.get_count() < discard: result.valid = false result.reason = "Not enough cards in hand to discard (need %d, have %d)" % [ discard, player.hand.get_count() ] return result # Check dull self cost var dull_self = cost.get("dull_self", false) if dull_self and source: if source.is_dull(): result.valid = false result.reason = "Card is already dulled" return result # Check specific card discard var specific_discard = cost.get("specific_discard", "") if specific_discard != "": # Player must have a card with this name in hand var has_card = false for card in player.hand.get_cards(): if card.card_data and card.card_data.name.to_lower() == specific_discard.to_lower(): has_card = true break if not has_card: result.valid = false result.reason = "Must discard a card named '%s'" % specific_discard return result return result ## Pay the cost for an ability ## Returns true if cost was paid successfully func _pay_ability_cost( cost: Dictionary, source: CardInstance, player ) -> bool: if cost.is_empty(): return true # Pay CP cost var cp_cost = cost.get("cp", 0) var element = cost.get("element", "") if cp_cost > 0: if element and element != "" and element.to_upper() != "ANY": # Spend specific element CP var element_enum = Enums.element_from_string(element) player.cp_pool.add_cp(element_enum, -cp_cost) else: # Spend from any element (generic) var remaining = cp_cost for elem in Enums.Element.values(): var available = player.cp_pool.get_cp(elem) if available > 0: var to_spend = mini(available, remaining) player.cp_pool.add_cp(elem, -to_spend) remaining -= to_spend if remaining <= 0: break # Pay dull self cost var dull_self = cost.get("dull_self", false) if dull_self and source: source.dull() # Note: Discard costs are handled through separate UI interaction # The discard selection would be queued as a separate effect return true ## Called when player selects mode(s) func _on_modes_selected( effect: Dictionary, modes: Array, selected_indices: Array, source: CardInstance ) -> void: # Queue the effects from selected modes for index in selected_indices: if index >= 0 and index < modes.size(): var mode = modes[index] var mode_effects = mode.get("effects", []) for mode_effect in mode_effects: _pending_effects.push_back({ "effect": mode_effect, "source": source, "controller": source.controller_index if source else 0, "ability": effect, "event_data": {} }) # Remove the CHOOSE_MODE effect from pending and continue if not _pending_effects.is_empty(): _pending_effects.pop_front() _resolve_next_effect() # ============================================================================= # Event Handlers # ============================================================================= func _on_card_played(card: CardInstance, player_index: int) -> void: # Register field abilities if card.is_forward() or card.is_backup(): var card_abilities = get_abilities(card.card_data.id) field_effect_manager.register_field_abilities(card, card_abilities) # Trigger enters field events process_event("ENTERS_FIELD", { "card": card, "player": player_index, "zone_from": Enums.ZoneType.HAND, "zone_to": Enums.ZoneType.FIELD_FORWARDS if card.is_forward() else Enums.ZoneType.FIELD_BACKUPS }) func _on_summon_cast(card: CardInstance, player_index: int) -> void: process_event("SUMMON_CAST", { "card": card, "player": player_index }) func _on_attack_declared(attacker: CardInstance) -> void: process_event("ATTACKS", { "card": attacker, "player": attacker.controller_index }) func _on_block_declared(blocker: CardInstance) -> void: if not _game_state or not _game_state.turn_manager: return var attacker = _game_state.turn_manager.current_attacker process_event("BLOCKS", { "card": blocker, "attacker": attacker, "player": blocker.controller_index }) if attacker: process_event("IS_BLOCKED", { "card": attacker, "blocker": blocker, "player": attacker.controller_index }) func _on_forward_broken(card: CardInstance) -> void: # Unregister field abilities field_effect_manager.unregister_field_abilities(card) process_event("LEAVES_FIELD", { "card": card, "zone_from": Enums.ZoneType.FIELD_FORWARDS, "zone_to": Enums.ZoneType.BREAK }) func _on_damage_dealt(player_index: int, amount: int, cards: Array) -> void: process_event("DAMAGE_DEALT_TO_PLAYER", { "player": player_index, "amount": amount, "cards": cards }) # Check for EX BURST triggers on damage cards for card in cards: if card.card_data and card.card_data.has_ex_burst: _trigger_ex_burst(card, player_index) func _on_card_moved(card: CardInstance, from_zone: Enums.ZoneType, to_zone: Enums.ZoneType) -> void: # Handle zone changes if to_zone == Enums.ZoneType.BREAK: field_effect_manager.unregister_field_abilities(card) func _on_combat_resolved(attacker: CardInstance, blocker: CardInstance) -> void: if not blocker: # Unblocked attack process_event("DEALS_DAMAGE_TO_OPPONENT", { "card": attacker, "player": attacker.controller_index }) else: # Blocked combat process_event("DEALS_DAMAGE", { "card": attacker, "target": blocker, "player": attacker.controller_index }) process_event("DEALS_DAMAGE", { "card": blocker, "target": attacker, "player": blocker.controller_index }) func _on_phase_changed(phase: Enums.TurnPhase) -> void: var event_type = "" match phase: Enums.TurnPhase.ACTIVE: event_type = "START_OF_ACTIVE_PHASE" Enums.TurnPhase.DRAW: event_type = "START_OF_DRAW_PHASE" Enums.TurnPhase.MAIN_1: event_type = "START_OF_MAIN_PHASE" Enums.TurnPhase.ATTACK: event_type = "START_OF_ATTACK_PHASE" Enums.TurnPhase.MAIN_2: event_type = "START_OF_MAIN_PHASE_2" Enums.TurnPhase.END: event_type = "START_OF_END_PHASE" if event_type: process_event(event_type, { "player": _game_state.turn_manager.current_player_index if _game_state else 0 }) func _on_turn_started(player_index: int, turn_number: int) -> void: process_event("START_OF_TURN", { "player": player_index, "turn_number": turn_number }) func _on_turn_ended(player_index: int) -> void: process_event("END_OF_TURN", { "player": player_index }) ## Trigger EX BURST for a damage card func _trigger_ex_burst(card: CardInstance, damaged_player: int) -> void: var card_abilities = get_abilities(card.card_data.id) for ability in card_abilities: var parsed = ability.get("parsed", {}) if parsed.get("is_ex_burst", false): # Queue the EX BURST ability _queue_ability({ "source": card, "ability": ability, "event_data": { "player": damaged_player, "trigger_type": "EX_BURST" } }) break ## Get power modifier from field effects for a card func get_field_power_modifier(card: CardInstance) -> int: return field_effect_manager.get_power_modifiers(card, _game_state) ## Check if a card has a field-granted keyword func has_field_keyword(card: CardInstance, keyword: String) -> bool: return field_effect_manager.has_keyword(card, keyword, _game_state) ## Check if a card has field-granted protection func has_field_protection(card: CardInstance, protection_type: String) -> bool: return field_effect_manager.has_protection(card, protection_type, _game_state) ## Get all granted keywords for a card from field effects func get_field_keywords(card: CardInstance) -> Array: return field_effect_manager.get_granted_keywords(card, _game_state) ## Trigger EX BURST on a specific card (called by EffectResolver) func trigger_ex_burst_on_card(card: CardInstance) -> void: if not card or not card.card_data: return var card_abilities = get_abilities(card.card_data.id) for ability in card_abilities: var parsed = ability.get("parsed", {}) if parsed.get("is_ex_burst", false): _queue_ability({ "source": card, "ability": ability, "event_data": { "trigger_type": "EX_BURST_TRIGGERED" } }) break