rabukasim / compiler /parser_v2.py
trioskosmos's picture
Upload folder using huggingface_hub
463f868 verified
# -*- 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("<br>", "\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)