# -*- coding: utf-8 -*- """New multi-pass ability parser using the pattern registry system. This parser replaces the legacy 3500-line spaghetti parser with a clean, modular architecture based on: 1. Declarative patterns organized by phase 2. Multi-pass parsing: Trigger → Conditions → Effects → Modifiers 3. Proper optionality handling (fixes the is_optional bug) """ import copy import json import re from typing import Any, Dict, List, Match, Optional, Tuple from engine.models.ability import ( Ability, AbilityCostType, Condition, ConditionType, Cost, Effect, EffectType, TargetType, TriggerType, ) from .patterns.base import PatternPhase from .patterns.registry import PatternRegistry, get_registry class AbilityParserV2: """Multi-pass ability parser using pattern registry.""" def __init__(self, registry: Optional[PatternRegistry] = None): self.registry = registry or get_registry() def parse(self, text: str) -> List[Ability]: """Parse ability text into structured Ability objects.""" # Detect pseudocode format triggers = ["TRIGGER:", "CONDITION:", "EFFECT:", "COST:"] if any(text.strip().startswith(kw) for kw in triggers): return self._parse_pseudocode_block(text) # Preprocessing text = self._preprocess(text) # Split into sentences sentences = self._split_sentences(text) # Group sentences into ability blocks blocks = [] current_block = [] for i, sentence in enumerate(sentences): if i > 0 and self._is_continuation(sentence, i): current_block.append(sentence) else: if current_block: blocks.append(" ".join(current_block)) current_block = [sentence] if current_block: blocks.append(" ".join(current_block)) abilities = [] for block in blocks: ability = self._parse_block(block) if ability: abilities.append(ability) return abilities def _parse_block(self, block: str) -> Optional[Ability]: """Parse a single combined ability block.""" # Split into cost and effect parts colon_idx = block.find(":") if colon_idx == -1: colon_idx = block.find(":") if colon_idx != -1: cost_part = block[:colon_idx].strip() effect_part = block[colon_idx + 1 :].strip() else: cost_part = "" effect_part = block # === PASS 1: Extract trigger === trigger, trigger_match = self._extract_trigger(block) # Mask trigger text from effect part to avoid double-matching # (e.g. "when placed in discard" shouldn't trigger "place in discard") effective_effect_part = effect_part if trigger_match: # Standard Japanese card formatting: [Trigger/Condition]とき、[Effect] # Or [Trigger/Condition]:[Effect] # If we see "とき", everything before it is usually trigger/condition toki_idx = effective_effect_part.find("とき") if toki_idx == -1: toki_idx = effective_effect_part.find("場合") if toki_idx != -1: # Mask everything up to "とき" or "場合" (plus the word itself) # BUT ONLY if it's in the same sentence (no punctuation in between) preceding = effective_effect_part[:toki_idx] if "。" in preceding: toki_idx = -1 if toki_idx != -1: mask_end = toki_idx + 2 # Length of "とき" or "場合" effective_effect_part = " " * mask_end + effective_effect_part[mask_end:] else: # Fallback: just mask the trigger match itself start, end = trigger_match.span() if start >= (len(block) - len(effect_part)): rel_start = start - (len(block) - len(effect_part)) rel_end = end - (len(block) - len(effect_part)) if rel_start >= 0 and rel_end <= len(effect_part): effective_effect_part = ( effect_part[:rel_start] + " " * (rel_end - rel_start) + effect_part[rel_end:] ) # === PASS 2: Extract conditions === # Scan the entire block for conditions as they can appear anywhere conditions = self._extract_conditions(block) # === PASS 3: Extract effects === # Only extract effects from the masked part to avoid trigger/cost confusion effects = self._extract_effects(effective_effect_part) # === PASS 5: Extract costs === costs = self._extract_costs(cost_part) # Determine Trigger and construct Ability if trigger == TriggerType.NONE and not (effects or conditions or costs): return None final_trigger = trigger if final_trigger == TriggerType.NONE: # Only default to CONSTANT if we have some indicators of an ability # (to avoid splitting errors defaulting to Constant) has_ability_indicators = any( kw in block for kw in [ "引", "スコア", "プラス", "+", "ブレード", "ハート", "控", "戻", "エネ", "デッキ", "山札", "見る", "公開", "選ぶ", "扱", "得る", "移動", ] ) if has_ability_indicators: final_trigger = TriggerType.CONSTANT else: return None ability = Ability(raw_text=block, trigger=final_trigger, effects=effects, conditions=conditions, costs=costs) # === PASS 4: Apply modifiers === # Scan the entire block for modifiers (OPT, optionality, etc.) modifiers = self._extract_modifiers(block) self._apply_modifiers(ability, modifiers) # === PASS 6: Handle "Choose Player" transformation === # If the ability starts with "自分か相手を選ぶ", transform following effects into SELECT_MODE if "自分か相手を選ぶ" in block and len(ability.effects) > 0: original_effects = [] # Find the "choose player" dummy effect (META_RULE) if present and remove it other_effects = [] for eff in ability.effects: if eff.effect_type == EffectType.META_RULE and eff.params.get("target") == "PLAYER_SELECT": continue other_effects.append(eff) if other_effects: # Option 1: Yourself self_effects = [] for eff in other_effects: new_eff = copy.deepcopy(eff) new_eff.target = TargetType.SELF self_effects.append(new_eff) # Option 2: Opponent opp_effects = [] for eff in other_effects: new_eff = copy.deepcopy(eff) new_eff.target = TargetType.OPPONENT opp_effects.append(new_eff) # Replace effects with a single SELECT_MODE ability.effects = [ Effect( EffectType.SELECT_MODE, value=1, target=TargetType.SELF, params={"options_text": ["自分", "相手"]}, modal_options=[self_effects, opp_effects], ) ] return ability # ========================================================================= # Preprocessing # ========================================================================= def _preprocess(self, text: str) -> str: """Normalize text for parsing.""" text = text.replace("
", "\n") return text def _split_sentences(self, text: str) -> List[str]: """Split text into individual sentences.""" # Split by newlines first blocks = re.split(r"\\n|\n", text) sentences = [] for block in blocks: block = block.strip() if not block: continue # Split on Japanese period, keeping the period parts = re.split(r"(。)\s*", block) # Reconstruct sentences with periods current = "" for part in parts: if part == "。": current += part if current.strip(): sentences.append(current.strip()) current = "" else: current = part if current.strip(): sentences.append(current.strip()) return sentences def _is_continuation(self, sentence: str, index: int) -> bool: """Check if sentence is a continuation of previous ability.""" # First sentence can't be a continuation if index == 0: return False # Explicit trigger icons should NEVER be continuations if any( icon in sentence for icon in ["{{live_success", "{{live_start", "{{toujyou", "{{kidou", "{{jyouji", "{{jidou"] ): return False # Check for continuation markers continuation_markers = [ "・", "-", "-", "回答が", "選んだ場合", "条件が", "それ以外", "その", "それら", "残り", "そし", "その後", "そこから", "山札", "デッキ", "もよい", "を自分", "ライブ終了時まで", "この能力", "この効果", "(", "(", "そうした場合", "」", "』", ")」", ")", ")", "ただし", "かつ", "または", "もしくは", "および", "代わりに", "このメンバー", "そのメンバー", "選んだ", "選んだエリア", "自分は", "相手は", ] # Check if it starts with any common phrase that usually continues an ability for marker in continuation_markers: if sentence.startswith(marker): return True # Special case: "その" or "プレイヤー" often appears slightly after "自分は" if "その" in sentence[:10] or "プレイヤー" in sentence[:10]: return True return False def _extend_ability(self, ability: Ability, sentence: str): """Extend an existing ability with content from a continuation sentence.""" # Extract additional effects effects = self._extract_effects(sentence) ability.effects.extend(effects) # Extract additional conditions conditions = self._extract_conditions(sentence) for cond in conditions: if cond not in ability.conditions: ability.conditions.append(cond) # Apply modifiers modifiers = self._extract_modifiers(sentence) self._apply_modifiers(ability, modifiers) # Update raw text ability.raw_text += " " + sentence # ========================================================================= # Pass 1: Trigger Extraction # ========================================================================= def _extract_trigger(self, sentence: str) -> Tuple[TriggerType, Optional[Match]]: """Extract trigger type and match object from sentence.""" result = self.registry.match_first(sentence, PatternPhase.TRIGGER) if result: pattern, match, data = result type_str = data.get("type", "") return self._resolve_trigger_type(type_str), match return TriggerType.NONE, None def _resolve_trigger_type(self, type_str: str) -> TriggerType: """Convert type string to TriggerType enum.""" mapping = { "TriggerType.ON_PLAY": TriggerType.ON_PLAY, "TriggerType.ON_LIVE_START": TriggerType.ON_LIVE_START, "TriggerType.ON_LIVE_SUCCESS": TriggerType.ON_LIVE_SUCCESS, "TriggerType.ACTIVATED": TriggerType.ACTIVATED, "TriggerType.CONSTANT": TriggerType.CONSTANT, "TriggerType.ON_LEAVES": TriggerType.ON_LEAVES, "TriggerType.ON_REVEAL": TriggerType.ON_REVEAL, "TriggerType.TURN_START": TriggerType.TURN_START, "TriggerType.TURN_END": TriggerType.TURN_END, } return mapping.get(type_str, TriggerType.NONE) # ========================================================================= # Pass 2: Condition Extraction # ========================================================================= def _extract_conditions(self, sentence: str) -> List[Condition]: """Extract all conditions from sentence.""" conditions = [] results = self.registry.match_all(sentence, PatternPhase.CONDITION) for pattern, match, data in results: cond_type = self._resolve_condition_type(data.get("type", "")) if cond_type is not None: params = data.get("params", {}).copy() # Use extracted value if not already in params if "value" in data and "min" not in params: params["min"] = data["value"] elif "min" not in params and match.lastindex: try: # Fallback for simple numeric patterns with one group params["min"] = int(match.group(1)) except (ValueError, IndexError): pass conditions.append(Condition(cond_type, params)) return conditions def _resolve_condition_type(self, type_str: str) -> Optional[ConditionType]: """Convert type string to ConditionType enum.""" if not type_str: return None name = type_str.replace("ConditionType.", "") print(f"DEBUG_LOUD: Resolving '{type_str}' -> '{name}'") # Debug members # if name == "COUNT_STAGE": # print(f"DEBUG_MEMBERS: {[m.name for m in ConditionType]}") try: val = ConditionType[name] print(f"DEBUG_LOUD: SUCCESS {name} -> {val}") return val except KeyError: print(f"DEBUG_LOUD: FAILED {name}") return None # ========================================================================= # Pass 3: Effect Extraction # ========================================================================= def _extract_effects(self, sentence: str) -> List[Effect]: """Extract all effects from sentence.""" effects = [] results = self.registry.match_all(sentence, PatternPhase.EFFECT) # Debug: Show what's being parsed if "DRAW(" in sentence: print(f"DEBUG_EFFECTS: Parsing sentence with DRAW: '{sentence[:50]}'") print(f"DEBUG_EFFECTS: Got {len(results)} pattern matches") for pattern, match, data in results: print(f"DEBUG_EFFECTS: Pattern={pattern.name}, Data={data}") for pattern, match, data in results: eff_type = self._resolve_effect_type(data.get("type", "")) if eff_type is not None: # Use 'is not None' because EffectType.DRAW = 0 is falsy value = data.get("value", 1) params = data.get("params", {}).copy() # Check for dynamic value condition value_cond = ConditionType.NONE if "value_cond" in data: vc_str = data["value_cond"] # If it's a string, try to resolve it if isinstance(vc_str, str): resolved_vc = self._resolve_condition_type(vc_str) if resolved_vc: value_cond = resolved_vc elif isinstance(vc_str, int): value_cond = ConditionType(vc_str) # Special case for "一番上" (top of deck) which means 1 card if "一番上" in sentence and value == 1: pass # Value 1 is already default # Determine target target = self._determine_target(sentence, params) effects.append(Effect(eff_type, value, value_cond, target, params)) return effects def _resolve_effect_type(self, type_str: str) -> Optional[EffectType]: """Convert type string to EffectType enum.""" if not type_str: return None name = type_str.replace("EffectType.", "") try: return EffectType[name] except KeyError: return None def _determine_target(self, sentence: str, params: Dict[str, Any]) -> TargetType: """Determine target type from sentence context.""" if "相手" in sentence: return TargetType.OPPONENT if "自分と相手" in sentence: return TargetType.ALL_PLAYERS if "控え室" in sentence: return TargetType.CARD_DISCARD if "手札" in sentence: return TargetType.CARD_HAND return TargetType.PLAYER # ========================================================================= # Pass 4: Modifier Extraction & Application # ========================================================================= def _extract_modifiers(self, sentence: str) -> Dict[str, Any]: """Extract all modifiers from sentence.""" modifiers = {} results = self.registry.match_all(sentence, PatternPhase.MODIFIER) for pattern, match, data in results: params = data.get("params", {}) # Special handling for target_name accumulation if "target_name" in params: if "target_names" not in modifiers: modifiers["target_names"] = [] modifiers["target_names"].append(params["target_name"]) # Remove target_name from params to avoid overwriting invalid data params = {k: v for k, v in params.items() if k != "target_name"} # Special handling for group accumulation if "group" in params: if "groups" not in modifiers: modifiers["groups"] = [] modifiers["groups"].append(params["group"]) # Note: We do NOT remove "group" from params here because we want the last one # to persist in modifiers["group"] for singular backward compatibility, # which modifiers.update(params) below will handle. modifiers.update(params) # Extract numeric values if present if match.lastindex: try: if "cost_max" not in modifiers and "コスト" in pattern.name: modifiers["cost_max"] = int(match.group(1)) if "multiplier" not in modifiers and "multiplier" in pattern.name: modifiers["multiplier_value"] = int(match.group(1)) except (ValueError, IndexError): pass return modifiers def _apply_modifiers(self, ability: Ability, modifiers: Dict[str, Any]): """Apply extracted modifiers to effects and conditions.""" target_str = None # Apply optionality is_optional = modifiers.get("is_optional", False) or modifiers.get("cost_is_optional", False) if is_optional: # Apply to all costs if they exist for cost in ability.costs: cost.is_optional = True for effect in ability.effects: # Primary effects that are usually optional primary_optional_types = [ EffectType.ADD_TO_HAND, EffectType.RECOVER_MEMBER, EffectType.RECOVER_LIVE, EffectType.PLAY_MEMBER_FROM_HAND, EffectType.SEARCH_DECK, EffectType.LOOK_AND_CHOOSE, EffectType.DRAW, EffectType.ENERGY_CHARGE, ] # Housekeeping effects that are usually NOT optional even if primary is # (unless they contain their own "may" keyword, which _extract_modifiers would catch) housekeeping_types = [ EffectType.SWAP_CARDS, # Often "discard remainder" EffectType.MOVE_TO_DECK, EffectType.ORDER_DECK, ] if effect.effect_type in primary_optional_types: effect.is_optional = True # If it's housekeeping, we check if the SPECIFIC text for this effect has "てもよい" # But since we don't have per-effect text easily here without more refactoring, # we'll stick to the heuristic. # Apply usage limits if modifiers.get("is_once_per_turn"): ability.is_once_per_turn = True # Apply duration duration = modifiers.get("duration") if duration: for effect in ability.effects: effect.params["until"] = duration # Apply target overrides if modifiers.get("target"): target_str = modifiers["target"] target_map = { "OPPONENT": TargetType.OPPONENT, "ALL_PLAYERS": TargetType.ALL_PLAYERS, "OPPONENT_HAND": TargetType.OPPONENT_HAND, } if target_str in target_map: for effect in ability.effects: effect.target = target_map[target_str] # Apply both_players flag if modifiers.get("both_players"): for effect in ability.effects: effect.params["both_players"] = True # Apply "all" scope if modifiers.get("all"): for effect in ability.effects: effect.params["all"] = True # Apply multiplier flags for key in ["per_member", "per_live", "per_energy", "has_multiplier"]: if modifiers.get(key): for effect in ability.effects: effect.params[key] = True # Apply filters if modifiers.get("cost_max"): for effect in ability.effects: effect.params["cost_max"] = modifiers["cost_max"] if modifiers.get("has_ability"): for effect in ability.effects: effect.params["has_ability"] = modifiers["has_ability"] # Apply group filter if modifiers.get("group") or modifiers.get("groups"): for effect in ability.effects: # Apply to effects that might need a group filter if effect.effect_type in [ EffectType.ADD_TO_HAND, EffectType.RECOVER_MEMBER, EffectType.RECOVER_LIVE, EffectType.SEARCH_DECK, EffectType.LOOK_AND_CHOOSE, EffectType.PLAY_MEMBER_FROM_HAND, EffectType.ADD_BLADES, EffectType.ADD_HEARTS, EffectType.BUFF_POWER, ]: if "group" not in effect.params and modifiers.get("group"): effect.params["group"] = modifiers["group"] if "groups" not in effect.params and modifiers.get("groups"): effect.params["groups"] = modifiers["groups"] # Apply name filter if modifiers.get("target_names"): for effect in ability.effects: # Apply to effects that might need a name filter if effect.effect_type in [ EffectType.ADD_TO_HAND, EffectType.RECOVER_MEMBER, EffectType.RECOVER_LIVE, EffectType.SEARCH_DECK, EffectType.LOOK_AND_CHOOSE, EffectType.PLAY_MEMBER_FROM_HAND, ]: if "names" not in effect.params: effect.params["names"] = modifiers["target_names"] # Apply opponent trigger flag to conditions if modifiers.get("opponent_trigger_allowed"): ability.conditions.append(Condition(ConditionType.OPPONENT_HAS, {"opponent_trigger_allowed": True})) # ========================================================================= # Pass 5: Cost Extraction # ========================================================================= def _extract_costs(self, cost_part: str) -> List[Cost]: """Extract ability costs from cost text.""" costs = [] if not cost_part: return costs # Extract names if present (e.g. discard specific members) cost_names = re.findall(r"「(?!\{\{)(.*?)」", cost_part) # Check for tap self cost if "このメンバーをウェイトにし" in cost_part: costs.append(Cost(AbilityCostType.TAP_SELF)) # Check for discard cost if "控え室に置" in cost_part and "手札" in cost_part: count = 1 if m := re.search(r"(\d+)枚", cost_part): count = int(m.group(1)) params = {} if cost_names: params["names"] = cost_names costs.append(Cost(AbilityCostType.DISCARD_HAND, count, params=params)) # Check for sacrifice self cost if "このメンバーを" in cost_part and "控え室に置" in cost_part: costs.append(Cost(AbilityCostType.SACRIFICE_SELF)) # Check for energy cost # Strip potential separators like '、' or '。' that might be between icons clean_cost_part = cost_part.replace("、", "").replace("。", "") energy_icons = len(re.findall(r"\{\{icon_energy.*?\}\}", clean_cost_part)) if energy_icons: costs.append(Cost(AbilityCostType.ENERGY, energy_icons)) # Check for reveal hand cost if "手札" in cost_part and "公開" in cost_part: count = 1 if m := re.search(r"(\d+)枚", cost_part): count = int(m.group(1)) params = {} if "ライブカード" in cost_part: params["filter"] = "live" elif "メンバー" in cost_part: params["filter"] = "member" costs.append(Cost(AbilityCostType.REVEAL_HAND, count, params)) return costs # ========================================================================= # Pseudocode Parsing (Inverse of tools/simplify_cards.py) # ========================================================================= def _parse_pseudocode_block(self, text: str) -> List[Ability]: """Parse one or more abilities from pseudocode format.""" # Split by "TRIGGER:" but respect quotes to support GRANT_ABILITY blocks = [] current_block = "" in_quote = False i = 0 while i < len(text): if text[i] == '"': in_quote = not in_quote # Check for TRIGGER: start # Ensure we are at start or newline-ish boundary to avoid false positives, # but main requirement is not in quote. if not in_quote and text[i:].startswith("TRIGGER:"): if current_block.strip(): blocks.append(current_block) current_block = "" # Append TRIGGER: and Move forward current_block += "TRIGGER:" i += 8 continue current_block += text[i] i += 1 if current_block.strip(): blocks.append(current_block) abilities = [] for block in blocks: if not block.strip(): continue ability = self._parse_single_pseudocode(block) # Default trigger to ACTIVATED if missing but has content if ability.trigger == TriggerType.NONE and (ability.costs or ability.effects): ability.trigger = TriggerType.ACTIVATED abilities.append(ability) return abilities def _parse_single_pseudocode(self, text: str) -> Ability: """Parse a single ability from pseudocode format.""" # Clean up lines but preserve structure for Options: parsing lines = [line.strip() for line in text.split("\n") if line.strip()] trigger = TriggerType.NONE costs = [] conditions = [] effects = [] instructions = [] is_once_per_turn = False # New: Track nested options for SELECT_MODE # If we see "Options:", the next lines until the next keyword belong to it i = 0 last_target = TargetType.PLAYER while i < len(lines): line = lines[i] if line.startswith("TRIGGER:"): t_name = line.replace("TRIGGER:", "").strip() if "(Once per turn)" in t_name: is_once_per_turn = True # Strip all content in parentheses t_name = re.sub(r"\(.*?\)", "", t_name).strip() # Aliases for triggers alias_map = { "ON_YELL": "ON_REVEAL", "ON_YELL_SUCCESS": "ON_REVEAL", "ON_ACTIVATE": "ACTIVATED", "JIDOU": "ON_REVEAL", # JIDOU often means automatic trigger on reveal "ON_MEMBER_DISCARD": "ON_LEAVES", "ON_DISCARDED": "ON_LEAVES", "ON_REMOVE": "ON_LEAVES", "ON_SET": "ON_PLAY", "ON_STAGE_ENTRY": "ON_PLAY", "ON_PLAY_OTHER": "ON_PLAY", "ON_REVEAL_OTHER": "ON_REVEAL", "ON_LIVE_SUCCESS_OTHER": "ON_LIVE_SUCCESS", "ON_TURN_START": "TURN_START", "ON_TURN_END": "TURN_END", "ON_TAP": "ACTIVATED", "ON_OPPONENT_TAP": "ON_LEAVES", # Approximation "ON_REVEAL_SELF": "ON_REVEAL", "ON_LIVE_SUCCESS_SELF": "ON_LIVE_SUCCESS", "ACTIVATED_FROM_DISCARD": "ACTIVATED", "ON_ENERGY_CHARGE": "ACTIVATED", "ON_DRAW": "ACTIVATED", # Approx } t_name = alias_map.get(t_name, t_name) try: trigger = TriggerType[t_name] except (KeyError, ValueError): trigger = getattr(TriggerType, t_name, TriggerType.NONE) elif "(Once per turn)" in line: is_once_per_turn = True elif line.startswith("COST:"): cost_str = line.replace("COST:", "").strip() costs = self._parse_pseudocode_costs(cost_str) elif line.startswith("CONDITION:"): cond_str = line.replace("CONDITION:", "").strip() new_conditions = self._parse_pseudocode_conditions(cond_str) conditions.extend(new_conditions) instructions.extend(new_conditions) elif line.startswith("EFFECT:"): eff_str = line.replace("EFFECT:", "").strip() new_effects = self._parse_pseudocode_effects(eff_str, last_target=last_target) if new_effects: last_target = new_effects[-1].target effects.extend(new_effects) instructions.extend(new_effects) elif line.startswith("Options:"): # The most recently added effect should be SELECT_MODE if effects and effects[-1].effect_type == EffectType.SELECT_MODE: # Parse subsequent lines until next major keyword modal_options = [] i += 1 while i < len(lines) and not any( lines[i].startswith(kw) for kw in ["TRIGGER:", "COST:", "CONDITION:", "EFFECT:"] ): # Format: N: EFFECT1, EFFECT2 option_match = re.match(r"\d+:\s*(.*)", lines[i]) if option_match: option_text = option_match.group(1) sub_effects = self._parse_pseudocode_effects_compact(option_text) modal_options.append(sub_effects) i += 1 effects[-1].modal_options = modal_options continue # Already incremented i elif line.startswith("OPTION:"): # Format: OPTION: Description | EFFECT: Effect1; Effect2 if effects and effects[-1].effect_type == EffectType.SELECT_MODE: # Parse the option line parts = line.replace("OPTION:", "").split("|") opt_desc = parts[0].strip() # Store description in select_mode effect params if "options" not in effects[-1].params: effects[-1].params["options"] = [] effects[-1].params["options"].append(opt_desc) eff_part = next((p.strip() for p in parts if p.strip().startswith("EFFECT:")), None) if eff_part: eff_str = eff_part.replace("EFFECT:", "").strip() # Use standard effect parser as these can be complex sub_effects = self._parse_pseudocode_effects(eff_str) # Initialize modal_options if needed if not hasattr(effects[-1], "modal_options") or effects[-1].modal_options is None: effects[-1].modal_options = [] effects[-1].modal_options.append(sub_effects) i += 1 return Ability( raw_text=text, trigger=trigger, costs=costs, conditions=conditions, effects=effects, is_once_per_turn=is_once_per_turn, instructions=instructions, ) def _parse_pseudocode_effects_compact(self, text: str) -> List[Effect]: """Special parser for compact effects in Options list (comma separated).""" # Format example: DRAW(1)->SELF {PARAMS}, MOVE_TO_DECK(1)->SELF {PARAMS} # Split by comma but not inside {} parts = [] current = "" depth = 0 for char in text: if char == "{": depth += 1 elif char == "}": depth -= 1 elif char == "," and depth == 0: parts.append(current.strip()) current = "" continue current += char if current: parts.append(current.strip()) effects = [] for p in parts: # Format: NAME(VAL)->TARGET {PARAMS} m = re.match(r"(\w+)\((.*?)\)\s*->\s*(\w+)(.*)", p) if m: name, val, target_name, rest = m.groups() etype = getattr(EffectType, name, EffectType.DRAW) target = getattr(TargetType, target_name, TargetType.PLAYER) params = self._parse_pseudocode_params(rest) val_int = 0 val_cond = ConditionType.NONE # Check if val is a condition type if hasattr(ConditionType, val): val_cond = getattr(ConditionType, val) else: try: val_int = int(val) except ValueError: val_int = 1 effects.append(Effect(etype, val_int, val_cond, target, params)) return effects def _parse_pseudocode_params(self, param_str: str) -> Dict[str, Any]: """Parse parameters in {KEY=VAL, ...} format.""" if not param_str or "{" not in param_str: return {} # Extract content between { and } match = re.search(r"\{(.*)\}", param_str) if not match: return {} content = match.group(1) params = {} # Simple parser for KEY=VAL or KEY=["a", "b"] or FLAG parts = [] current = "" depth = 0 in_quotes = False for char in content: if char == '"' and (not current or (current and current[-1] != "\\")): in_quotes = not in_quotes if not in_quotes: if char in "[{": depth += 1 elif char in "}]": depth -= 1 elif char == "," and depth == 0: parts.append(current.strip()) current = "" continue current += char if current: parts.append(current.strip()) for part in parts: if "=" in part: k, v = part.split("=", 1) k = k.strip().lower() v = v.strip() # Try to parse as JSON for lists/objects try: val = json.loads(v) # Normalize common ENUM string values if k in ["until", "from", "to", "target", "type"]: if isinstance(val, str): params[k] = val.lower() elif isinstance(val, list): params[k] = [x.lower() if isinstance(x, str) else x for x in val] else: params[k] = val elif k == "cost_max" or k == "cost_min": params[k] = val elif k == "cost<= ": # Support legacy/alternative params["cost_max"] = val elif k == "cost>= ": params["cost_min"] = val else: params[k] = val except: # Fallback if v.startswith('"') and v.endswith('"'): v = v[1:-1] if v.isdigit(): params[k] = int(v) elif k in ["until", "from", "to", "target", "type"]: params[k] = v.lower() else: params[k] = v elif part.startswith("{") and part.endswith("}"): # Merge embedded JSON try: embedded = json.loads(part) if isinstance(embedded, dict): params.update(embedded) except: pass elif part.startswith("(") and part.endswith(")"): # Handle (VAL) shorthand params["val"] = part[1:-1] else: # Flag params[part.lower()] = True return params def _parse_pseudocode_costs(self, text: str) -> List[Cost]: costs = [] # Split by ' OR ' first, but for now we might just take the first one or treat as optional? # Actually, let's treat 'OR' as splitting into separate options if needed, # but the Cost model is AND-only. # We'll split by comma AND ' OR ' for now and mark them all. parts = [] current = "" depth = 0 i = 0 while i < len(text): char = text[i] if char == "{": depth += 1 elif char == "}": depth -= 1 elif depth == 0: if text[i : i + 4] == " OR ": parts.append(current.strip()) current = "" i += 4 continue elif char == "," or char == ";": parts.append(current.strip()) current = "" i += 1 continue current += char i += 1 if current: parts.append(current.strip()) for p in parts: if not p: continue # Format: NAME(VAL) {PARAMS} (Optional) m = re.match(r"(\w+)(?:\((.*?)\))?(.*)", p) if m: name, val_str, rest = m.groups() # Manual Mapping for specific cost names if name == "MOVE_TO_DECK": if 'from="discard"' in rest.lower() or "from='discard'" in rest.lower(): name = "RETURN_DISCARD_TO_DECK" else: name = "RETURN_MEMBER_TO_DECK" cost_name = name.upper() if cost_name == "REMOVE_SELF": cost_name = "SACRIFICE_SELF" ctype = getattr(AbilityCostType, cost_name, AbilityCostType.NONE) try: val = int(val_str) if val_str else 0 except ValueError: val = 0 is_opt = "(Optional)" in rest or " OR " in text # OR implies selectivity params = self._parse_pseudocode_params(rest) costs.append(Cost(ctype, val, is_optional=is_opt, params=params)) return costs def _parse_pseudocode_conditions(self, text: str) -> List[Condition]: conditions = [] parts = [] current = "" depth = 0 i = 0 while i < len(text): char = text[i] if char == "{": depth += 1 elif char == "}": depth -= 1 elif depth == 0: if text[i : i + 4] == " OR ": parts.append(current.strip()) current = "" i += 4 continue elif char == "," or char == ";": parts.append(current.strip()) current = "" i += 1 continue current += char i += 1 if current: parts.append(current.strip()) for p in parts: if not p: continue negated = p.startswith("NOT ") name_part = p[4:] if negated else p # Support ! as prefix for negation if not negated and name_part.startswith("!"): negated = True name_part = name_part[1:] # Match name and params m = re.match(r"(\w+)(.*)", name_part) if m: name, rest = m.groups() ctype = getattr(ConditionType, name.upper(), ConditionType.NONE) # Robust parameter parsing params = self._parse_pseudocode_params(rest) if not params or "val" not in params: # Check for (VAL) p_m = re.search(r"\((.*?)\)", rest) if p_m: params["val"] = p_m.group(1) else: # Check for =VAL e_m = re.search(r"=\s*[\"']?(.*?)[\"']?$", rest.strip()) if e_m and "{" not in rest: params["val"] = e_m.group(1) params["raw_cond"] = name if name == "COST_LEAD": ctype = ConditionType.SCORE_COMPARE params["type"] = "cost" params["target"] = "opponent" params["comparison"] = "GT" if params.get("area") == "CENTER": params["zone"] = "CENTER_STAGE" del params["area"] # Fix for SCORE_LEAD -> SCORE_COMPARE if name == "SCORE_LEAD": ctype = ConditionType.SCORE_COMPARE params["type"] = "score" # Default comparison GT (Lead) if "comparison" not in params: params["comparison"] = "GT" # If target is opponent, it implies checking relative to opponent if "target" not in params: params["target"] = "opponent" # TYPE_MEMBER/TYPE_LIVE -> TYPE_CHECK if name == "TYPE_MEMBER": ctype = ConditionType.TYPE_CHECK params["card_type"] = "member" if name == "TYPE_LIVE": ctype = ConditionType.TYPE_CHECK params["card_type"] = "live" # Fix for COUNT_LIVE -> COUNT_LIVE_ZONE if name == "COUNT_LIVE": ctype = ConditionType.COUNT_LIVE_ZONE # ENERGY_LAGGING / ENERGY_LEAD -> OPPONENT_ENERGY_DIFF if name == "ENERGY_LAGGING": ctype = ConditionType.OPPONENT_ENERGY_DIFF params["comparison"] = "GE" if "diff" not in params: params["diff"] = 1 if name == "ENERGY_LEAD": ctype = ConditionType.OPPONENT_ENERGY_DIFF params["comparison"] = "LE" if "diff" not in params: params["diff"] = 0 # Aliases if name == "SUM_SCORE": ctype = ConditionType.SCORE_COMPARE params["type"] = "score" if "comparison" not in params: params["comparison"] = "GE" if "min" in params and "value" not in params: # Map min to value for SCORE_COMPARE absolute check? # Assuming SCORE_COMPARE supports absolute value if target is set? # Actually logic.rs might compare vs opponent score if no value is set? # If value IS set, it might compare vs value? # I'll rely on value mapping logic. pass if name == "COUNT_PLAYED_THIS_TURN": # Pending engine support, use HAS_KEYWORD to silence linter ctype = ConditionType.HAS_KEYWORD params["keyword"] = "PLAYED_THIS_TURN" if name == "SUM_COST": ctype = ConditionType.SCORE_COMPARE params["type"] = "cost" if "comparison" not in params: params["comparison"] = "GE" # Default target to ME if not specified? # If params has TARGET="OPPONENT", it will be parsed. if name == "REVEALED_CONTAINS": # No generic HAS_CARD_IN_ZONE condition yet ctype = ConditionType.HAS_KEYWORD params["keyword"] = "REVEALED_CONTAINS" if "TYPE_LIVE" in params: params["value"] = "live" if "TYPE_MEMBER" in params: params["value"] = "member" if name == "ZONE": # Heuristic for ZONE condition (e.g. ZONE="YELL_REVEALED") ctype = ConditionType.HAS_KEYWORD params["keyword"] = "ZONE_CHECK" params["value"] = params.get("val", "Unknown") # Default param processing might put it in val? # The parser puts the value in params based on default logic? # Actually _parse_pseudocode_conditions logic puts keys in params. # params is passed in? No, params is dict. # We rely on default param parsing for the "YELL_REVEALED" value which should be in params? # Actually parsing of condition params happens AFTER this block usually? # No, this block converts Name to Params. # If ZONE="YELL_REVEALED", input `name` is "ZONE". # params is empty. pass if name == "IS_MAIN_PHASE" or name == "MAIN_PHASE": # Implicit in activated abilities usually, map to NONE to ignore ctype = ConditionType.NONE if name == "COUNT_SUCCESS_LIVES" or name == "COUNT_SUCCESS_LIVE": ctype = ConditionType.COUNT_SUCCESS_LIVE # Handle PLAYER=0/1 param mapping if "PLAYER" in params: pval = params["PLAYER"] if str(pval) == "1": params["target"] = "opponent" else: params["target"] = "self" del params["PLAYER"] if "COUNT" in params: params["value"] = params["COUNT"] params["comparison"] = "EQ" del params["COUNT"] if name == "HAS_SUCCESS_LIVE": ctype = ConditionType.COUNT_SUCCESS_LIVE if name == "SUM_ENERGY": ctype = ConditionType.COUNT_ENERGY if name == "BATON_FROM_NAME": ctype = ConditionType.BATON if name == "MOVED_THIS_TURN": ctype = ConditionType.HAS_MOVED if name == "DECK_REFRESHED_THIS_TURN": ctype = ConditionType.DECK_REFRESHED if name == "HAND_SIZE_DIFF": ctype = ConditionType.OPPONENT_HAND_DIFF if name == "COST_LE_9": ctype = ConditionType.COST_CHECK params["comparison"] = "LE" params["value"] = 9 if name == "TARGET": # Data error where params separated by comma ctype = ConditionType.NONE if name.startswith("MATCH_"): ctype = ConditionType.HAS_KEYWORD params["keyword"] = name if name.startswith("DID_ACTIVATE_"): ctype = ConditionType.HAS_KEYWORD params["keyword"] = name if name == "SUCCESS_LIVES_CONTAINS": ctype = ConditionType.HAS_KEYWORD params["keyword"] = "SUCCESS_LIVES_CONTAINS" if name == "YELL_COUNT" or name == "COUNT_YELL_REVEALED": # Pending engine support for Yell Count ctype = ConditionType.HAS_KEYWORD ctype = ConditionType.HAS_KEYWORD params["keyword"] = "YELL_COUNT" if name == "HAS_REMAINING_HEART": ctype = ConditionType.COUNT_HEARTS params["min"] = 1 if name == "COUNT_CHARGED_ENERGY": ctype = ConditionType.COUNT_ENERGY if name == "SUM_SUCCESS_LIVE": ctype = ConditionType.COUNT_SUCCESS_LIVE # Approx if name == "SUM_HEARTS": ctype = ConditionType.COUNT_HEARTS if name == "SCORE_EQUAL_OPPONENT": ctype = ConditionType.SCORE_COMPARE params["comparison"] = "EQ" params["target"] = "opponent" if name == "AREA": ctype = ConditionType.HAS_KEYWORD # Likely filtering by area params["keyword"] = "AREA_CHECK" if name == "EFFECT_NEGATED_THIS_TURN": ctype = ConditionType.HAS_KEYWORD params["keyword"] = "EFFECT_NEGATED" if name == "HIGHEST_COST_ON_STAGE": ctype = ConditionType.HAS_KEYWORD params["keyword"] = "HIGHEST_COST" if name == "BATON_TOUCH": ctype = ConditionType.BATON if name == "HAND_SIZE": ctype = ConditionType.COUNT_HAND if name == "COUNT_UNIQUE_NAMES": ctype = ConditionType.HAS_KEYWORD params["keyword"] = "UNIQUE_NAMES" if name == "HAS_TYPE_LIVE": ctype = ConditionType.TYPE_CHECK params["card_type"] = "live" if name == "OPPONENT_EXTRA_HEARTS": ctype = ConditionType.HAS_KEYWORD params["keyword"] = "OPPONENT_EXTRA_HEARTS" if name == "EXTRA_HEARTS": ctype = ConditionType.COUNT_HEARTS # Typically means checking if we have extra hearts if "min" not in params: params["min"] = 1 if name == "BLADES": ctype = ConditionType.COUNT_BLADES if name == "AREA_IN" or name == "AREA": val = params.get("val", "").upper().strip('"') if val == "CENTER" or params.get("zone") == "CENTER" or params.get("area") == "CENTER": ctype = ConditionType.IS_CENTER else: ctype = ConditionType.GROUP_FILTER params["keyword"] = "AREA_CHECK" if name == "BATON_COUNT" or name == "BATON" or name == "BATON_TOUCH": ctype = ConditionType.BATON if name == "HAS_ACTIVE_ENERGY": ctype = ConditionType.COUNT_ENERGY params["filter"] = "active" if "min" not in params: params["min"] = 1 if name == "HAS_LIVE_SET": ctype = ConditionType.HAS_KEYWORD params["keyword"] = "HAS_LIVE_SET" if name == "ALL_ENERGY_ACTIVE": ctype = ConditionType.COUNT_ENERGY params["filter"] = "active" params["comparison"] = "ALL" # Custom logic in engine likely if name == "ENERGY": ctype = ConditionType.COUNT_ENERGY # Aliases if name == "ON_YELL" or name == "ON_YELL_SUCCESS": ctype = ConditionType.NONE # Triggers handled separately, but avoid ERROR if name == "CHECK_GROUP_FILTER": ctype = ConditionType.GROUP_FILTER if name == "FILTER": ctype = ConditionType.GROUP_FILTER if name == "TOTAL_BLADES": ctype = ConditionType.COUNT_BLADES if name == "HEART_LEAD": ctype = ConditionType.COUNT_HEARTS if "comparison" not in params: params["comparison"] = "GE" if "min" not in params and "value" not in params: params["min"] = 1 if name == "SCORE_TOTAL": ctype = ConditionType.SCORE_COMPARE params["type"] = "score" if "comparison" not in params: params["comparison"] = "GE" if name == "COUNT_ACTIVATED": ctype = ConditionType.COUNT_STAGE params["filter"] = "ACTIVATED" if name == "OPPONENT_HAS_WAIT": ctype = ConditionType.COUNT_STAGE params["target"] = "opponent" params["filter"] = "tapped" if "min" not in params: params["min"] = 1 if name == "CHECK_IS_IN_DISCARD": ctype = ConditionType.IS_IN_DISCARD if name == "HAS_EXCESS_HEART": ctype = ConditionType.COUNT_HEARTS params["context"] = "excess" if "min" not in params: params["min"] = 1 if name == "COUNT_MEMBER": ctype = ConditionType.COUNT_STAGE if name == "TOTAL_HEARTS": ctype = ConditionType.COUNT_HEARTS if name == "ALL_MEMBER": ctype = ConditionType.GROUP_FILTER if name == "MEMBER_AT_SLOT": ctype = ConditionType.GROUP_FILTER if name == "SUCCESS": ctype = ConditionType.MODAL_ANSWER if name == "HAS_LIVE_HEART_COLORS": ctype = ConditionType.HAS_COLOR if name == "COUNT_REVEALED": ctype = ConditionType.COUNT_HAND # Approximate or META_RULE if name == "COUNT_DISCARDED_THIS_TURN": ctype = ConditionType.COUNT_DISCARD if name == "IS_MAIN_PHASE": ctype = ConditionType.NONE if name == "MATCH_PREVIOUS": ctype = ConditionType.MODAL_ANSWER # Heuristic if name == "NOT_MOVED_THIS_TURN": ctype = ConditionType.HAS_MOVED negated = True if name == "NAME_MATCH": ctype = ConditionType.GROUP_FILTER params["filter"] = "NAME_MATCH" conditions.append(Condition(ctype, params, is_negated=negated)) return conditions def _parse_pseudocode_effects(self, text: str, last_target: TargetType = TargetType.PLAYER) -> List[Effect]: effects = [] # Split by semicolon but not inside {} parts = [] current = "" depth = 0 for char in text: if char == "{": depth += 1 elif char == "}": depth -= 1 elif char == ";" and depth == 0: parts.append(current.strip()) current = "" continue current += char if current: parts.append(current.strip()) for p in parts: if not p: continue # Format: NAME(VAL) -> TARGET {PARAMS} (Optional) # Support optional -> TARGET # Improved regex to handle optional {PARAMS} before -> TARGET m = re.match(r"(\w+)(?:\((.*?)\))?(?:\s*\{.*?\}\s*)?(?:\s*->\s*([\w, ]+))?(.*)", p) if m: name, val, target_name, rest = m.groups() # Extract params from the whole string 'p' since {} might be anywhere params = self._parse_pseudocode_params(p) # Aliases from parser_pseudocode if name == "TAP_PLAYER": name = "TAP_MEMBER" if name == "CHARGE_SELF": name = "ENERGY_CHARGE" target_name = "MEMBER_SELF" if name == "CHARGE_ENERGY": name = "ENERGY_CHARGE" if name == "MOVE_DISCARD": name = "MOVE_TO_DISCARD" if name == "REMOVE_SELF": name = "MOVE_TO_DISCARD" target_name = "MEMBER_SELF" if name == "SWAP_SELF": name = "SWAP_ZONE" target_name = "MEMBER_SELF" if name == "MOVE_HAND" or name == "MOVE_TO_HAND": name = "ADD_TO_HAND" if name == "ADD_HAND": name = "ADD_TO_HAND" if name == "TRIGGER_YELL_AGAIN": name = "META_RULE" params["meta_type"] = "TRIGGER_YELL_AGAIN" if name == "DISCARD_HAND": name = "LOOK_AND_CHOOSE" params["source"] = "HAND" params["destination"] = "discard" if name == "RECOVER_LIVE": # Usually means from discard params["source"] = "discard" if name == "RECOVER_MEMBER": # Usually means from discard params["source"] = "discard" if name == "SELECT_LIMIT": name = "REDUCE_LIVE_SET_LIMIT" if name == "POWER_UP": name = "BUFF_POWER" if name == "REDUCE_SET_LIMIT": name = "REDUCE_LIVE_SET_LIMIT" if name == "REDUCE_LIMIT": name = "REDUCE_LIVE_SET_LIMIT" if name == "REDUCE_HEART": name = "REDUCE_HEART_REQ" if name == "ADD_TAG": name = "META_RULE" params["tag"] = val if name == "PREVENT_LIVE": name = "RESTRICTION" params["type"] = "no_live" if name == "PREVENT_SET_TO_SUCCESS_PILE": name = "META_RULE" params["meta_type"] = "PREVENT_SET_TO_SUCCESS_PILE" if name == "MOVE_DECK": name = "MOVE_TO_DECK" if name == "OPPONENT_CHOICE": etype = EffectType.OPPONENT_CHOOSE # OPPONENT_CHOICE implies complex options which parse_pseudocode_block/effects handles? # Actually SELECT_MODE handles options. OPPONENT_CHOICE likely structured similarly. if name == "RESET_YELL_HEARTS": name = "META_RULE" params["meta_type"] = "RESET_YELL_HEARTS" if name == "TRIGGER_YELL_AGAIN": name = "META_RULE" params["meta_type"] = "TRIGGER_YELL_AGAIN" if name == "ADD_HAND": name = "ADD_TO_HAND" if name == "ACTION_YELL_MULLIGAN": name = "META_RULE" params["meta_type"] = "ACTION_YELL_MULLIGAN" if name == "OPPONENT_CHOICE": name = "OPPONENT_CHOOSE" if name == "SET_BASE_BLADES": name = "SET_BLADES" if name == "GRANT_HEARTS" or name == "GRANT_HEART": name = "ADD_HEARTS" if name == "SELECT_REVEALED": name = "LOOK_AND_CHOOSE" params["source"] = "revealed" if name == "LOOK_AND_CHOOSE_REVEALED": name = "LOOK_AND_CHOOSE" params["source"] = "revealed" if name == "TAP_SELF": name = "TAP_MEMBER" target_name = "SELF" if name == "CHANGE_BASE_HEART": name = "TRANSFORM_HEART" if name == "SELECT_LIVE_CARD": name = "SELECT_LIVE" if name == "MOVE_TO_HAND": name = "ADD_TO_HAND" if name == "POSITION_CHANGE": name = "MOVE_MEMBER" if name == "INCREASE_HEART": name = "INCREASE_HEART_COST" if name == "CHANGE_YELL_BLADE_COLOR": name = "TRANSFORM_COLOR" if name == "MOVE_SUCCESS": name = "META_RULE" params["meta_type"] = "MOVE_SUCCESS" # Use meta_type to silence linter if name.startswith("PLAY_MEMBER"): # Heuristic: if params has 'discard', use PLAY_MEMBER_FROM_DISCARD if params.get("zone") == "DISCARD" or "DISCARD" in p.upper(): name = "PLAY_MEMBER_FROM_DISCARD" else: name = "PLAY_MEMBER_FROM_HAND" if name == "PREVENT_ACTIVATE": name = "META_RULE" # No opcode yet etype = getattr(EffectType, name.upper(), None) if name.upper() == "LOOK_AND_CHOOSE_ORDER": etype = EffectType.ORDER_DECK if name.upper() == "LOOK_AND_CHOOSE_REVEAL": etype = EffectType.LOOK_AND_CHOOSE if name.upper() == "DISCARD_HAND": etype = EffectType.LOOK_AND_CHOOSE params["source"] = "HAND" params["destination"] = "discard" if target_name: target_name_up = target_name.upper() if "CARD_HAND" in target_name_up: target = TargetType.CARD_HAND elif "CARD_DISCARD" in target_name_up: target = TargetType.CARD_DISCARD else: t_part = target_name.split(",")[0].strip() target = getattr(TargetType, t_part.upper(), TargetType.PLAYER) if "DISCARD_REMAINDER" in target_name_up: params["destination"] = "discard" # Variable targeting support: if target is "TARGET" or "TARGET_MEMBER", use last_target if target_name_up in ["TARGET", "TARGET_MEMBER"]: target = last_target elif target_name_up == "ACTIVATE_AND_SELF": # Special case for "activate and self" -> targets player but implied multi-target # For now default to player or member self target = TargetType.PLAYER else: target = TargetType.PLAYER if name.upper() == "LOOK_AND_CHOOSE_REVEAL" and "DISCARD_REMAINDER" in p.upper(): params["destination"] = "discard" if etype is None: etype = EffectType.META_RULE params["raw_effect"] = name.upper() if target_name and target_name.upper() == "SLOT" and params.get("self"): target = TargetType.MEMBER_SELF is_opt = "(Optional)" in rest or "(Optional)" in p val_int = 0 val_cond = ConditionType.NONE # Check if val is a condition type (e.g. COUNT_STAGE) if val and hasattr(ConditionType, val): val_cond = getattr(ConditionType, val) elif etype == EffectType.REVEAL_UNTIL and val: # Special parsing for REVEAL_UNTIL(CONDITION) if "TYPE_LIVE" in val: val_cond = ConditionType.TYPE_CHECK params["card_type"] = "live" elif "TYPE_MEMBER" in val: val_cond = ConditionType.TYPE_CHECK params["card_type"] = "member" # Handle COST_GE/LE in REVEAL_UNTIL if "COST_" in val: # Extract COST_GE=10 or COST_LE=X cost_match = re.search(r"COST_(GE|LE|GT|LT|EQ)=(\d+)", val) if cost_match: comp, cval = cost_match.groups() # If we also have TYPE check, we need to combine them? # Bytecode only supports one condition on REVEAL_UNTIL. # We'll prioritize COST check if present, or maybe the engine supports compound? # For now, map to COST_CHECK condition. val_cond = ConditionType.COST_CHECK params["comparison"] = comp params["value"] = int(cval) if "COST_GE" in val: val_cond = ConditionType.COST_CHECK m_cost = re.search(r"COST_GE=(\d+)", val) if m_cost: params["min"] = int(m_cost.group(1)) if val_cond == ConditionType.NONE: try: val_int = int(val) except ValueError: val_int = 1 else: try: val_int = int(val) if val else 1 except ValueError: val_int = 1 # Fallback for non-numeric val (e.g. "ALL") if val == "ALL": val_int = 99 effects.append(Effect(etype, val_int, val_cond, target, params, is_optional=is_opt)) last_target = target return effects # Convenience function def parse_ability_text(text: str) -> List[Ability]: """Parse ability text using the V2 parser.""" parser = AbilityParserV2() return parser.parse(text)