Spaces:
Running
Running
| # -*- 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("<br>", "\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) | |