# -*- 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) 4. Structural Lexing: Balanced-brace scanning instead of greedy regex """ import re from typing import Any, Dict, List, Optional, Tuple from engine.models.ability import ( Ability, AbilityCostType, Condition, ConditionType, Cost, Effect, EffectType, TargetType, TriggerType, ) from .parser_lexer import StructuredEffect, StructuralLexer from .parser_costs import parse_pseudocode_costs from .parser_effect_normalization import normalize_select_hand_effect from .parser_effect_aliases import resolve_effect_aliases from .parser_grant_ability import parse_grant_ability_effect from .parser_play_member_resolution import resolve_play_member_source from .parser_target_resolution import resolve_target_type from .parser_semantics import looks_like_condition_instruction, parse_pseudocode_conditions from .parser_patterns import IGNORED_CONDITIONS, KEYWORD_CONDITIONS, MAX_SELECT_ALL, TRIGGER_ALIASES class AbilityParserV2: """Multi-pass ability parser using pattern registry.""" def __init__(self): pass def parse(self, text: str) -> List[Ability]: """Parse ability text into structured Ability objects.""" # Preprocess text = text.replace("
", "\n") # Detect format triggers = ["TRIGGER:", "CONDITION:", "EFFECT:", "COST:"] # Behavior blocks are handled if present, else fallback to pseudocode if text.strip().upper().startswith("BEHAVIOR:"): # Check if behavior parser exists (it was legacy/retired in some versions) if hasattr(self, "_parse_behavior_block"): return self._parse_behavior_block(text) return self._parse_pseudocode_block(text) return self._parse_pseudocode_block(text) # ========================================================================= # 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.""" # Normalized splitting: ensure each keyword starts a new line for kw in ["TRIGGER:", "CONDITION:", "EFFECT:", "COST:"]: text = text.replace(f"; {kw}", f"\n{kw}").replace(f";{kw}", f"\n{kw}") # Split by keywords but respect quotes # We want to identify blocks that belong together. # A block starts with one or more TRIGGER: lines followed by a body. lines = [line.strip() for line in text.split("\n") if line.strip()] # Filter out reminder-only lines at the top level # (e.g. (エールで出たスコア...)) filtered_lines = [] for line in lines: cleaned = line.strip() if ( cleaned.startswith("(") and cleaned.endswith(")") and not any(kw in cleaned.upper() for kw in ["TRIGGER:", "EFFECT:", "COST:", "CONDITION:"]) ): continue filtered_lines.append(line) lines = filtered_lines if not lines: return [] # Group lines into logical abilities # Each ability has a set of triggers and a set of instructions. current_triggers = [] current_body = [] ability_specs = [] for line in lines: # Check if it's a trigger line (can be multiple on one line if separated by semicolon) if line.upper().startswith("TRIGGER:"): # If we have a body from a previous trigger group, finalize it if current_body: ability_specs.append((current_triggers, current_body)) current_triggers = [] current_body = [] # Split and add all triggers from this line # Use regex to find all TRIGGER: instances matches = re.finditer(r"TRIGGER:\s*([^;]+)(?:;|$)", line, re.I) for m in matches: t_text = m.group(1).strip() if t_text: split_triggers = StructuralLexer.split_respecting_nesting(t_text, delimiter=",") for split_trigger in split_triggers: split_trigger = split_trigger.strip() if split_trigger: current_triggers.append(split_trigger) else: current_body.append(line) # Finalize last block if current_triggers or current_body: ability_specs.append((current_triggers, current_body)) abilities = [] for triggers, body in ability_specs: if not triggers: # Default to ACTIVATED if body exists but no trigger # BUT ONLY IF THERE IS SUBSTANCE (not just keywords or reminders) has_substance = False for line in body: cleaned = line.upper() if any(kw in cleaned for kw in ["EFFECT:", "COST:", "CONDITION:"]): has_substance = True break # Also check for non-parenthesized text if line.strip() and not (line.strip().startswith("(") and line.strip().endswith(")")): has_substance = True break if not has_substance: continue triggers = ["ACTIVATED"] # For each trigger, create a separate ability but sharing the same body content body_text = "\n".join(body) for t_val in triggers: full_text = f"TRIGGER: {t_val}\n{body_text}" ability = self._parse_single_pseudocode(full_text) if ability: 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 # Pre-pass: Remove parenthesized reminder text at the very start of the ability # unless it starts with a keyword. if lines and lines[0].startswith("(") and lines[0].endswith(")"): # If it's just reminder text like (エールで出たスコア...), skip it if no keywords inside inner = lines[0][1:-1].lower() keywords = ["trigger:", "effect:", "cost:", "condition:"] if not any(kw in inner for kw in keywords): lines = lines[1:] while i < len(lines): line = lines[i] upper_line = line.upper() # Check for Once per turn/Game flags globally for any instruction line low_line = line.lower() if "once per turn" in low_line or "once per game" in low_line or "(once per turn)" in low_line: is_once_per_turn = True if upper_line.startswith("TRIGGER:"): t_name = line[len("TRIGGER:") :].strip().upper() # Strip all content in parentheses (...) or braces {...} t_name = re.sub(r"\(.*?\)", "", t_name) t_name = re.sub(r"\{.*?\}", "", t_name).strip() # Use module-level constant for trigger aliases t_name = TRIGGER_ALIASES.get(t_name, t_name) try: trigger = TriggerType[t_name] except (KeyError, ValueError): trigger = getattr(TriggerType, t_name, TriggerType.NONE) elif upper_line.startswith("COST:"): cost_str = line[len("COST:") :].strip() new_costs = self._parse_pseudocode_costs(cost_str) for c in new_costs: # SYSTEMIC FIX: Sort costs between Activation Phase (Shell) and Execution Phase (Bytecode) # Mandatory initial costs stay in 'costs' for shell pre-checks. # Optional or mid-ability costs move to 'instructions' so the bytecode # interpreter can suspend for pay/skip interactions. # Complex costs (SELECT_MEMBER, etc.) MUST be in bytecode is_complex = c.type == AbilityCostType.NONE and c.params.get("cost_type_name") != "SELECT_SELF_OR_DISCARD" if not c.is_optional and not instructions and not is_complex: costs.append(c) else: instructions.append(c) elif upper_line.startswith("CONDITION:"): cond_str = line[len("CONDITION:") :].strip() new_conditions = self._parse_pseudocode_conditions(cond_str) # Only add to pre-activation pre-check conditions if NO effects or costs have been encountered yet if not effects and not costs: conditions.extend(new_conditions) instructions.extend(new_conditions) elif upper_line.startswith("EFFECT:"): eff_str = line[len("EFFECT:") :].strip() new_effects = self._parse_pseudocode_effects(eff_str, last_target=last_target, full_text=text) if new_effects: last_target = new_effects[-1].target if isinstance(new_effects[-1], Effect) else last_target # Filter out Conditions from the 'effects' list to avoid AttributeErrors in compiler effects.extend([e for e in new_effects if isinstance(e, Effect)]) instructions.extend(new_effects) elif upper_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].upper().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 upper_line.startswith("OPTION:"): # Format: OPTION: Description | EFFECT: Effect1; Effect2 | COST: Cost1 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) sub_instructions = [] # Parse Costs cost_part = next((p.strip() for p in parts if p.strip().startswith("COST:")), None) if cost_part: cost_str = cost_part.replace("COST:", "").strip() sub_costs = self._parse_pseudocode_costs(cost_str) sub_instructions.extend(sub_costs) # Parse Effects 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) # Filter for modal_options which expects List[Effect] usually, or Union sub_instructions.extend(sub_effects) # 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_instructions) 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, pseudocode=text, ) 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} or NAME(VAL)->EFFECT(VAL2) # Try to match name and val first m = re.match(r"(\w+)\((.*?)\)(.*)", p) if m: name, val_part, rest = m.groups() name_up = name.upper() etype = getattr(EffectType, name_up, EffectType.DRAW) # Check for target or chained effect in rest target = last_target chained_effect_str = "" arrow_match = re.search(r"->\s*([\w!]+)(\(.*\))?(.*)", rest) if arrow_match: target_or_eff_name = arrow_match.group(1).upper() inner_val = arrow_match.group(2) extra_rest = arrow_match.group(3) if hasattr(EffectType, target_or_eff_name): # Chained effect! chained_effect_str = f"{target_or_eff_name}{inner_val if inner_val else '()'} {extra_rest}" target = last_target # Keep last target for the first effect else: target = getattr(TargetType, target_or_eff_name, TargetType.PLAYER) rest = extra_rest # Parameters belong to the first effect params = self._parse_pseudocode_params(rest) val_int = 0 val_cond = ConditionType.NONE # Check if val_part is a condition type or contains multiple params val_up = str(val_part).upper() if "," in val_up: # Positional params in parentheses: META_RULE(SCORE_RULE, ALL_ENERGY_ACTIVE) v_parts = [vp.strip() for vp in val_part.split(",")] for vp in v_parts: vp_up = vp.upper() if vp_up == "SCORE_RULE": params["type"] = "SCORE_RULE" elif vp_up == "ALL_ENERGY_ACTIVE": params["rule"] = "ALL_ENERGY_ACTIVE" val_int = 1 # v=1 for SCORE_RULE: ALL_ENERGY_ACTIVE else: if "=" in vp: k, v = vp.split("=", 1) params[k.strip().lower()] = v.strip().strip("\"'") else: try: if val_int == 0: val_int = int(vp) except (TypeError, ValueError): pass elif hasattr(ConditionType, val_up): val_cond = getattr(ConditionType, val_up) else: try: val_int = int(val_part) except ValueError: val_int = 1 params["raw_val"] = val_part effects.append(Effect(etype, val_int, val_cond, target, params)) if chained_effect_str: # Recursively parse the chained effect effects.extend(self._parse_pseudocode_effects(chained_effect_str, last_target=target)) 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 {} params = {} if not param_str or param_str == "{}": return params # Remove outer braces content = param_str.strip() if content.startswith("{") and content.endswith("}"): content = content[1:-1] # Split by comma but respect quotes and brackets parts = [] current = "" in_quotes = False depth = 0 for char in content: if char == '"': in_quotes = not in_quotes elif char == "[": depth += 1 elif char == "]": depth -= 1 if char == "," and not in_quotes and depth == 0: parts.append(current.strip()) current = "" continue current += char if current: parts.append(current.strip()) for p in parts: # Handle special formats like COUNT_EQ_2 (without = sign) # Pattern: KEY_EQ_N or KEY_LE_N or KEY_GE_N etc. special_match = re.match(r"(COUNT_EQ|COUNT_LE|COUNT_GE|COUNT_LT|COUNT_GT)_(\d+)$", p.strip(), re.I) if special_match: key_part = special_match.group(1).upper() num_val = int(special_match.group(2)) params[key_part] = num_val continue if "=" in p: k, v = p.split("=", 1) k = k.strip().upper() v = v.strip().strip('"').strip("'") # Handle list values like [1, 2, 3] if v.startswith("[") and v.endswith("]"): items = [i.strip().strip('"').strip("'") for i in v[1:-1].split(",") if i.strip()] # Convert to ints if numeric v = [int(i) if i.isdigit() else i for i in items] # Handle numeric values elif v.isdigit(): v = int(v) elif v.upper() == "TRUE": v = True elif v.upper() == "FALSE": v = False # HEART_TYPE / HEART_0x mapping if k == "HEART_TYPE" or k == "HEART": if isinstance(v, str) and v.startswith("HEART_0"): # Map HEART_00..05 to 0..5 try: v = int(v[7:]) except ValueError: pass # Special color mapping for FILTER strings if k == "FILTER" and isinstance(v, str): h_map = { "HEART_00": "COLOR_PINK", "HEART_01": "COLOR_RED", "HEART_02": "COLOR_YELLOW", "HEART_03": "COLOR_GREEN", "HEART_04": "COLOR_BLUE", "HEART_05": "COLOR_PURPLE", } for old, new in h_map.items(): v = v.replace(old, new) params[k] = v else: # Single word like "UNIQUE_NAMES" or "ALL_AREAS" k = p.strip().upper() if k: params[k] = True return params def _parse_pseudocode_costs(self, text: str) -> List[Cost]: return parse_pseudocode_costs(self, text) def _serialize_condition_clause(self, cond: Condition) -> Dict[str, Any]: return { "type": int(cond.type), "value": int(cond.value), "attr": int(cond.attr), "is_negated": bool(cond.is_negated), "params": dict(cond.params), } def _parse_pseudocode_conditions(self, text: str) -> List[Condition]: return parse_pseudocode_conditions(self, text) def _parse_pseudocode_effects(self, text: str, last_target: TargetType = TargetType.PLAYER, full_text: str = "") -> List[Effect]: effects = [] # Use the shared split method parts = StructuralLexer.split_respecting_nesting(text, delimiter=";") for p in parts: if not p: continue # Special handling for GRANT_ABILITY(TARGET, "ABILITY") if "GRANT_ABILITY" in p: grant_effects = parse_grant_ability_effect(p) if grant_effects: effects.extend(grant_effects) continue # Special handling for CONDITION: inner instruction if p.upper().startswith("CONDITION:"): # Recursive call to condition parser cond_str = p[10:].strip() # These will be filtered out by the caller (parse method) effects.extend(self._parse_pseudocode_conditions(cond_str)) continue p = p.strip() # More robust regex that handles underscores, varies spacing, and mid-string (Optional) # Format: NAME(VAL) (Optional)? {PARAMS}? -> TARGET? REST m = re.match(r"^([\w_]+)(?:\((.*?)\))?\s*(?:\(Optional\)\s*)?(?:(\{.*?\})\s*)?(?:->\s*([\w, _]+))?(.*)$", p) if m: name, val, param_block, target_name, rest = m.groups() rest = rest or "" # If we matched (Optional) via the explicit group, we should ensure is_optional is set later. # The current logic checks for "(Optional)" in rest or p, which is sufficient. # Extract params only from the isolated {...} block if found params = self._parse_pseudocode_params(param_block) if param_block else {} # FALLBACK: If params block was at the end (after target), it will be in 'rest' if not params and rest and "{" in rest: import re as re_mod m_param = re_mod.search(r"(\{.*?\})", rest) if m_param: params = self._parse_pseudocode_params(m_param.group(1)) # Preserve chained destinations like: # SELECT_HAND(...) -> PLAY_STAGE_EMPTY -> TARGET_PLAYED chain_destinations = [] if target_name: chain_destinations.append(target_name.strip().upper()) if rest: chain_destinations.extend(m.group(1).strip().upper() for m in re.finditer(r"->\s*([\w_]+)", rest)) if chain_destinations: params["chain_destinations"] = chain_destinations params.setdefault("destination", chain_destinations[0].lower()) if len(chain_destinations) > 1: params["capture"] = chain_destinations[-1].lower() # Target Resolution target, is_chained = resolve_target_type(target_name, last_target, params) # Legacy SELF mapping (If -> SELF exists in text) if "-> SELF" in p or "-> self" in p: target = TargetType.MEMBER_SELF # Apply effect aliases using module-level constants name_up = name.upper() # Normalize aliases in one helper so the parser only owns structure. name_up, params, target = resolve_effect_aliases(name_up, params, target) # Special cases that need dynamic handling if name_up == "ADD_TAG": name_up = "META_RULE" params["tag"] = val if name_up == "SELECT_HAND": name_up, target = normalize_select_hand_effect(name_up, params, target) if name_up.startswith("PLAY_MEMBER"): name_up = resolve_play_member_source(name_up, params, p, full_text) etype = getattr(EffectType, name_up, None) # Special handling for SELECT_MODE labels in inline params if etype == EffectType.SELECT_MODE: option_names = [] for j in range(1, 11): key = f"OPTION_{j}" if val_from_params := (params.get(key) or params.get(key.lower())): option_names.append(str(val_from_params)) if option_names: params["options"] = option_names if target_name and not is_chained: 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 elif target_name_up in {"ALL_PLAYERS", "PLAYER_AND_OPPONENT"}: target = TargetType.ALL_PLAYERS else: t_part = target_name.split(",")[0].strip() target = getattr(TargetType, t_part.upper(), last_target) if "DISCARD_REMAINDER" in target_name_up: params["destination"] = "discard" # Variable targeting support: if target is "TARGET" or "TARGET_MEMBER" or "SLOT", use last_target if target_name_up in ["TARGET", "TARGET_MEMBER", "SLOT"]: 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 elif not is_chained: target = TargetType.PLAYER if name.upper() == "LOOK_AND_CHOOSE_REVEAL" and "DISCARD_REMAINDER" in p.upper(): params["destination"] = "discard" if etype is None: # Fallback to condition parser if name is a known condition alias if looks_like_condition_instruction(p): # Recursive call to condition parser for this single instruction effects.extend(self._parse_pseudocode_conditions(p)) continue # Safe fallback: unknown instructions become NOP (NONE) instead of broken META_RULE etype = EffectType.NONE 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 # SPECIAL HANDLING FOR REVEAL_UNTIL WITH COMMA-SEPARATED FILTERS # Must happen BEFORE comma parsing to properly extract TYPE and COST conditions if etype == EffectType.REVEAL_UNTIL and val and "," in val: # Process: REVEAL_UNTIL(TYPE_LIVE, COST_GE_10) or REVEAL_UNTIL(TYPE_MEMBER, COST_GE_10) parts = [p.strip() for p in val.split(",")] for part in parts: if "TYPE_LIVE" in part: val_cond = ConditionType.TYPE_CHECK params["card_type"] = "live" elif "TYPE_MEMBER" in part: val_cond = ConditionType.TYPE_CHECK params["card_type"] = "member" elif part.startswith("COST_"): # Extract COST_GE=10 or COST_GE_10, COST_LE=X, etc. cost_match = re.search(r"COST_(GE|LE|GT|LT|EQ)([=_])(\d+)", part) if cost_match: comp, sep, cval = cost_match.groups() val_cond = ConditionType.COST_CHECK params["comparison"] = comp params["value"] = int(cval) else: # Unknown part, store as raw if part and not part.startswith("COST_"): params[part.lower()] = True # Check for comma-separated positional params in val (e.g. META_RULE(SCORE_RULE, ALL_ENERGY_ACTIVE)) elif val and "," in val: v_parts = [vp.strip() for vp in val.split(",")] for vp in v_parts: vp_up = vp.upper() if vp_up == "SCORE_RULE": params["type"] = "SCORE_RULE" elif vp_up == "ALL_ENERGY_ACTIVE": params["rule"] = "ALL_ENERGY_ACTIVE" val_int = 1 # v=1 for SCORE_RULE: ALL_ENERGY_ACTIVE else: if "=" in vp: k, v = vp.split("=", 1) params[k.strip().lower()] = v.strip().strip("\"'") else: try: if val_int == 0: val_int = int(vp) except (TypeError, ValueError): pass # Check if val is a condition type (e.g. COUNT_STAGE) elif val and hasattr(ConditionType, val): val_cond = getattr(ConditionType, val) elif etype == EffectType.REVEAL_UNTIL and val: # Special parsing for REVEAL_UNTIL(CONDITION) - single 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 (supports both = and _ separators) if "COST_" in val: # Extract COST_GE=10 or COST_GE_10, COST_LE=X, etc. cost_match = re.search(r"COST_(GE|LE|GT|LT|EQ)([=_])(\d+)", val) if cost_match: comp, sep, 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 and val_cond == ConditionType.NONE: val_cond = ConditionType.COST_CHECK m_cost = re.search(r"COST_GE([=_])(\d+)", val) if m_cost: params["min"] = int(m_cost.group(2)) if val_cond == ConditionType.NONE: try: val_int = int(val) except ValueError: val_int = 1 if val: params["raw_val"] = val else: # Handle comma-separated values inside parentheses, e.g., NAME(VAL, OPT="X") if val and "," in val: inner_parts = val.split(",") val = inner_parts[0].strip() for inner_p in inner_parts[1:]: inner_p = inner_p.strip() if "=" in inner_p: ik, iv = inner_p.split("=", 1) ik = ik.strip().lower() iv = iv.strip().strip('"').strip("'") params[ik] = iv else: params[inner_p.lower()] = True try: val_int = int(val) if val else 1 except ValueError: val_int = 1 # Fallback for non-numeric val (e.g. "ALL") if val: params["raw_val"] = val if val == "ALL": val_int = MAX_SELECT_ALL elif val == "OPPONENT": target = TargetType.OPPONENT target_name = "OPPONENT" elif val == "PLAYER": target = TargetType.PLAYER target_name = "PLAYER" if etype == EffectType.ENERGY_CHARGE: if params.get("wait") or params.get("mode") == "WAIT": params["wait"] = True # Special parsing for TRANSFORM_COLOR(ALL) -> X if etype == EffectType.TRANSFORM_COLOR: # If val was "ALL", int(val) would have yielded 1 or 99. # We want 'a' (source) to be 0 (all) and 'v' (destination) to be the target number. if val == "ALL": # Determine destination from target_name (e.g. -> 5) try: # The pseudocode usually uses 1-indexed colors (1=Pink, 5=Blue). # The engine uses 0-indexed (0=Pink, 4=Blue). val_int = int(target_name) - 1 except (ValueError, TypeError): val_int = 0 # Default to pink if unknown # Source (a) is encoded in attr in ability.py, so we just set it here params["source_color"] = 0 target = TargetType.PLAYER # Reset target so it doesn't try to target member '5' elif val and val.isdigit(): # Standard TRANSFORM_COLOR(src) -> dst try: source_color = int(val) dest_color = int(target_name) - 1 if target_name and target_name.isdigit() else 0 val_int = max(0, dest_color) params["source_color"] = source_color target = TargetType.PLAYER except (TypeError, ValueError): pass # Special parsing for TRANSFORM_BLADES(ALL) -> X if etype == EffectType.TRANSFORM_BLADES: # If val was "ALL", we want to extract the destination from target_name if val == "ALL" and target_name: try: val_int = int(target_name) # Clear destination since we've extracted the value if "destination" in params and params["destination"] == target_name: del params["destination"] target = TargetType.MEMBER_SELF # targeting the selected member except (ValueError, TypeError): val_int = 99 # Fallback to ALL if not a number elif val and val.isdigit(): # Direct value: TRANSFORM_BLADES(3) -> TARGET val_int = int(val) if etype == EffectType.LOOK_AND_CHOOSE and "choose_count" not in params: params["choose_count"] = 1 # Special handling for SET_HEART_COST - parse array format [2,2,3,3,6,6] if etype == EffectType.SET_HEART_COST and val: # Check if val is an array format like [2,2,3,3,6,6] if val.startswith("[") and val.endswith("]"): # Parse the array and convert to raw_val format params["raw_val"] = val val_int = 0 else: # Try to parse as heart cost string like "2xYELLOW,2xGREEN,2xPURPLE" # Strip quotes from the value clean_val = val.strip('"').strip("'") params["raw_val"] = clean_val effects.append(Effect(etype, val_int, val_cond, target, params, is_optional=is_opt)) last_target = target # --- SELECT_MODE: Convert inline OPTION_N params to modal_options --- if etype == EffectType.SELECT_MODE: option_keys = sorted( [k for k in params if re.match(r"OPTION_\d+", str(k), re.I)], key=lambda k: int(re.search(r"\d+", k).group()), ) if option_keys: modal_opts = [] for ok in option_keys: opt_text = str(params[ok]) # Parse each option value as an effect string sub_effects = self._parse_pseudocode_effects(opt_text) modal_opts.append(sub_effects) effects[-1].modal_options = modal_opts # Clean up OPTION_N keys from params for ok in option_keys: del params[ok] if is_chained: chained_str = f"{target_name}{rest}" effects.extend(self._parse_pseudocode_effects(chained_str, 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)